v0.1.59: IoT telemetry pipeline — Postgres storage + REST API + UI table
This commit is contained in:
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// =============================================================
|
||||
|
||||
Reference in New Issue
Block a user