Возможности Как это работает Тарифы Документация Cookbook Партнёрам Личный кабинет
Бизнес-кейсы
📊

Live-дашборд

9 мин чтения Средне

Обновление метрик и графиков в реальном времени без перезагрузки.

Analytics Charts JavaScript

📋 Что получится

  • Метрики обновляются в реальном времени
  • Графики без перезагрузки страницы
  • Алерты при критических значениях
  • Список активных пользователей
Любой бэкенд: Примеры на PHP, но паттерн универсален — работает с Node.js, Python, Go и др.

🏗️ Архитектура

📊
Источник данных
БД, API, IoT
🖥️
Ваш сервер
агрегация данных
Pushler.ru
WebSocket
📈
Дашборд
мгновенное обновление

💻 Код сервера

Сервер собирает метрики и отправляет их в реальном времени. Примеры на PHP.

Установка PHP SDK

Bash
composer require pushler/php-sdk

Сервис метрик

PHP
<?php
// MetricsService.php — сбор и отправка метрик

require_once 'vendor/autoload.php';

use PushlerRu\PushlerClient;

class MetricsService
{
    private PushlerClient $pushler;
    private PDO $pdo;

    public function __construct(string $appKey, string $appSecret, PDO $pdo)
    {
        $this->pushler = new PushlerClient($appKey, $appSecret);
        $this->pdo = $pdo;
    }

    /**
     * Собрать и отправить все метрики
     */
    public function broadcastMetrics(): void
    {
        $metrics = $this->collectMetrics();
        
        $this->pushler->trigger(
            'dashboard',
            'metrics:update',
            [
                'metrics' => $metrics,
                'timestamp' => date('c')
            ]
        );
    }

    /**
     * Сбор метрик из БД
     */
    public function collectMetrics(): array
    {
        return [
            'users' => $this->getUsersMetrics(),
            'orders' => $this->getOrdersMetrics(),
            'revenue' => $this->getRevenueMetrics(),
            'performance' => $this->getPerformanceMetrics(),
        ];
    }

