380 lines
17 KiB
HTML
380 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<!-- =========================================================================
|
||
IoT Dashboard — «Умный дом»
|
||
Визуализация данных из Redis через Chart.js
|
||
|
||
Пайплайн данных:
|
||
Producer → RabbitMQ → Consumer → Redis + MongoDB → Dashboard (этот файл)
|
||
|
||
Данные получает через API (server.js):
|
||
GET /api/latest — последние значения датчиков (bar chart)
|
||
GET /api/counters — счётчики по типам (doughnut chart)
|
||
GET /api/recent — лента событий (таблица)
|
||
|
||
Автообновление каждые 3 секунды (setInterval).
|
||
Chart.js v4 загружается из CDN.
|
||
========================================================================= -->
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<!-- viewport для мобильных: масштабирование под ширину экрана -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>IoT Dashboard — Умный дом</title>
|
||
|
||
<!-- Chart.js v4 из CDN (jsDelivr) — библиотека графиков -->
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||
|
||
<style>
|
||
/* ===================================================================
|
||
Стили: тёмная тема в стиле Tailwind/shadcn
|
||
Цветовая схема: Slate (фон #0f172a, карточки #1e293b, бордер #334155)
|
||
=================================================================== */
|
||
|
||
/* Сброс отступов и box-sizing для всех элементов */
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
/* Основной фон — тёмный slate-900 */
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: #0f172a; /* slate-900 */
|
||
color: #e2e8f0; /* slate-200 — светлый текст */
|
||
min-height: 100vh; /* на весь экран */
|
||
}
|
||
|
||
/* Шапка: тёмная полоса вверху */
|
||
.header {
|
||
background: #1e293b; /* slate-800 */
|
||
padding: 20px 30px;
|
||
border-bottom: 1px solid #334155; /* slate-700 */
|
||
}
|
||
.header h1 {
|
||
font-size: 24px;
|
||
color: #38bdf8; /* sky-400 — голубой заголовок */
|
||
}
|
||
.header span {
|
||
color: #94a3b8; /* slate-400 — серый подзаголовок */
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* Сетка карточек: автоматически подстраивается под ширину экрана */
|
||
.grid {
|
||
display: grid;
|
||
/* auto-fit: карточки переносятся на новую строку если не влезают */
|
||
/* minmax(400px, 1fr): минимум 400px, максимум — равные доли */
|
||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||
gap: 20px;
|
||
padding: 20px;
|
||
}
|
||
|
||
/* Карточка с графиком или таблицей */
|
||
.card {
|
||
background: #1e293b; /* slate-800 */
|
||
border-radius: 12px; /* скруглённые углы */
|
||
padding: 20px;
|
||
border: 1px solid #334155; /* slate-700 */
|
||
}
|
||
.card h2 {
|
||
font-size: 16px;
|
||
color: #94a3b8; /* slate-400 */
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
/* Контейнер для графика — фиксированная высота */
|
||
.chart-wrap {
|
||
position: relative;
|
||
height: 250px; /* высота области графика */
|
||
}
|
||
|
||
/* Контейнер таблицы с прокруткой */
|
||
.table-wrap {
|
||
max-height: 300px; /* ограничение по высоте */
|
||
overflow-y: auto; /* вертикальная прокрутка */
|
||
}
|
||
|
||
/* Таблица событий */
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse; /* убираем двойные границы */
|
||
font-size: 13px;
|
||
}
|
||
th, td {
|
||
padding: 8px 12px;
|
||
text-align: left;
|
||
border-bottom: 1px solid #334155;
|
||
}
|
||
th {
|
||
color: #64748b; /* slate-500 — заголовки таблицы */
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Бейджи для типов устройств (цветовое кодирование) */
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
}
|
||
.badge-temp { background: #f97316; color: #fff; } /* orange-500 — температура */
|
||
.badge-hum { background: #06b6d4; color: #fff; } /* cyan-500 — влажность */
|
||
.badge-power { background: #eab308; color: #000; } /* yellow-500 — энергия */
|
||
|
||
/* Подсказка об автообновлении (серая, мелкая) */
|
||
.refresh {
|
||
text-align: right;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
margin-bottom: 10px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- =====================================================================
|
||
Шапка дашборда
|
||
===================================================================== -->
|
||
<div class="header">
|
||
<h1>🏠 IoT Dashboard — Умный дом</h1>
|
||
<!-- Описание пайплайна: от RabbitMQ через Redis до отображения -->
|
||
<span>RabbitMQ → Redis → MongoDB | Автообновление каждые 3 сек</span>
|
||
</div>
|
||
|
||
<!-- =====================================================================
|
||
Сетка: 3 карточки (графики + таблица)
|
||
===================================================================== -->
|
||
<div class="grid">
|
||
|
||
<!-- =================================================================
|
||
Карточка 1: Bar Chart — последние значения датчиков
|
||
Данные: GET /api/latest
|
||
================================================================= -->
|
||
<div class="card">
|
||
<h2>📊 Последние значения датчиков</h2>
|
||
<div class="chart-wrap">
|
||
<!-- Canvas для Chart.js — рисуется программно -->
|
||
<canvas id="latestChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =================================================================
|
||
Карточка 2: Doughnut Chart — счётчики по типам устройств
|
||
Данные: GET /api/counters
|
||
================================================================= -->
|
||
<div class="card">
|
||
<h2>📈 Счётчики по типам устройств</h2>
|
||
<div class="chart-wrap">
|
||
<canvas id="countersChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =================================================================
|
||
Карточка 3: Таблица последних событий
|
||
Данные: GET /api/recent
|
||
================================================================= -->
|
||
<div class="card">
|
||
<h2>📋 Последние события</h2>
|
||
<div class="table-wrap">
|
||
<table id="recentTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Время</th>
|
||
<th>Датчик</th>
|
||
<th>Значение</th>
|
||
<th>Локация</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<!-- Заполняется динамически через JS -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =====================================================================
|
||
Карточка с описанием демо (вне сетки, на всю ширину)
|
||
===================================================================== -->
|
||
<div class="card" style="margin: 0 20px 20px 20px; line-height: 1.8;">
|
||
<h2>ℹ️ О демонстрации</h2>
|
||
<p>
|
||
<b>Эмуляция:</b> IoT-датчики «Умного дома» — температура, влажность, энергопотребление (8 датчиков, 3 типа).<br>
|
||
<b>Пайплайн данных:</b><br>
|
||
1. <b>Producer</b> (Node.js) — генерирует события → отправляет в RabbitMQ<br>
|
||
2. <b>RabbitMQ</b> — очередь сообщений (iot-events)<br>
|
||
3. <b>Consumer</b> (Node.js) — читает очередь → сохраняет в Redis + MongoDB<br>
|
||
4. <b>Redis</b> — кэш: последние значения, счётчики, лента событий<br>
|
||
5. <b>MongoDB</b> — постоянное хранение всех событий<br>
|
||
6. <b>Dashboard</b> (Node.js + Chart.js) — читает Redis → отображает графики<br>
|
||
<b>Сервисы Nubes Cloud:</b> RabbitMQ, Redis, MongoDB, Node.js ×3<br>
|
||
<b>Развёрнуто через Terraform</b> на тестовом стенде (k8s-4-sandbox-nubes-ru)
|
||
</p>
|
||
</div>
|
||
|
||
<!-- =====================================================================
|
||
JavaScript: логика дашборда
|
||
===================================================================== -->
|
||
<script>
|
||
// -------------------------------------------------------------------
|
||
// Переменные для Chart.js-инстансов (создаются один раз, потом обновляются)
|
||
// -------------------------------------------------------------------
|
||
let latestChart, countersChart;
|
||
|
||
// -------------------------------------------------------------------
|
||
// getBadge(type) — возвращает CSS-класс бейджа по типу устройства
|
||
// Используется для цветового кодирования в таблице событий
|
||
// -------------------------------------------------------------------
|
||
function getBadge(type) {
|
||
if (type === 'temperature') return 'badge-temp'; // оранжевый
|
||
if (type === 'humidity') return 'badge-hum'; // голубой
|
||
return 'badge-power'; // жёлтый (power_meter)
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// refresh() — основная функция обновления данных
|
||
// Вызывается при загрузке страницы и каждые 3 секунды.
|
||
//
|
||
// Порядок работы:
|
||
// 1. Запрашивает /api/latest → обновляет Bar Chart
|
||
// 2. Запрашивает /api/counters → обновляет Doughnut Chart
|
||
// 3. Запрашивает /api/recent → обновляет таблицу (первые 15 строк)
|
||
//
|
||
// При первом вызове создаёт Chart.js-инстансы,
|
||
// при последующих — обновляет данные через .update()
|
||
// (это эффективнее, чем пересоздавать canvas каждый раз).
|
||
// -------------------------------------------------------------------
|
||
async function refresh() {
|
||
try {
|
||
// ===============================================================
|
||
// 1. Bar Chart — последние значения датчиков
|
||
// ===============================================================
|
||
const latest = await fetch('/api/latest').then(r => r.json());
|
||
|
||
// Извлекаем массивы для графика
|
||
const labels = latest.map(d => d.sensor_id); // названия датчиков
|
||
const values = latest.map(d => d.value); // числовые значения
|
||
|
||
// Цвет столбца зависит от единицы измерения:
|
||
// °C → оранжевый, % → голубой, kW → жёлтый
|
||
const colors = latest.map(d => {
|
||
if (d.unit === '°C') return '#f97316'; // orange-500
|
||
if (d.unit === '%') return '#06b6d4'; // cyan-500
|
||
return '#eab308'; // yellow-500
|
||
});
|
||
|
||
if (!latestChart) {
|
||
// === Первый вызов: создаём новый Chart.js-инстанс ===
|
||
latestChart = new Chart(
|
||
document.getElementById('latestChart'),
|
||
{
|
||
type: 'bar', // столбчатая диаграмма
|
||
data: {
|
||
labels, // подписи по оси X (имена датчиков)
|
||
datasets: [{
|
||
label: 'Значение',
|
||
data: values,
|
||
backgroundColor: colors, // цвет каждого столбца
|
||
}],
|
||
},
|
||
options: {
|
||
responsive: true, // адаптивный размер
|
||
maintainAspectRatio: false, // можно менять пропорции
|
||
scales: {
|
||
y: { ticks: { color: '#94a3b8' } }, // цвет шкалы Y
|
||
x: { ticks: { color: '#94a3b8' } }, // цвет шкалы X
|
||
},
|
||
plugins: {
|
||
legend: { display: false }, // скрываем легенду (избыточно)
|
||
},
|
||
},
|
||
}
|
||
);
|
||
} else {
|
||
// === Последующие вызовы: обновляем существующий график ===
|
||
latestChart.data.labels = labels;
|
||
latestChart.data.datasets[0].data = values;
|
||
latestChart.data.datasets[0].backgroundColor = colors;
|
||
latestChart.update(); // перерисовываем без моргания
|
||
}
|
||
|
||
// ===============================================================
|
||
// 2. Doughnut Chart — счётчики по типам устройств
|
||
// ===============================================================
|
||
const counters = await fetch('/api/counters').then(r => r.json());
|
||
|
||
// Ключи — типы устройств (temperature, humidity, power_meter)
|
||
const cLabels = Object.keys(counters);
|
||
// Значения — счётчики (строки из Redis, преобразуем в Number)
|
||
const cValues = Object.values(counters).map(Number);
|
||
|
||
if (!countersChart) {
|
||
// === Первый вызов: создаём кольцевую диаграмму ===
|
||
countersChart = new Chart(
|
||
document.getElementById('countersChart'),
|
||
{
|
||
type: 'doughnut', // кольцевая диаграмма
|
||
data: {
|
||
labels: cLabels,
|
||
datasets: [{
|
||
data: cValues,
|
||
// Цвета секторов (те же, что для бейджей)
|
||
backgroundColor: ['#f97316', '#06b6d4', '#eab308'],
|
||
}],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
labels: { color: '#94a3b8' }, // цвет текста легенды
|
||
},
|
||
},
|
||
},
|
||
}
|
||
);
|
||
} else {
|
||
// === Последующие вызовы: обновляем диаграмму ===
|
||
countersChart.data.labels = cLabels;
|
||
countersChart.data.datasets[0].data = cValues;
|
||
countersChart.update();
|
||
}
|
||
|
||
// ===============================================================
|
||
// 3. Таблица последних событий
|
||
// ===============================================================
|
||
const recent = await fetch('/api/recent').then(r => r.json());
|
||
|
||
const tbody = document.querySelector('#recentTable tbody');
|
||
|
||
// Берём первые 15 событий, формируем HTML-строки таблицы
|
||
tbody.innerHTML = recent.slice(0, 15).map(e => `
|
||
<tr>
|
||
<!-- Время события (локальное) -->
|
||
<td>${new Date(e.timestamp).toLocaleTimeString()}</td>
|
||
<!-- Идентификатор датчика -->
|
||
<td>${e.sensor_id}</td>
|
||
<!-- Значение с цветным бейджем -->
|
||
<td>
|
||
<span class="badge ${getBadge(e.device_type)}">
|
||
${e.value} ${e.unit}
|
||
</span>
|
||
</td>
|
||
<!-- Локация датчика -->
|
||
<td>${e.location}</td>
|
||
</tr>
|
||
`).join(''); // объединяем массив строк в одну HTML-строку
|
||
|
||
} catch (e) {
|
||
// Сетевая ошибка или API недоступен — логируем,
|
||
// дашборд продолжит попытки через 3 секунды
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Запуск: первый рендер + автообновление каждые 3 секунды
|
||
// -------------------------------------------------------------------
|
||
refresh(); // немедленный первый рендер
|
||
setInterval(refresh, 3000); // затем каждые 3000 мс (3 секунды)
|
||
</script>
|
||
</body>
|
||
</html>
|