README, комментарии в коде и HTML

This commit is contained in:
2026-07-22 10:25:52 +04:00
parent bc6f43d561
commit 1bc5bffc38
3 changed files with 443 additions and 61 deletions
+56
View File
@@ -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
View File
@@ -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>
+103 -11
View File
@@ -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();