Дашборд — экран, который открывают чаще всех остальных, и по нему судят о скорости всей админки. При этом он же самый тяжёлый: каждая плитка и каждый график — это агрегирующий запрос, а выполняются они все сразу при каждом открытии.
Разберём построение дашборда и то, без чего он становится самой медленной страницей проекта.
Главный экран — Dashboard
Дашборд — первое, что видит пользователь админки. Сделаем его информативным.
// app/Orchid/Screens/DashboardScreen.php
namespace App\Orchid\Screens;
use Orchid\Screen\Screen;
use Orchid\Support\Facades\Layout;
use App\Models\Order;
use App\Models\User;
use App\Models\Post;
class DashboardScreen extends Screen
{
public ?string $name = 'Дашборд';
public ?string $description = 'Обзор ключевых метрик';
public function query(): iterable
{
return [
'metrics' => $this->getMetrics(),
'salesChart' => $this->getSalesChart(),
'ordersChart' => $this->getOrdersChart(),
'recentOrders' => Order::with('user')
->latest()
->limit(5)
->get(),
];
}
public function layout(): iterable
{
return [
// Метрики
Layout::metrics([
'Выручка сегодня' => 'metrics.revenue_today',
'Заказов сегодня' => 'metrics.orders_today',
'Новых клиентов' => 'metrics.new_users',
'Статей' => 'metrics.posts_count',
]),
// Графики в колонках
Layout::columns([
Layout::view('admin.widgets.sales-chart', [
'data' => $this->query()['salesChart'],
]),
Layout::view('admin.widgets.orders-chart', [
'data' => $this->query()['ordersChart'],
]),
]),
// Последние заказы
Layout::table('recentOrders', [
TD::make('id', '№'),
TD::make('user.name', 'Клиент'),
TD::make('total', 'Сумма')
->render(fn($order) => number_format($order->total, 0, ',', ' ') . ' ₽'),
TD::make('created_at', 'Дата')
->render(fn($order) => $order->created_at->diffForHumans()),
]),
];
}
private function getMetrics(): array
{
return [
'revenue_today' => number_format(
Order::whereDate('created_at', today())
->where('status', 'completed')
->sum('total'),
0, ',', ' '
) . ' ₽',
'orders_today' => Order::whereDate('created_at', today())->count(),
'new_users' => User::whereDate('created_at', today())->count(),
'posts_count' => Post::where('is_published', true)->count(),
];
}
private function getSalesChart(): array
{
return Order::query()
->where('status', 'completed')
->where('created_at', '>=', now()->subDays(30))
->selectRaw('DATE(created_at) as date, SUM(total) as total')
->groupBy('date')
->orderBy('date')
->get()
->map(fn($item) => [
'date' => $item->date,
'total' => $item->total,
])
->toArray();
}
private function getOrdersChart(): array
{
return Order::query()
->where('created_at', '>=', now()->subDays(7))
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->get()
->toArray();
}
}Layout::metrics() — встроенный компонент для отображения ключевых показателей в карточках.
Метод query() выполняется при каждом открытии экрана. Восемь плиток с показателями — это восемь запросов вида SELECT COUNT(*) и SELECT SUM(...) по всем заказам за период, и все они уходят в базу синхронно, пока пользователь смотрит на пустой экран.
На тестовых данных незаметно; на боевой базе с сотнями тысяч заказов дашборд открывается секундами. Причём открывают его постоянно — это стартовая страница админки.
Что с этим делать:
Кэшируйте показатели. Выручка за прошлый месяц не меняется вообще, за сегодня — меняется постепенно. Кэш на 5–15 минут снимает почти всю нагрузку, а актуальность цифр на дашборде редко требуется до секунды.
Проверьте индексы на полях, по которым идёт группировка и фильтрация — обычно это дата создания и статус. Без них каждая плитка означает полное сканирование таблицы.
Считайте агрегаты заранее для действительно больших объёмов: ночная задача, складывающая дневные итоги в отдельную таблицу, превращает запрос по миллиону строк в запрос по тремстам.
Встроенные Charts
Line Chart
use Orchid\Screen\Layouts\Chart;
class SalesChart extends Chart
{
/**
* Ключ данных из query()
*/
protected $target = 'salesData';
/**
* Название графика
*/
protected $title = 'Продажи за месяц';
/**
* Высота
*/
protected $height = 300;
/**
* Доступные типы: line, bar, scatter, pie
*/
protected $type = self::TYPE_LINE;
/**
* Экспорт в CSV/JSON
*/
protected $export = true;
}
// В Screen
public function layout(): iterable
{
return [
SalesChart::class,
];
}
public function query(): iterable
{
return [
'salesData' => [
[
'name' => 'Продажи',
'values' => [100, 200, 150, 300, 250, 400, 350],
'labels' => ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
],
],
];
}Bar Chart
class OrdersChart extends Chart
{
protected $target = 'ordersData';
protected $title = 'Заказы по статусам';
protected $type = self::TYPE_BAR;
protected $colors = [
'#28a745', // completed
'#ffc107', // pending
'#dc3545', // cancelled
];
}
// Данные
'ordersData' => [
[
'name' => 'Выполнены',
'values' => [45],
],
[
'name' => 'В ожидании',
'values' => [12],
],
[
'name' => 'Отменены',
'values' => [3],
],
],Pie Chart
class CategoriesChart extends Chart
{
protected $target = 'categoriesData';
protected $title = 'Товары по категориям';
protected $type = self::TYPE_PIE;
}Кастомный виджет с Chart.js
Blade-шаблон
{{-- resources/views/admin/widgets/sales-chart.blade.php --}}
<div class="bg-white rounded shadow-sm p-4 mb-3">
<h5 class="mb-3">Продажи за 30 дней</h5>
<canvas id="salesChart" height="200"></canvas>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const data = @json($data);
const ctx = document.getElementById('salesChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(item => item.date),
datasets: [{
label: 'Выручка',
data: data.map(item => item.total),
borderColor: '#3490dc',
backgroundColor: 'rgba(52, 144, 220, 0.1)',
fill: true,
tension: 0.4,
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function(context) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(context.raw);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value.toLocaleString('ru-RU') + ' ₽';
}
}
}
}
}
});
});
</script>
@endpushИспользование
public function layout(): iterable
{
return [
Layout::view('admin.widgets.sales-chart', [
'data' => $this->query()['salesChart'],
]),
];
}Кастомный Layout-виджет
// app/Orchid/Layouts/StatsWidget.php
namespace App\Orchid\Layouts;
use Orchid\Screen\Layouts\Rows;
use Orchid\Screen\Fields\Label;
class StatsWidget extends Rows
{
private string $title;
private string $valueKey;
private string $icon;
private string $color;
public function __construct(string $title, string $valueKey, string $icon = 'graph', string $color = 'primary')
{
$this->title = $title;
$this->valueKey = $valueKey;
$this->icon = $icon;
$this->color = $color;
}
protected function fields(): iterable
{
$value = $this->query->get($this->valueKey);
return [
Label::make('widget')
->value(view('admin.widgets.stat-card', [
'title' => $this->title,
'value' => $value,
'icon' => $this->icon,
'color' => $this->color,
])),
];
}
}{{-- resources/views/admin/widgets/stat-card.blade.php --}}
<div class="card border-{{ $color }} mb-3">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<x-orchid-icon path="{{ $icon }}" class="h1 text-{{ $color }}"/>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="text-muted mb-1">{{ $title }}</h6>
<h3 class="mb-0">{{ $value }}</h3>
</div>
</div>
</div>
</div>// Использование
public function layout(): iterable
{
return [
Layout::columns([
new StatsWidget('Выручка', 'revenue', 'wallet', 'success'),
new StatsWidget('Заказы', 'orders_count', 'bag', 'primary'),
new StatsWidget('Клиенты', 'users_count', 'people', 'info'),
new StatsWidget('Товары', 'products_count', 'grid', 'warning'),
]),
];
}Real-time обновление
Периодический опрос — самая дорогая функция дашборда. Обновление раз в 10 секунд у пяти открытых вкладок в отделе продаж означает тридцать полных выполнений query() в минуту — то есть постоянная фоновая нагрузка от страницы, на которую никто не смотрит.
Прежде чем включать, ответьте на вопрос, нужна ли эта актуальность на самом деле. Для дашборда руководителя, который открывают утром, — нет. Для монитора на стене в отделе — да, но тогда интервал имеет смысл сделать минутным, а данные — кэшированными.
// Polling через Turbo
public function layout(): iterable
{
return [
Layout::view('admin.widgets.live-stats')
->async('asyncLoadStats'),
];
}
public function asyncLoadStats(): iterable
{
return [
'stats' => [
'online_users' => cache()->remember('online_users', 10, fn() =>
User::where('last_activity_at', '>=', now()->subMinutes(5))->count()
),
'pending_orders' => Order::where('status', 'pending')->count(),
],
];
}{{-- resources/views/admin/widgets/live-stats.blade.php --}}
<div data-turbo-poll="10000"> {{-- Обновление каждые 10 сек --}}
<div class="row">
<div class="col">
<div class="alert alert-info">
<strong>{{ $stats['online_users'] }}</strong> пользователей онлайн
</div>
</div>
<div class="col">
<div class="alert alert-warning">
<strong>{{ $stats['pending_orders'] }}</strong> заказов ожидают обработки
</div>
</div>
</div>
</div>Производительность: Real-time виджеты создают нагрузку. Используйте кэширование и разумные интервалы обновления (не чаще 10 сек).
Итоги
| Компонент | Назначение |
|---|---|
Layout::metrics() | Карточки с метриками |
Chart класс | Встроенные графики |
Layout::view() | Кастомные виджеты |
| Turbo polling | Real-time данные |
Структура дашборда:
┌─────────────┬─────────────┬─────────────┬─────────────┐
│ Выручка │ Заказы │ Клиенты │ Товары │
└─────────────┴─────────────┴─────────────┴─────────────┘
┌────────────────────────────┬────────────────────────────┐
│ График продаж │ График заказов │
└────────────────────────────┴────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Последние заказы │
└─────────────────────────────────────────────────────────┘
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.