v0.1.59: IoT telemetry pipeline — Postgres storage + REST API + UI table

This commit is contained in:
Naeel
2026-04-05 08:46:17 +03:00
parent d51e33d876
commit 2bdd753f4e
11 changed files with 630 additions and 21 deletions
+145 -7
View File
@@ -267,6 +267,13 @@ const S = {
mqttConnecting: false,
mqttLog: [],
autoTimer: null,
// Телеметрия
telemetryRows: [], // загруженные строки [{id, device_id, ts, payload}]
telemetryLoading: false,
telemetryAutoRefresh: false,
telemetryRefreshTimer: null,
telemetryDeviceFilter: '', // '' = все устройства
};
// Сохраняем auth-параметры в localStorage
@@ -838,6 +845,12 @@ function switchTab(tab) {
// Останавливаем авто-отправку при уходе с вкладки эмулятора
mqttStopAuto();
}
if (tab !== 'telemetry') {
// Останавливаем авто-рефреш при уходе с вкладки телеметрии
clearInterval(S.telemetryRefreshTimer);
S.telemetryRefreshTimer = null;
S.telemetryAutoRefresh = false;
}
S.activeTab = tab;
// Обновляем active-класс на вкладках
@@ -849,6 +862,11 @@ function switchTab(tab) {
if (body && S.deviceDetail) {
body.innerHTML = tabContent(S.deviceDetail, tab);
}
// При переходе на телеметрию — сразу загружаем данные
if (tab === 'telemetry') {
loadTelemetry();
}
}
function tabContent(d, tab) {
@@ -989,6 +1007,10 @@ function emulatorTab(d) {
<div class="form-group">
<label>Payload (JSON)</label>
<textarea id="emu-payload" rows="4">{"temp": 22.5, "humidity": 60}</textarea>
<button class="btn btn-ghost btn-sm" style="margin-top:6px;"
onclick="document.getElementById('emu-payload').value=generateSensorPayload()">
🎲 Случайные данные (температура / влажность)
</button>
</div>
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick="emuSendOnce()">Отправить</button>
@@ -1026,6 +1048,14 @@ function emuSendOnce() {
mqttPublish(topic, payload.trim());
}
// generateSensorPayload — случайный payload: температура [18-28°C], влажность [40-80%]
// Используется кнопкой «Случайные данные» в эмуляторе
function generateSensorPayload() {
const temp = parseFloat((18 + Math.random() * 10).toFixed(1));
const hum = Math.round(40 + Math.random() * 40);
return JSON.stringify({ temperature: temp, humidity: hum, ts: new Date().toISOString() });
}
// toggleAuto — включить/выключить авто-отправку
function toggleAuto() {
const btn = document.getElementById('auto-btn');
@@ -1044,18 +1074,126 @@ function toggleAuto() {
// ВКЛАДКА — ТЕЛЕМЕТРИЯ
// =============================================================
function telemetryTab() {
const deviceOptions = S.devices.map(d =>
`<option value="${h(d.device_id)}" ${S.telemetryDeviceFilter === d.device_id ? 'selected' : ''}>${h(d.name)}</option>`
).join('');
const rows = S.telemetryRows.length === 0
? `<tr><td colspan="3" style="text-align:center;color:#888;padding:24px;">Нет данных — нажмите «↻ Обновить»</td></tr>`
: S.telemetryRows.map(r => {
const ts = r.ts ? new Date(r.ts).toLocaleString('ru') : '—';
const dev = h(r.device_id || '—');
let payloadStr = '';
try {
payloadStr = typeof r.payload === 'object'
? JSON.stringify(r.payload)
: String(r.payload);
} catch (_) { payloadStr = String(r.payload); }
return `<tr>
<td style="padding:6px 12px;white-space:nowrap;">${h(ts)}</td>
<td style="padding:6px 12px;">${dev}</td>
<td style="padding:6px 12px;font-family:monospace;font-size:12px;word-break:break-all;">${h(payloadStr)}</td>
</tr>`;
}).join('');
return `<div class="card">
<div class="coming-soon">
<div class="icon">📊</div>
<h3>Телеметрия</h3>
<p class="hint" style="margin-top:8px;">Скоро — хранение данных и API чтения</p>
<p class="hint" style="margin-top:4px;color:#334155;">
В разработке — появится в следующем обновлении
</p>
<div class="card-title">Телеметрия</div>
<!-- Панель управления: фильтр, обновление, авто-рефреш -->
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;gap:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:8px;">
<label class="hint" for="telem-dev-filter">Устройство:</label>
<select id="telem-dev-filter" onchange="S.telemetryDeviceFilter=this.value;loadTelemetry()">
<option value="">Все</option>
${deviceOptions}
</select>
</div>
<button class="btn btn-ghost btn-sm" onclick="loadTelemetry()">↻ Обновить</button>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
<input type="checkbox" id="telem-auto" ${S.telemetryAutoRefresh ? 'checked' : ''}
onchange="toggleTelemetryAutoRefresh(this.checked)">
Авто (5 с)
</label>
<span id="telem-status" class="hint"></span>
</div>
<!-- Таблица телеметрии -->
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="background:#f0f4f8;text-align:left;">
<th style="padding:8px 12px;white-space:nowrap;">Время</th>
<th style="padding:8px 12px;white-space:nowrap;">Устройство</th>
<th style="padding:8px 12px;">Данные</th>
</tr>
</thead>
<tbody id="telem-tbody">${rows}</tbody>
</table>
</div>
</div>`;
}
// loadTelemetry — загружает историю телеметрии с API.
// URL: GET /v1/namespaces/{ns}/iot/telemetry?limit=100[&device=...]
async function loadTelemetry() {
if (!S.ns || !S.token) return;
const status = document.getElementById('telem-status');
if (status) status.textContent = 'Загрузка...';
// Читаем текущий фильтр из DOM (если вкладка открыта) или из state
const sel = document.getElementById('telem-dev-filter');
if (sel) S.telemetryDeviceFilter = sel.value;
try {
let url = `${S.apiBase}/v1/namespaces/${encodeURIComponent(S.ns)}/iot/telemetry?limit=100`;
if (S.telemetryDeviceFilter) url += `&device=${encodeURIComponent(S.telemetryDeviceFilter)}`;
const resp = await fetch(url, { headers: { Authorization: 'Bearer ' + S.token } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
S.telemetryRows = data.items || [];
// Обновляем только tbody — не перерисовываем весь таб (сохраняем фильтр/чекбокс)
const tbody = document.getElementById('telem-tbody');
if (tbody) {
if (S.telemetryRows.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#888;padding:24px;">Нет данных</td></tr>';
} else {
tbody.innerHTML = S.telemetryRows.map(r => {
const ts = r.ts ? new Date(r.ts).toLocaleString('ru') : '—';
const dev = h(r.device_id || '—');
let payloadStr = '';
try {
payloadStr = typeof r.payload === 'object'
? JSON.stringify(r.payload)
: String(r.payload);
} catch (_) { payloadStr = String(r.payload); }
return `<tr>
<td style="padding:6px 12px;white-space:nowrap;">${h(ts)}</td>
<td style="padding:6px 12px;">${dev}</td>
<td style="padding:6px 12px;font-family:monospace;font-size:12px;word-break:break-all;">${h(payloadStr)}</td>
</tr>`;
}).join('');
}
}
if (status) status.textContent = `Обновлено в ${new Date().toLocaleTimeString('ru')}`;
} catch(e) {
if (status) status.textContent = 'Ошибка: ' + e.message;
}
}
// toggleTelemetryAutoRefresh — включить/выключить авто-обновление каждые 5 секунд
function toggleTelemetryAutoRefresh(enabled) {
S.telemetryAutoRefresh = enabled;
clearInterval(S.telemetryRefreshTimer);
S.telemetryRefreshTimer = null;
if (enabled) {
// Немедленная загрузка + интервал
loadTelemetry();
S.telemetryRefreshTimer = setInterval(loadTelemetry, 5000);
}
}
// =============================================================
// NAVBAR
// =============================================================