    private function getUsersMetrics(): array
    {
        // Онлайн за последние 5 минут
        $stmt = $this->pdo->query("
            SELECT COUNT(*) as online 
            FROM users 
            WHERE last_activity > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
        ");
        $online = $stmt->fetchColumn();

        // Новые за сегодня
        $stmt = $this->pdo->query("
            SELECT COUNT(*) as new_today 
            FROM users 
            WHERE DATE(created_at) = CURDATE()
        ");
        $newToday = $stmt->fetchColumn();

        // Всего
        $stmt = $this->pdo->query("SELECT COUNT(*) FROM users");
        $total = $stmt->fetchColumn();

        return [
            'online' => (int) $online,
            'new_today' => (int) $newToday,
            'total' => (int) $total,
        ];
    }

    private function getOrdersMetrics(): array
    {
        // Заказы за сегодня
        $stmt = $this->pdo->query("
            SELECT 
                COUNT(*) as count,
                SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
                SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
                SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered
            FROM orders 
            WHERE DATE(created_at) = CURDATE()
        ");
        
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }

    private function getRevenueMetrics(): array
    {
        // Выручка за сегодня
        $stmt = $this->pdo->query("
            SELECT COALESCE(SUM(total), 0) as today 
            FROM orders 
            WHERE DATE(created_at) = CURDATE() AND status != 'cancelled'
        ");
        $today = $stmt->fetchColumn();

        // За вчера (для сравнения)
        $stmt = $this->pdo->query("
            SELECT COALESCE(SUM(total), 0) as yesterday 
            FROM orders 
            WHERE DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY) 
            AND status != 'cancelled'
        ");
        $yesterday = $stmt->fetchColumn();

        $change = $yesterday > 0 
            ? round(($today - $yesterday) / $yesterday * 100, 1) 
            : 0;

        return [
            'today' => (float) $today,
            'yesterday' => (float) $yesterday,
            'change_percent' => $change,
        ];
    }

    private function getPerformanceMetrics(): array
    {
        return [
            'cpu' => sys_getloadavg()[0] ?? 0,
            'memory' => memory_get_usage(true) / 1024 / 1024,
            'requests_per_minute' => $this->getRequestsPerMinute(),
        ];
    }

    private function getRequestsPerMinute(): int
    {
        // Можно брать из Redis, логов или APM
        return rand(100, 500); // Заглушка
    }

    /**
     * Отправить алерт при критическом значении
     */
    public function sendAlert(string $type, string $message, array $data = []): void
    {
        $this->pushler->trigger(
            'dashboard',
            'alert',
            [
                'type' => $type, // 'warning', 'error', 'critical'
                'message' => $message,
                'data' => $data,
                'timestamp' => date('c')
            ]
        );
    }
}

// Использование (cron каждую минуту)
$metrics = new MetricsService(
    'key_ваш_ключ',
    'secret_ваш_секрет',
    getDbConnection()
);

$metrics->broadcastMetrics();

Данные для графиков (история)

PHP
<?php
// chart_data.php — данные для графика за последние 24 часа

require_once 'vendor/autoload.php';

use PushlerRu\PushlerClient;

$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$pdo = getDbConnection();

// Заказы по часам за последние 24 часа
$stmt = $pdo->query("
    SELECT 
        DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') as hour,
        COUNT(*) as orders,
        SUM(total) as revenue
    FROM orders
    WHERE created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)
    GROUP BY hour
    ORDER BY hour
");

$chartData = $stmt->fetchAll(PDO::FETCH_ASSOC);

// Преобразуем для Chart.js
$labels = [];
$ordersData = [];
$revenueData = [];

foreach ($chartData as $row) {
    $labels[] = date('H:i', strtotime($row['hour']));
    $ordersData[] = (int) $row['orders'];
    $revenueData[] = (float) $row['revenue'];
}

// Отправляем обновление графика
$pushler->trigger('dashboard', 'chart:update', [
    'chart_id' => 'orders-24h',
    'labels' => $labels,
    'datasets' => [
        ['label' => 'Заказы', 'data' => $ordersData],
        ['label' => 'Выручка', 'data' => $revenueData],
    ]
]);

Отправка точки данных при новом заказе

PHP
<?php
// При создании заказа — отправляем точку на график

$pushler->trigger('dashboard', 'chart:add-point', [
    'chart_id' => 'orders-realtime',
    'timestamp' => date('c'),
    'value' => $order['total']
]);

// Обновляем счётчики
$pushler->trigger('dashboard', 'counter:increment', [
    'counter_id' => 'orders-today',
    'increment' => 1
]);

$pushler->trigger('dashboard', 'counter:increment', [
    'counter_id' => 'revenue-today',
    'increment' => $order['total']
]);

🌐 Код клиента

Vanilla JavaScript с Chart.js

HTML
<!-- Подключение библиотек -->
<script src="https://cdn.pushler.ru/sdk/pushler.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
JavaScript
// Live Dashboard
class LiveDashboard {
    constructor(appKey) {
        this.appKey = appKey;
        this.pushler = null;
        this.channel = null;
        this.charts = new Map();
        this.counters = new Map();
    }

    async connect() {
        this.pushler = new PushlerClient({
            appKey: this.appKey
        });

        return new Promise((resolve) => {
            this.pushler.on('connected', () => {
                this.channel = this.pushler.subscribe('dashboard');
                this.setupListeners();
                resolve();
            });
        });
    }

    setupListeners() {
        // Обновление всех метрик
        this.channel.on('metrics:update', (data) => {
            this.updateMetrics(data.metrics);
        });

        // Обновление графика
        this.channel.on('chart:update', (data) => {
            this.updateChart(data.chart_id, data);
        });

        // Добавление точки на график
        this.channel.on('chart:add-point', (data) => {
            this.addChartPoint(data.chart_id, data.timestamp, data.value);
        });

        // Инкремент счётчика
        this.channel.on('counter:increment', (data) => {
            this.incrementCounter(data.counter_id, data.increment);
        });

        // Алерты
        this.channel.on('alert', (data) => {
            this.showAlert(data);
        });
    }

    updateMetrics(metrics) {
        // Пользователи
        if (metrics.users) {
            this.animateValue('users-online', metrics.users.online);
            this.animateValue('users-new', metrics.users.new_today);
            this.animateValue('users-total', metrics.users.total);
        }

        // Заказы
        if (metrics.orders) {
            this.animateValue('orders-today', metrics.orders.count);
            this.animateValue('orders-pending', metrics.orders.pending);
        }

        // Выручка
        if (metrics.revenue) {
            this.animateValue('revenue-today', metrics.revenue.today, true);
            this.updateChangeIndicator('revenue-change', metrics.revenue.change_percent);
        }
    }

    animateValue(elementId, endValue, isCurrency = false) {
        const el = document.getElementById(elementId);
        if (!el) return;

        const startValue = parseFloat(el.dataset.value || 0);
        const duration = 500;
        const startTime = performance.now();

        const animate = (currentTime) => {
            const elapsed = currentTime - startTime;
            const progress = Math.min(elapsed / duration, 1);
            
            // Easing
            const eased = 1 - Math.pow(1 - progress, 3);
            const current = startValue + (endValue - startValue) * eased;

            el.textContent = isCurrency 
                ? this.formatCurrency(current) 
                : this.formatNumber(current);
            el.dataset.value = endValue;

            if (progress < 1) {
                requestAnimationFrame(animate);
            }
        };

        requestAnimationFrame(animate);
    }

    formatNumber(num) {
        return new Intl.NumberFormat('ru-RU').format(Math.round(num));
    }

    formatCurrency(num) {
        return new Intl.NumberFormat('ru-RU', {
            style: 'currency',
            currency: 'RUB',
            maximumFractionDigits: 0
        }).format(num);
    }

    updateChangeIndicator(elementId, percent) {
        const el = document.getElementById(elementId);
        if (!el) return;

        el.textContent = (percent >= 0 ? '+' : '') + percent + '%';
        el.className = 'change ' + (percent >= 0 ? 'positive' : 'negative');
    }

    // Регистрация Chart.js графика
    registerChart(chartId, chartInstance) {
        this.charts.set(chartId, chartInstance);
    }

    updateChart(chartId, data) {
        const chart = this.charts.get(chartId);
        if (!chart) return;

        chart.data.labels = data.labels;
        data.datasets.forEach((ds, i) => {
            if (chart.data.datasets[i]) {
                chart.data.datasets[i].data = ds.data;
            }
        });
        chart.update('none');
    }

    addChartPoint(chartId, timestamp, value) {
        const chart = this.charts.get(chartId);
        if (!chart) return;

        const label = new Date(timestamp).toLocaleTimeString('ru-RU', {
            hour: '2-digit',
            minute: '2-digit'
        });

        chart.data.labels.push(label);
        chart.data.datasets[0].data.push(value);

        // Ограничиваем количество точек
        if (chart.data.labels.length > 50) {
            chart.data.labels.shift();
            chart.data.datasets[0].data.shift();
        }

        chart.update('none');
    }

    incrementCounter(counterId, increment) {
        const el = document.getElementById(counterId);
        if (!el) return;

        const current = parseFloat(el.dataset.value || 0);
        const newValue = current + increment;
        
        this.animateValue(counterId, newValue, counterId.includes('revenue'));
    }

    showAlert(data) {
        const alertsContainer = document.getElementById('alerts');
        
        const alert = document.createElement('div');
        alert.className = `alert alert-${data.type}`;
        alert.innerHTML = `
            <span class="alert-icon">${this.getAlertIcon(data.type)}</span>
            <span class="alert-message">${data.message}</span>
            <button onclick="this.parentElement.remove()">×</button>
        `;
        
        alertsContainer.prepend(alert);

        // Звук для critical
        if (data.type === 'critical') {
            this.playAlertSound();
        }

        // Автоудаление
        setTimeout(() => alert.remove(), 10000);
    }

    getAlertIcon(type) {
        const icons = {
            warning: '⚠️',
            error: '❌',
            critical: '🚨'
        };
        return icons[type] || '⚠️';
    }

    playAlertSound() {
        const audio = new Audio('/sounds/alert.mp3');
        audio.volume = 0.5;
        audio.play().catch(() => {});
    }
}

// Инициализация
const dashboard = new LiveDashboard('key_ваш_ключ');
dashboard.connect();

// Регистрируем график
const ctx = document.getElementById('ordersChart').getContext('2d');
const ordersChart = new Chart(ctx, {
    type: 'line',
    data: {
        labels: [],
        datasets: [{
            label: 'Заказы',
            data: [],
            borderColor: '#ff7f1f',
            tension: 0.4
        }]
    },
    options: {
        responsive: true,
        animation: false,
        scales: {
            y: { beginAtZero: true }
        }
    }
});

dashboard.registerChart('orders-realtime', ordersChart);

Vue 3 компонент

Vue
<!-- DashboardWidget.vue -->
<template>
  <div class="dashboard">
    <!-- Алерты -->
    <TransitionGroup name="alert" tag="div" class="alerts">
      <div v-for="alert in alerts" :key="alert.id" :class="['alert', alert.type]">
        {{ alert.message }}
        <button @click="removeAlert(alert.id)">×</button>
      </div>
    </TransitionGroup>

    <!-- Метрики -->
    <div class="metrics-grid">
      <MetricCard 
        title="Онлайн" 
        :value="metrics.users?.online || 0"
        icon="👥"
      />
      <MetricCard 
        title="Заказов сегодня" 
        :value="metrics.orders?.count || 0"
        icon="📦"
      />
      <MetricCard 
        title="Выручка" 
        :value="metrics.revenue?.today || 0"
        :change="metrics.revenue?.change_percent"
        currency
        icon="💰"
      />
    </div>

    <!-- График -->
    <div class="chart-container">
      <canvas ref="chartEl"></canvas>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { Chart, registerables } from 'chart.js';
import PushlerClient from '@pushler/js';
import MetricCard from './MetricCard.vue';

Chart.register(...registerables);

const props = defineProps<{ appKey: string }>();

const metrics = ref<Record<string, any>>({});
const alerts = ref<Array<{ id: string; type: string; message: string }>>([]);
const chartEl = ref<HTMLCanvasElement | null>(null);

let pushler: PushlerClient | null = null;
let channel: any = null;
let chart: Chart | null = null;

onMounted(() => {
    // Инициализация графика
    if (chartEl.value) {
        chart = new Chart(chartEl.value, {
            type: 'line',
            data: {
                labels: [],
                datasets: [{
                    label: 'Заказы',
                    data: [],
                    borderColor: '#ff7f1f',
                    backgroundColor: 'rgba(255, 127, 31, 0.1)',
                    fill: true,
                    tension: 0.4
                }]
            },
            options: {
                responsive: true,
                animation: false
            }
        });
    }

    // Подключение к Pushler
    pushler = new PushlerClient({ appKey: props.appKey });
    
    pushler.on('connected', () => {
        channel = pushler!.subscribe('dashboard');
        
        channel.on('metrics:update', (data: any) => {
            metrics.value = data.metrics;
        });

        channel.on('chart:add-point', (data: any) => {
            if (!chart) return;
            
            const label = new Date(data.timestamp).toLocaleTimeString('ru-RU', {
                hour: '2-digit', minute: '2-digit'
            });
            
            chart.data.labels!.push(label);
            chart.data.datasets[0].data.push(data.value);
            
            if (chart.data.labels!.length > 50) {
                chart.data.labels!.shift();
                chart.data.datasets[0].data.shift();
            }
            
            chart.update('none');
        });

        channel.on('alert', (data: any) => {
            const alert = { id: Date.now().toString(), ...data };
            alerts.value.unshift(alert);
            setTimeout(() => removeAlert(alert.id), 10000);
        });
    });
});

onUnmounted(() => {
    channel?.unsubscribe();
    pushler?.disconnect();
    chart?.destroy();
});

function removeAlert(id: string) {
    alerts.value = alerts.value.filter(a => a.id !== id);
}
</script>

⚠️ Типичные ошибки

1. Слишком частые обновления

PHP
// ❌ Неправильно — отправляем на каждый запрос
// В обработчике HTTP запроса:
$pushler->trigger('dashboard', 'metrics:update', collectMetrics());
// 1000 RPS = 1000 событий/сек!

// ✅ Правильно — агрегируем и отправляем периодически
// Cron каждую минуту или секунду:
$pushler->trigger('dashboard', 'metrics:update', collectMetrics());

2. Нет анимации — дёргается

JavaScript
// ❌ Неправильно — резкая смена значения
element.textContent = newValue;

// ✅ Правильно — плавная анимация
animateValue(element, oldValue, newValue, 500);

3. График перерисовывается целиком

JavaScript
// ❌ Неправильно — полное обновление графика
chart.data = newData;
chart.update(); // Мигание!

// ✅ Правильно — добавляем точку
chart.data.labels.push(newLabel);
chart.data.datasets[0].data.push(newValue);
chart.update('none'); // Без анимации

Готовы попробовать?

Создайте бесплатный аккаунт и начните интеграцию за пару минут