README, комментарии в коде и HTML
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# IoT Dashboard — веб-интерфейс «Умного дома»
|
||||||
|
|
||||||
|
Читает данные из Redis (только чтение) и отображает графики через Chart.js в браузере.
|
||||||
|
|
||||||
|
## Пайплайн
|
||||||
|
|
||||||
|
```
|
||||||
|
Producer → RabbitMQ → Consumer → Redis + MongoDB → Dashboard (этот сервис)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard **не пишет** в Redis — только читает. Данные туда кладёт Consumer.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Путь | Ответ | Данные из Redis |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/latest` | `[{sensor_id, value, unit, location, timestamp}]` | `iot:latest` (hash) |
|
||||||
|
| `GET /api/counters` | `{temperature: "N", humidity: "N", power_meter: "N"}` | `iot:counters` (hash) |
|
||||||
|
| `GET /api/recent` | `[{...50 последних событий}]` | `iot:recent` (zset) |
|
||||||
|
| `GET /` | HTML-дашборд | — |
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
- **Bar Chart** — последние значения датчиков (цвет зависит от типа)
|
||||||
|
- **Doughnut Chart** — распределение событий по типам устройств
|
||||||
|
- **Таблица** — последние 15 событий с временем, датчиком и значением
|
||||||
|
- **Автообновление** — каждые 3 секунды
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `REDIS_HOST` | `127.0.0.1` | Хост Redis |
|
||||||
|
| `REDIS_PORT` | `6379` | Порт Redis |
|
||||||
|
| `REDIS_PASS` | — | Пароль Redis (необязательно) |
|
||||||
|
| `PORT` | `3000` | Порт HTTP |
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
REDIS_HOST=localhost node server.js
|
||||||
|
# Открыть http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Деплой на Nubes Cloud
|
||||||
|
|
||||||
|
1. Запушьте этот репо в свой Gitea
|
||||||
|
2. В Terraform-конфиге укажите `git_path` на ваш репо и `git_revision`
|
||||||
|
3. Nubes автоматически запустит `npm start`
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- **Chart.js загружается из CDN** (jsDelivr) — под должен иметь доступ в интернет
|
||||||
|
- **Тёмная тема** — slate-палитра, адаптивная сетка
|
||||||
|
- При недоступности Redis дашборд показывает ошибку в JSON (не 500)
|
||||||
+284
-50
@@ -1,55 +1,198 @@
|
|||||||
<!DOCTYPE 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">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<!-- viewport для мобильных: масштабирование под ширину экрана -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>IoT Dashboard — Умный дом</title>
|
<title>IoT Dashboard — Умный дом</title>
|
||||||
|
|
||||||
|
<!-- Chart.js v4 из CDN (jsDelivr) — библиотека графиков -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* ===================================================================
|
||||||
|
Стили: тёмная тема в стиле Tailwind/shadcn
|
||||||
|
Цветовая схема: Slate (фон #0f172a, карточки #1e293b, бордер #334155)
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
/* Сброс отступов и box-sizing для всех элементов */
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
|
|
||||||
.header { background: #1e293b; padding: 20px 30px; border-bottom: 1px solid #334155; }
|
/* Основной фон — тёмный slate-900 */
|
||||||
.header h1 { font-size: 24px; color: #38bdf8; }
|
body {
|
||||||
.header span { color: #94a3b8; font-size: 14px; }
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; padding: 20px; }
|
background: #0f172a; /* slate-900 */
|
||||||
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
|
color: #e2e8f0; /* slate-200 — светлый текст */
|
||||||
.card h2 { font-size: 16px; color: #94a3b8; margin-bottom: 15px; }
|
min-height: 100vh; /* на весь экран */
|
||||||
.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; }
|
.header {
|
||||||
th { color: #64748b; font-weight: 600; }
|
background: #1e293b; /* slate-800 */
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
|
padding: 20px 30px;
|
||||||
.badge-temp { background: #f97316; color: #fff; }
|
border-bottom: 1px solid #334155; /* slate-700 */
|
||||||
.badge-hum { background: #06b6d4; color: #fff; }
|
}
|
||||||
.badge-power { background: #eab308; color: #000; }
|
.header h1 {
|
||||||
.refresh { text-align: right; font-size: 12px; color: #64748b; margin-bottom: 10px; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
Шапка дашборда
|
||||||
|
===================================================================== -->
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🏠 IoT Dashboard — Умный дом</h1>
|
<h1>🏠 IoT Dashboard — Умный дом</h1>
|
||||||
|
<!-- Описание пайплайна: от RabbitMQ через Redis до отображения -->
|
||||||
<span>RabbitMQ → Redis → MongoDB | Автообновление каждые 3 сек</span>
|
<span>RabbitMQ → Redis → MongoDB | Автообновление каждые 3 сек</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
Сетка: 3 карточки (графики + таблица)
|
||||||
|
===================================================================== -->
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
|
||||||
|
<!-- =================================================================
|
||||||
|
Карточка 1: Bar Chart — последние значения датчиков
|
||||||
|
Данные: GET /api/latest
|
||||||
|
================================================================= -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📊 Последние значения датчиков</h2>
|
<h2>📊 Последние значения датчиков</h2>
|
||||||
<div class="chart-wrap"><canvas id="latestChart"></canvas></div>
|
<div class="chart-wrap">
|
||||||
|
<!-- Canvas для Chart.js — рисуется программно -->
|
||||||
|
<canvas id="latestChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- =================================================================
|
||||||
|
Карточка 2: Doughnut Chart — счётчики по типам устройств
|
||||||
|
Данные: GET /api/counters
|
||||||
|
================================================================= -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📈 Счётчики по типам устройств</h2>
|
<h2>📈 Счётчики по типам устройств</h2>
|
||||||
<div class="chart-wrap"><canvas id="countersChart"></canvas></div>
|
<div class="chart-wrap">
|
||||||
|
<canvas id="countersChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- =================================================================
|
||||||
|
Карточка 3: Таблица последних событий
|
||||||
|
Данные: GET /api/recent
|
||||||
|
================================================================= -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📋 Последние события</h2>
|
<h2>📋 Последние события</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="recentTable">
|
<table id="recentTable">
|
||||||
<thead><tr><th>Время</th><th>Датчик</th><th>Значение</th><th>Локация</th></tr></thead>
|
<thead>
|
||||||
<tbody></tbody>
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Датчик</th>
|
||||||
|
<th>Значение</th>
|
||||||
|
<th>Локация</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Заполняется динамически через JS -->
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
Карточка с описанием демо (вне сетки, на всю ширину)
|
||||||
|
===================================================================== -->
|
||||||
<div class="card" style="margin: 0 20px 20px 20px; line-height: 1.8;">
|
<div class="card" style="margin: 0 20px 20px 20px; line-height: 1.8;">
|
||||||
<h2>ℹ️ О демонстрации</h2>
|
<h2>ℹ️ О демонстрации</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -66,80 +209,171 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
JavaScript: логика дашборда
|
||||||
|
===================================================================== -->
|
||||||
<script>
|
<script>
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Переменные для Chart.js-инстансов (создаются один раз, потом обновляются)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
let latestChart, countersChart;
|
let latestChart, countersChart;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// getBadge(type) — возвращает CSS-класс бейджа по типу устройства
|
||||||
|
// Используется для цветового кодирования в таблице событий
|
||||||
|
// -------------------------------------------------------------------
|
||||||
function getBadge(type) {
|
function getBadge(type) {
|
||||||
if (type === 'temperature') return 'badge-temp';
|
if (type === 'temperature') return 'badge-temp'; // оранжевый
|
||||||
if (type === 'humidity') return 'badge-hum';
|
if (type === 'humidity') return 'badge-hum'; // голубой
|
||||||
return 'badge-power';
|
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() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
// Latest values
|
// ===============================================================
|
||||||
|
// 1. Bar Chart — последние значения датчиков
|
||||||
|
// ===============================================================
|
||||||
const latest = await fetch('/api/latest').then(r => r.json());
|
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);
|
// Извлекаем массивы для графика
|
||||||
|
const labels = latest.map(d => d.sensor_id); // названия датчиков
|
||||||
|
const values = latest.map(d => d.value); // числовые значения
|
||||||
|
|
||||||
|
// Цвет столбца зависит от единицы измерения:
|
||||||
|
// °C → оранжевый, % → голубой, kW → жёлтый
|
||||||
const colors = latest.map(d => {
|
const colors = latest.map(d => {
|
||||||
if (d.unit === '°C') return '#f97316';
|
if (d.unit === '°C') return '#f97316'; // orange-500
|
||||||
if (d.unit === '%') return '#06b6d4';
|
if (d.unit === '%') return '#06b6d4'; // cyan-500
|
||||||
return '#eab308';
|
return '#eab308'; // yellow-500
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!latestChart) {
|
if (!latestChart) {
|
||||||
latestChart = new Chart(document.getElementById('latestChart'), {
|
// === Первый вызов: создаём новый Chart.js-инстанс ===
|
||||||
type: 'bar',
|
latestChart = new Chart(
|
||||||
data: { labels, datasets: [{ label: 'Значение', data: values, backgroundColor: colors }] },
|
document.getElementById('latestChart'),
|
||||||
options: { responsive: true, maintainAspectRatio: false,
|
{
|
||||||
scales: { y: { ticks: { color: '#94a3b8' } }, x: { ticks: { color: '#94a3b8' } } },
|
type: 'bar', // столбчатая диаграмма
|
||||||
plugins: { legend: { display: false } }
|
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 {
|
} else {
|
||||||
|
// === Последующие вызовы: обновляем существующий график ===
|
||||||
latestChart.data.labels = labels;
|
latestChart.data.labels = labels;
|
||||||
latestChart.data.datasets[0].data = values;
|
latestChart.data.datasets[0].data = values;
|
||||||
latestChart.data.datasets[0].backgroundColor = colors;
|
latestChart.data.datasets[0].backgroundColor = colors;
|
||||||
latestChart.update();
|
latestChart.update(); // перерисовываем без моргания
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counters
|
// ===============================================================
|
||||||
|
// 2. Doughnut Chart — счётчики по типам устройств
|
||||||
|
// ===============================================================
|
||||||
const counters = await fetch('/api/counters').then(r => r.json());
|
const counters = await fetch('/api/counters').then(r => r.json());
|
||||||
|
|
||||||
|
// Ключи — типы устройств (temperature, humidity, power_meter)
|
||||||
const cLabels = Object.keys(counters);
|
const cLabels = Object.keys(counters);
|
||||||
|
// Значения — счётчики (строки из Redis, преобразуем в Number)
|
||||||
const cValues = Object.values(counters).map(Number);
|
const cValues = Object.values(counters).map(Number);
|
||||||
|
|
||||||
if (!countersChart) {
|
if (!countersChart) {
|
||||||
countersChart = new Chart(document.getElementById('countersChart'), {
|
// === Первый вызов: создаём кольцевую диаграмму ===
|
||||||
type: 'doughnut',
|
countersChart = new Chart(
|
||||||
data: { labels: cLabels, datasets: [{ data: cValues, backgroundColor: ['#f97316','#06b6d4','#eab308'] }] },
|
document.getElementById('countersChart'),
|
||||||
options: { responsive: true, maintainAspectRatio: false,
|
{
|
||||||
plugins: { legend: { labels: { color: '#94a3b8' } } }
|
type: 'doughnut', // кольцевая диаграмма
|
||||||
|
data: {
|
||||||
|
labels: cLabels,
|
||||||
|
datasets: [{
|
||||||
|
data: cValues,
|
||||||
|
// Цвета секторов (те же, что для бейджей)
|
||||||
|
backgroundColor: ['#f97316', '#06b6d4', '#eab308'],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: { color: '#94a3b8' }, // цвет текста легенды
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// === Последующие вызовы: обновляем диаграмму ===
|
||||||
countersChart.data.labels = cLabels;
|
countersChart.data.labels = cLabels;
|
||||||
countersChart.data.datasets[0].data = cValues;
|
countersChart.data.datasets[0].data = cValues;
|
||||||
countersChart.update();
|
countersChart.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent events
|
// ===============================================================
|
||||||
|
// 3. Таблица последних событий
|
||||||
|
// ===============================================================
|
||||||
const recent = await fetch('/api/recent').then(r => r.json());
|
const recent = await fetch('/api/recent').then(r => r.json());
|
||||||
|
|
||||||
const tbody = document.querySelector('#recentTable tbody');
|
const tbody = document.querySelector('#recentTable tbody');
|
||||||
|
|
||||||
|
// Берём первые 15 событий, формируем HTML-строки таблицы
|
||||||
tbody.innerHTML = recent.slice(0, 15).map(e => `
|
tbody.innerHTML = recent.slice(0, 15).map(e => `
|
||||||
<tr>
|
<tr>
|
||||||
|
<!-- Время события (локальное) -->
|
||||||
<td>${new Date(e.timestamp).toLocaleTimeString()}</td>
|
<td>${new Date(e.timestamp).toLocaleTimeString()}</td>
|
||||||
|
<!-- Идентификатор датчика -->
|
||||||
<td>${e.sensor_id}</td>
|
<td>${e.sensor_id}</td>
|
||||||
<td><span class="badge ${getBadge(e.device_type)}">${e.value} ${e.unit}</span></td>
|
<!-- Значение с цветным бейджем -->
|
||||||
|
<td>
|
||||||
|
<span class="badge ${getBadge(e.device_type)}">
|
||||||
|
${e.value} ${e.unit}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<!-- Локация датчика -->
|
||||||
<td>${e.location}</td>
|
<td>${e.location}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join(''); // объединяем массив строк в одну HTML-строку
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Сетевая ошибка или API недоступен — логируем,
|
||||||
|
// дашборд продолжит попытки через 3 секунды
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
// -------------------------------------------------------------------
|
||||||
setInterval(refresh, 3000);
|
// Запуск: первый рендер + автообновление каждые 3 секунды
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
refresh(); // немедленный первый рендер
|
||||||
|
setInterval(refresh, 3000); // затем каждые 3000 мс (3 секунды)
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,40 +1,115 @@
|
|||||||
const express = require("express");
|
// =============================================================================
|
||||||
const { createClient } = require("redis");
|
// IoT Dashboard — Веб-интерфейс для визуализации IoT-данных
|
||||||
|
// =============================================================================
|
||||||
|
// Читает данные ТОЛЬКО из Redis (не трогает MongoDB — это зона Consumer'а).
|
||||||
|
// Отдаёт статический HTML (public/index.html) и JSON API для графиков.
|
||||||
|
//
|
||||||
|
// Redis-ключи (заполняются Consumer'ом):
|
||||||
|
// iot:latest (hash) — последние значения всех датчиков
|
||||||
|
// iot:counters (hash) — счётчики событий по типам устройств
|
||||||
|
// iot:recent (zset) — лента последних событий (сортирована по времени)
|
||||||
|
//
|
||||||
|
// API-эндпоинты:
|
||||||
|
// GET /api/latest — последние значения датчиков [{sensor_id, value, unit, ...}]
|
||||||
|
// GET /api/counters — счётчики по типам {temperature: N, humidity: N, ...}
|
||||||
|
// GET /api/recent — последние 50 событий (в обратном хронологическом порядке)
|
||||||
|
// GET / — HTML-дашборд (static из public/)
|
||||||
|
//
|
||||||
|
// Переменные окружения (задаются Terraform'ом через json_env):
|
||||||
|
// REDIS_HOST — хост Redis (K8s service.cluster.local)
|
||||||
|
// REDIS_PORT — порт Redis (6379)
|
||||||
|
// REDIS_PASS — пароль Redis
|
||||||
|
// PORT — порт HTTP-сервера (3000)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
// ---------------------------------------------------------------------------
|
||||||
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
|
// Зависимости
|
||||||
const REDIS_PORT = parseInt(process.env.REDIS_PORT || "6379");
|
// ---------------------------------------------------------------------------
|
||||||
const REDIS_PASS = process.env.REDIS_PASS || undefined;
|
const express = require("express"); // HTTP-фреймворк
|
||||||
|
const { createClient } = require("redis"); // Redis-клиент (v4+, Promise API)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Конфигурация из переменных окружения
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PORT = process.env.PORT || 3000; // HTTP-порт
|
||||||
|
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1"; // Redis хост
|
||||||
|
const REDIS_PORT = parseInt(process.env.REDIS_PORT || "6379"); // Redis порт
|
||||||
|
const REDIS_PASS = process.env.REDIS_PASS || undefined; // пароль
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Express-приложение
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Раздаём статику из папки public/ (index.html, Chart.js подгружается из CDN)
|
||||||
app.use(express.static("public"));
|
app.use(express.static("public"));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Redis-клиент (один на всё приложение)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const redis = createClient({
|
const redis = createClient({
|
||||||
socket: { host: REDIS_HOST, port: REDIS_PORT },
|
socket: { host: REDIS_HOST, port: REDIS_PORT },
|
||||||
password: REDIS_PASS,
|
password: REDIS_PASS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Логируем ошибки Redis, но не крашим сервер
|
||||||
redis.on("error", (err) => console.error("Redis error:", err.message));
|
redis.on("error", (err) => console.error("Redis error:", err.message));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// start() — подключение к Redis и регистрация API-маршрутов
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
async function start() {
|
async function start() {
|
||||||
await redis.connect();
|
await redis.connect();
|
||||||
console.log("Redis connected");
|
console.log("Redis connected");
|
||||||
|
|
||||||
// API: latest sensor values
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /api/latest — последние значения всех датчиков
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Читает Redis-хэш iot:latest (sensor_id → JSON),
|
||||||
|
// десериализует JSON и возвращает массив объектов.
|
||||||
|
//
|
||||||
|
// Redis-структура:
|
||||||
|
// iot:latest = {
|
||||||
|
// "temp_living": '{"value":26.95,"unit":"°C","location":"living_room",...}',
|
||||||
|
// "humidity_bed": '{"value":52.1,"unit":"%","location":"bedroom",...}',
|
||||||
|
// ...
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Ответ:
|
||||||
|
// [{sensor_id: "temp_living", value: 26.95, unit: "°C", location: "living_room", ...}, ...]
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
app.get("/api/latest", async (req, res) => {
|
app.get("/api/latest", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// hGetAll возвращает объект {sensor_id: json_string, ...}
|
||||||
const data = await redis.hGetAll("iot:latest");
|
const data = await redis.hGetAll("iot:latest");
|
||||||
|
|
||||||
|
// Преобразуем: {key: json} → [{sensor_id: key, ...parsedJson}]
|
||||||
const items = Object.entries(data).map(([id, json]) => ({
|
const items = Object.entries(data).map(([id, json]) => ({
|
||||||
sensor_id: id,
|
sensor_id: id,
|
||||||
...JSON.parse(json),
|
...JSON.parse(json), // разворачиваем JSON в поля объекта
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// При ошибке Redis — возвращаем ошибку в JSON (не 500,
|
||||||
|
// чтобы фронтенд не упал)
|
||||||
res.json({ error: e.message });
|
res.json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// API: counters by device type
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /api/counters — счётчики событий по типам устройств
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Redis-структура (заполняется HINCRBY в consumer):
|
||||||
|
// iot:counters = {
|
||||||
|
// "temperature": "3847",
|
||||||
|
// "humidity": "2563",
|
||||||
|
// "power_meter": "3851"
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Ответ: {temperature: "3847", humidity: "2563", power_meter: "3851"}
|
||||||
|
// Значения — строки (Redis hash), фронтенд преобразует в Number.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
app.get("/api/counters", async (req, res) => {
|
app.get("/api/counters", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = await redis.hGetAll("iot:counters");
|
const data = await redis.hGetAll("iot:counters");
|
||||||
@@ -44,22 +119,39 @@ async function start() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// API: recent events
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /api/recent — последние 50 событий (лента)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Redis-структура (заполняется ZADD score=timestamp в consumer):
|
||||||
|
// iot:recent (sorted set, до 1000 элементов)
|
||||||
|
//
|
||||||
|
// ZRANGE 0 49 REV — последние 50 элементов (REV = обратный порядок)
|
||||||
|
// Каждый элемент — JSON-строка события, десериализуем.
|
||||||
|
//
|
||||||
|
// Ответ: массив до 50 объектов событий в обратном хронологическом порядке
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
app.get("/api/recent", async (req, res) => {
|
app.get("/api/recent", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// ZRANGE с REV: true — от большего score к меньшему (сначала новые)
|
||||||
const items = await redis.zRange("iot:recent", 0, 49, { REV: true });
|
const items = await redis.zRange("iot:recent", 0, 49, { REV: true });
|
||||||
|
|
||||||
|
// Каждый элемент в zset — JSON-строка, парсим
|
||||||
res.json(items.map(JSON.parse));
|
res.json(items.map(JSON.parse));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.json({ error: e.message });
|
res.json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check
|
// -----------------------------------------------------------------------
|
||||||
|
// GET / — заглушка (статику отдаёт express.static выше)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.send("IoT Dashboard OK");
|
res.send("IoT Dashboard OK");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Запуск HTTP-сервера
|
||||||
app.listen(PORT, () => console.log(`Dashboard on port ${PORT}`));
|
app.listen(PORT, () => console.log(`Dashboard on port ${PORT}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Стартуем приложение
|
||||||
start();
|
start();
|
||||||
|
|||||||
Reference in New Issue
Block a user