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
+5 -2
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-11
// Изменено: 2026-04-05 (добавлено поле IoTPG для IoT телеметрии)
// Handler — общий контейнер зависимостей для всех REST handlers.
// Все handlers получают доступ к k8s, S3 и Postgres через эту структуру.
// Логирование через slog, маршрутизация через gorilla/mux.
@@ -28,6 +28,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/iotpg"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
)
@@ -43,7 +44,9 @@ type Handler struct {
Scheme *runtime.Scheme
S3 *s3.Client
PG *postgres.Store
Log *slog.Logger
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
IoTPG *iotpg.IoTPostgresStore
Log *slog.Logger
}
// writeJSON отправляет JSON-ответ с указанным статусом.
@@ -0,0 +1,55 @@
// Создано: 2026-04-05
// iot_telemetry_handler.go — REST handler для чтения IoT телеметрии.
//
// Endpoint:
// GET /v1/namespaces/{namespace}/iot/telemetry?device={id}&limit={n}
//
// Авторизация: Bearer JWT → namespace validation (как все /v1/ маршруты).
// Данные берутся из per-tenant Postgres DB через IoTPostgresStore.
// Если IoTPG не инициализирован (IOT_PG_DSN не задан) — возвращает 503.
package handler
import (
"net/http"
"strconv"
"github.com/gorilla/mux"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/iotpg"
)
// ListIoTTelemetry обрабатывает GET /v1/namespaces/{namespace}/iot/telemetry.
// Параметры: device (опционально), limit (default 50, max 1000).
func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
if h.IoTPG == nil {
writeJSON(w, http.StatusServiceUnavailable, errResp("IoT telemetry storage not configured"))
return
}
ns := mux.Vars(r)["namespace"]
deviceID := r.URL.Query().Get("device")
limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" {
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
limit = n
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
return
}
// Возвращаем пустой массив вместо null — удобнее для JS
if rows == nil {
rows = []iotpg.TelemetryRow{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"count": len(rows),
})
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-04 (tls: CORS origin обновлён с http → https для iot.kube5s.ru)
// Изменено: 2026-04-05 (добавлен route GET /iot/telemetry)
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты /v1/ защищены Bearer-токеном (middleware.Auth).
// /console — публичный маршрут (статический HTML без auth).
@@ -100,6 +100,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.DeleteIoTDevice).Methods(http.MethodDelete)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.UpdateIoTDevice).Methods(http.MethodPatch)
// IoT Telemetry — чтение сырых данных от устройств (Postgres per-tenant)
v1.HandleFunc("/namespaces/{namespace}/iot/telemetry", h.ListIoTTelemetry).Methods(http.MethodGet)
// MQTT Auth — БЕЗ JWT. Вызывается EMQX при MQTT CONNECT из кластера.
// /internal/ недоступен снаружи (Ingress не проксирует /internal/).
r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost)
+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
// =============================================================