feat(iot-console): IoT управляющий UI v0.1.53

- Добавлен HTML SPA: internal/api/ui/iot-console.html
  Ванильный JS + mqtt.js (CDN), без фреймворков.
  Страницы: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
- Добавлен go:embed: internal/api/console_embed.go, GET /console
- Добавлен CORS middleware в router.go для http://iot.kube5s.ru
- Ingress emqx-ws-ingress.yaml: /console → sless-operator:9090
- Версия образа v0.1.53, задеплоен

Доступно: http://iot.kube5s.ru/console
This commit is contained in:
Naeel
2026-04-04 19:28:30 +03:00
parent d57558c798
commit b48c300ac5
7 changed files with 1104 additions and 11 deletions
+14 -1
View File
@@ -1,9 +1,11 @@
# Создано: 2026-04-04
# Изменено: 2026-04-04 (iot-console: добавлен path /console → sless-operator:9090)
# ВРЕМЕННЫЙ WORKAROUND: MQTT over WebSocket через порт 443.
# Причина: порт 1883 заблокирован NSX-T Edge firewall на уровне облака.
# Решение: EMQX WebSocket listener (8083) проксируется через nginx-ingress.
#
# IoT устройство подключается: ws://iot.kube5s.ru/mqtt
# IoT Консоль (UI): http://iot.kube5s.ru/console
#
# DNS A-запись: iot.kube5s.ru → 185.247.187.147 (создана через Nubes API, zoneUid=498096ee)
#
# Когда DevOps откроет порт 1883 — этот файл можно удалить.
@@ -42,6 +44,7 @@ spec:
- host: iot.kube5s.ru
http:
paths:
# MQTT over WebSocket — для подключения IoT устройств и браузерного эмулятора
- path: /mqtt
pathType: Exact
backend:
@@ -49,3 +52,13 @@ spec:
name: emqx-ws
port:
number: 8083
# IoT Консоль (UI) — HTML SPA встроенный в бинарник sless-operator
# URL: http://iot.kube5s.ru/console
# Почему здесь: UI работает по HTTP чтобы ws:// (без TLS) не вызывал mixed content
- path: /console
pathType: Exact
backend:
service:
name: sless-operator
port:
number: 9090
+1 -1
View File
@@ -44,7 +44,7 @@ spec:
- name: mqtt-bridge
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
# При смене версии оператора — менять тег и здесь.
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.50
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.53
imagePullPolicy: Always
command: ["/iot-mqtt-bridge"]
env:
+1 -1
View File
@@ -75,7 +75,7 @@ spec:
- name: operator
# При обновлении версии оператора — менять тег здесь (не latest!)
# v0.1.50 — добавлены IoT controller, IoT REST API, MQTT auth endpoint
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.50
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.53
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always
ports:
+54
View File
@@ -213,3 +213,57 @@ namespace: iot-{hash} — tenant IoT (IoTDevice CRDs)
- Индекс: `(device_id, ts DESC)` для быстрых запросов по устройству за период
- Retention: пока без TTL, добавить позже через pg_partman или cron job
---
## 2026-04-04 — IoT Console UI: план и реализация
**Агент**: GitHub Copilot (Claude Sonnet 4.6)
### Постановка задачи
Пользователь сформулировал: нужен UI для управления IoT устройствами.
Причина: не все пользователи работают через Terraform/API напрямую.
Нужно: создать устройство, получить credentials, прошить в устройство, проверить отправку данных.
### Ключевое решение: эмулятор устройства в браузере
MQTT WebSocket уже работает: `ws://iot.kube5s.ru:80/mqtt`.
Браузер через mqtt.js (CDN) может подключиться как устройство напрямую.
Это значит: эмулятор — это не "симуляция", а реальная публикация MQTT сообщений.
Когда у клиента ещё нет физического устройства — он тестирует через эмулятор.
Это закрывает весь цикл без необходимости устанавливать MQTT-клиент.
### Архитектурные решения UI
**Стек**: ванильный HTML/CSS/JS + mqtt.js (CDN). Никаких фреймворков.
**Где хранить**: встраиваем в бинарник оператора через `go:embed`.
- Файл: `internal/api/ui/iot-console.html`
- Маршрут: `GET /console`
**Где доступен**: `http://iot.kube5s.ru/console`
- Ingress добавляем path `/console``sless-operator:9090`
**Почему не `https://sless.kube5s.ru/console`:**
- UI на HTTPS + MQTT WS без TLS = mixed content, браузер блокирует
- UI на HTTP + MQTT WS = нет mixed content, всё работает
- HTTP → HTTPS API вызовы разрешены (это не mixed content)
- Нужен только CORS на API стороне
**CORS**: заголовки `Access-Control-Allow-Origin: http://iot.kube5s.ru` + OPTIONS preflight
### Страницы
1. Вход: API адрес + MQTT брокер + namespace + токен → localStorage
2. Список устройств: таблица, создать, удалить
3. Устройство (3 вкладки):
- Credentials: username, password скрыт, топик, инструкция
- Эмулятор: подключиться → JSON payload → send / авто
- Телеметрия: "скоро"
### Следующие шаги после UI
1. Postgres StatefulSet в namespace `iot`
2. INSERT в iot_telemetry из mqtt-bridge
3. REST API для чтения телеметрии
4. Заполнить вкладку "Телеметрия" в UI
+32
View File
@@ -0,0 +1,32 @@
// Создано: 2026-04-04
// console_embed.go — встраивает HTML-файл IoT консоли в бинарник оператора через go:embed.
//
// Файл ui/iot-console.html встраивается при компиляции и раздаётся по GET /console.
// Путь /console доступен без JWT — это публичная статическая страница.
// Авторизация в UI происходит через Bearer-токен который пользователь вводит сам.
//
// Почему go:embed а не отдельный nginx: нет лишних pod'ов, единый деплой, нет drift.
// Почему /console без auth: HTML файл не содержит секретов, токен вводит пользователь.
package api
import (
_ "embed"
"net/http"
)
// iotConsoleHTML — бинарное содержимое IoT консоли, встроенное при сборке.
// При изменении HTML-файла достаточно пересобрать оператор.
//
//go:embed ui/iot-console.html
var iotConsoleHTML []byte
// ServeIoTConsole обрабатывает GET /console — отдаёт HTML SPA без JWT-проверки.
// Браузер кэширует HTML; API-запросы из JS защищены Bearer-токеном.
func ServeIoTConsole(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Не кэшировать агрессивно — консоль обновляется вместе с оператором
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(iotConsoleHTML)
}
+30 -8
View File
@@ -1,7 +1,8 @@
// Изменено: 2026-03-20 (function-service-split: добавлены /services маршруты)
// Изменено: 2026-04-04 (iot-console: добавлен CORS middleware + /console маршрут)
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты защищены Bearer-токеном (middleware.Auth).
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
// Все маршруты /v1/ защищены Bearer-токеном (middleware.Auth).
// /console — публичный маршрут (статический HTML без auth).
// CORS включён для http://iot.kube5s.ru — там хостится IoT Консоль (UI).
package api
@@ -15,12 +16,34 @@ import (
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/middleware"
)
// corsMiddleware добавляет CORS-заголовки для IoT Консоли на http://iot.kube5s.ru.
// Нужен потому что: консоль на http://iot.kube5s.ru, API на https://sless.kube5s.ru — разные origin.
// Обрабатывает preflight OPTIONS запросы от браузера.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://iot.kube5s.ru")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
// Preflight OPTIONS возвращаем немедленно без передачи дальше
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// NewRouter собирает gorilla/mux роутер со всеми маршрутами.
// /fn/{namespace}/{name} — публичный прокси для вызова функций, без auth.
// /v1/ — защищён JWT-аутентификацией (middleware.Auth).
// /console — IoT Консоль (HTML SPA), без auth, с CORS.
// /v1/ — защищён JWT-аутентификацией (middleware.Auth), с CORS.
func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
r := mux.NewRouter()
// IoT Консоль — статический HTML, публично доступен
r.HandleFunc("/console", ServeIoTConsole).Methods(http.MethodGet)
// Публичный прокси для вызова HTTP-триггеров — без auth токена
// Все HTTP методы разрешены (GET/POST/PUT/... — решает сама функция)
r.PathPrefix("/fn/{namespace}/{name}").HandlerFunc(h.InvokeFunction)
@@ -84,12 +107,11 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
// Изолирует клиента в пределах его топиков: {namespace}/{deviceId}/#
r.HandleFunc("/internal/mqtt/acl", h.MQTTAcl).Methods(http.MethodPost)
// Цепочка middleware: logging → (auth только для /v1/) → router
// /fn/ — без auth, /v1/ — с auth.
// Используем gorilla/mux Use() чтобы auth применялся только к v1 суброутеру.
// Цепочка middleware: CORS → logging → (auth только для /v1/) → router
// /fn/ — без auth, /console — без auth, /v1/ — с auth.
v1.Use(func(next http.Handler) http.Handler {
return middleware.Auth(log, next)
})
return middleware.Logging(log, r)
return corsMiddleware(middleware.Logging(log, r))
}
+972
View File
@@ -0,0 +1,972 @@
<!DOCTYPE html>
<!-- Создано: 2026-04-04
IoT Консоль — SPA для управления IoT устройствами.
Стек: ванильный HTML/CSS/JS + mqtt.js (CDN).
Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
Встраивается в оператор через go:embed, раздаётся по GET /console.
Доступна по: http://iot.kube5s.ru/console -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IoT Консоль</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0f172a; color: #e2e8f0; min-height: 100vh;
}
/* Navbar */
.navbar {
background: #1e293b; border-bottom: 1px solid #334155;
padding: 0 24px; height: 56px;
display: flex; align-items: center; gap: 12px;
}
.navbar-brand {
font-weight: 800; font-size: 17px; color: #3b82f6; cursor: pointer;
display: flex; align-items: center; gap: 6px; user-select: none;
}
.navbar-ns {
background: #334155; border-radius: 4px; padding: 3px 10px;
font-size: 12px; color: #94a3b8; font-family: monospace;
}
.navbar-spacer { flex: 1; }
/* Layout */
.container { max-width: 920px; margin: 0 auto; padding: 32px 24px; }
/* Card */
.card {
background: #1e293b; border-radius: 10px; border: 1px solid #334155;
padding: 24px; margin-bottom: 16px;
}
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
/* Form */
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 13px; color: #94a3b8; margin-bottom: 6px; }
input, textarea, select {
width: 100%; background: #0f172a; border: 1px solid #334155;
border-radius: 6px; padding: 10px 12px; color: #e2e8f0;
font-size: 14px; font-family: inherit;
}
input:focus, textarea:focus { outline: none; border-color: #3b82f6; }
textarea { resize: vertical; font-family: 'Courier New', monospace; line-height: 1.5; }
input[type="number"] { width: auto; }
/* Buttons */
.btn {
border: none; border-radius: 6px; padding: 10px 18px; font-size: 14px;
font-weight: 500; cursor: pointer; transition: opacity 0.15s; display: inline-flex;
align-items: center; gap: 6px; white-space: nowrap;
}
.btn:hover:not(:disabled) { opacity: 0.82; }
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-success { background: #22c55e; color: #0f172a; font-weight: 600; }
.btn-warning { background: #f59e0b; color: #0f172a; font-weight: 600; }
.btn-ghost { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
/* Table */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; font-size: 11px; color: #64748b;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 0 14px 10px; border-bottom: 1px solid #334155;
}
td { padding: 13px 14px; border-bottom: 1px solid rgba(51,65,85,0.5); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(255,255,255,0.02); }
/* Badges */
.badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 500; border-radius: 9999px; padding: 2px 10px;
}
.badge::before { content: '●'; font-size: 7px; }
.badge-active { background: rgba(34,197,94,0.12); color: #22c55e; }
.badge-active::before { color: #22c55e; }
.badge-disabled { background: rgba(100,116,139,0.12); color: #64748b; }
.badge-disabled::before { color: #64748b; }
.badge-error { background: rgba(239,68,68,0.12); color: #ef4444; }
.badge-error::before { color: #ef4444; }
.badge-pending { background: rgba(251,191,36,0.12); color: #fbbf24; }
.badge-pending::before { color: #fbbf24; }
/* Tabs */
.tabs {
display: flex; border-bottom: 1px solid #334155; margin-bottom: 24px;
}
.tab {
padding: 12px 20px; font-size: 14px; font-weight: 500; cursor: pointer;
color: #64748b; border-bottom: 2px solid transparent; transition: all 0.15s;
user-select: none;
}
.tab:hover { color: #cbd5e1; }
.tab.active { color: #3b82f6; border-bottom-color: #3b82f6; }
/* Credentials rows */
.cred-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.cred-label { font-size: 12px; color: #64748b; min-width: 130px; }
.cred-value {
font-family: 'Courier New', monospace; font-size: 13px;
background: #0a0f1a; padding: 8px 12px; border-radius: 4px;
border: 1px solid #334155; flex: 1; color: #cbd5e1;
overflow-x: auto; word-break: break-all;
}
/* Connection status pill */
.conn-pill {
display: inline-flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 500; padding: 5px 14px; border-radius: 9999px;
}
.conn-pill::before { content: '●'; font-size: 7px; }
.conn-pill.off { background: rgba(100,116,139,0.12); color: #64748b; }
.conn-pill.off::before { color: #64748b; }
.conn-pill.ing { background: rgba(251,191,36,0.12); color: #fbbf24; }
.conn-pill.ing::before { color: #fbbf24; animation: blink 1s step-start infinite; }
.conn-pill.on { background: rgba(34,197,94,0.12); color: #22c55e; }
.conn-pill.on::before { color: #22c55e; }
@keyframes blink { 50% { opacity: 0; } }
/* MQTT log */
.mqtt-log {
background: #080d18; border: 1px solid #334155; border-radius: 6px;
padding: 10px 12px; max-height: 180px; overflow-y: auto;
font-family: 'Courier New', monospace; font-size: 12px;
line-height: 1.6;
}
.mqtt-log .ok { color: #22c55e; }
.mqtt-log .err { color: #ef4444; }
.mqtt-log .info { color: #64748b; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.modal {
background: #1e293b; border: 1px solid #334155; border-radius: 12px;
padding: 28px; width: 100%; max-width: 460px;
}
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 20px; }
/* Alerts */
.alert { padding: 11px 15px; border-radius: 6px; font-size: 13px; margin-bottom: 14px; }
.alert-error { background: rgba(239,68,68,0.12); color: #f87171; border: 1px solid rgba(239,68,68,0.2); }
.alert-success { background: rgba(34,197,94,0.12); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
.alert-info { background: rgba(59,130,246,0.12); color: #60a5fa; border: 1px solid rgba(59,130,246,0.2); }
/* Misc */
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.flex1 { flex: 1; }
.mt8 { margin-top: 8px; }
.mt16 { margin-top: 16px; }
.mt24 { margin-top: 24px; }
.separator { border: none; border-top: 1px solid #334155; margin: 20px 0; }
.hint { font-size: 12px; color: #64748b; line-height: 1.6; }
.mono { font-family: 'Courier New', monospace; }
.coming-soon {
text-align: center; padding: 56px 24px; color: #475569;
}
.coming-soon .icon { font-size: 40px; margin-bottom: 12px; }
.coming-soon h3 { font-size: 18px; font-weight: 600; margin-bottom: 8px; color: #64748b; }
a { color: #3b82f6; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div id="app"></div>
<!-- mqtt.js CDN — подключение к EMQX через WebSocket напрямую из браузера -->
<script src="https://unpkg.com/mqtt@5.3.5/dist/mqtt.min.js"></script>
<script>
// =============================================================
// STATE — всё состояние приложения в одном объекте
// =============================================================
const S = {
// Сохраняемые настройки
token: localStorage.getItem('iot_token') || '',
ns: localStorage.getItem('iot_ns') || '',
apiBase: localStorage.getItem('iot_api_base') || 'https://sless.kube5s.ru',
mqttBase: localStorage.getItem('iot_mqtt_base')|| 'ws://iot.kube5s.ru/mqtt',
// Рабочие данные
devices: [],
deviceDetail: null, // IoTDevice с паролем из GET /devices/{name}
currentPage: 'login',
currentDevice: null, // имя текущего устройства
activeTab: 'creds', // 'creds' | 'emulator' | 'telemetry'
// MQTT состояние эмулятора
mqttClient: null,
mqttConnected: false,
mqttConnecting: false,
mqttLog: [],
autoTimer: null,
};
// Сохраняем auth-параметры в localStorage
function saveSettings() {
localStorage.setItem('iot_token', S.token);
localStorage.setItem('iot_ns', S.ns);
localStorage.setItem('iot_api_base', S.apiBase);
localStorage.setItem('iot_mqtt_base',S.mqttBase);
}
// =============================================================
// API
// =============================================================
// apiCall — универсальная обёртка для REST запросов к оператору
async function apiCall(method, path, body) {
const opts = {
method,
headers: {
'Authorization': 'Bearer ' + S.token,
'Content-Type': 'application/json',
},
};
if (body !== undefined) opts.body = JSON.stringify(body);
let resp;
try {
resp = await fetch(S.apiBase + path, opts);
} catch (e) {
throw new Error('Сеть недоступна: ' + e.message);
}
if (resp.status === 204) return null;
let data;
try { data = await resp.json(); } catch(e) { data = {}; }
if (!resp.ok) throw new Error(data.error || 'HTTP ' + resp.status);
return data;
}
// Загрузить список устройств (без паролей)
function apiListDevices() {
return apiCall('GET', `/v1/namespaces/${S.ns}/iot/devices`);
}
// Получить одно устройство с паролем
function apiGetDevice(name) {
return apiCall('GET', `/v1/namespaces/${S.ns}/iot/devices/${name}`);
}
// Создать устройство
function apiCreateDevice(name, deviceId) {
return apiCall('POST', `/v1/namespaces/${S.ns}/iot/devices`, {
name, device_id: deviceId, enabled: true,
});
}
// Удалить устройство
function apiDeleteDevice(name) {
return apiCall('DELETE', `/v1/namespaces/${S.ns}/iot/devices/${name}`);
}
// =============================================================
// MQTT ЭМУЛЯТОР
// =============================================================
// mqttConnect — подключить браузер к EMQX как устройство
function mqttConnect(username, password) {
mqttCleanup();
S.mqttConnecting = true;
S.mqttConnected = false;
S.mqttLog = [];
const clientId = 'console-emu-' + Math.random().toString(16).slice(2, 10);
S.mqttClient = mqtt.connect(S.mqttBase, {
username,
password,
clientId,
clean: true,
connectTimeout: 12000,
reconnectPeriod: 0, // не переподключаться автоматически
});
S.mqttClient.on('connect', () => {
S.mqttConnecting = false;
S.mqttConnected = true;
mqttLogEntry('ok', 'Подключён к ' + S.mqttBase);
emulatorPartialUpdate();
});
S.mqttClient.on('error', (err) => {
S.mqttConnecting = false;
S.mqttConnected = false;
mqttLogEntry('err', 'Ошибка: ' + (err.message || String(err)));
emulatorPartialUpdate();
});
S.mqttClient.on('close', () => {
if (S.mqttConnected) mqttLogEntry('info', 'Соединение закрыто');
S.mqttConnected = false;
S.mqttConnecting = false;
mqttStopAuto();
emulatorPartialUpdate();
});
mqttLogEntry('info', 'Подключение к ' + S.mqttBase + '...');
emulatorPartialUpdate();
}
// mqttPublish — опубликовать JSON сообщение в топик
function mqttPublish(topic, payloadStr) {
if (!S.mqttClient || !S.mqttConnected) {
mqttLogEntry('err', 'Не подключён');
emulatorRefreshLog();
return;
}
// Проверяем что payload — валидный JSON
try { JSON.parse(payloadStr); } catch(e) {
mqttLogEntry('err', 'Неверный JSON: ' + e.message);
emulatorRefreshLog();
return;
}
S.mqttClient.publish(topic, payloadStr, { qos: 1 }, (err) => {
if (err) {
mqttLogEntry('err', 'Отправка не удалась: ' + err.message);
} else {
mqttLogEntry('ok', new Date().toLocaleTimeString('ru') + ' → ' + topic);
}
emulatorRefreshLog();
});
}
// mqttDisconnect — отключить клиент
function mqttDisconnect() {
mqttStopAuto();
mqttCleanup();
emulatorPartialUpdate();
}
// mqttCleanup — принудительно разорвать соединение
function mqttCleanup() {
if (S.mqttClient) {
try { S.mqttClient.end(true); } catch(_) {}
S.mqttClient = null;
}
S.mqttConnected = false;
S.mqttConnecting = false;
}
// mqttStartAuto — запустить авто-отправку каждые N секунд
function mqttStartAuto(topic, getPayload, intervalSec) {
mqttStopAuto();
S.autoTimer = setInterval(() => mqttPublish(topic, getPayload()), intervalSec * 1000);
}
// mqttStopAuto — остановить авто-отправку
function mqttStopAuto() {
if (S.autoTimer) { clearInterval(S.autoTimer); S.autoTimer = null; }
}
// mqttLogEntry — добавить запись в лог эмулятора (FIFO max 60)
function mqttLogEntry(type, msg) {
S.mqttLog.push({ type, msg });
if (S.mqttLog.length > 60) S.mqttLog.shift();
}
// =============================================================
// PARTIAL DOM UPDATES — обновление без полного перерисовки
// =============================================================
// Обновить только UI статуса подключения и кнопки без полного ре-рендера
function emulatorPartialUpdate() {
const statusEl = document.getElementById('emu-status');
const btnsEl = document.getElementById('emu-btns');
const sendBox = document.getElementById('emu-send-box');
if (statusEl) statusEl.innerHTML = renderConnPill();
if (btnsEl) btnsEl.innerHTML = renderConnBtns();
if (sendBox) sendBox.style.display = S.mqttConnected ? '' : 'none';
emulatorRefreshLog();
}
// Перерисовать только лог
function emulatorRefreshLog() {
const el = document.getElementById('mqtt-log');
if (!el) return;
el.innerHTML = S.mqttLog
.map(l => `<div class="${l.type}">${h(l.msg)}</div>`)
.join('');
el.scrollTop = el.scrollHeight;
}
// =============================================================
// НАВИГАЦИЯ
// =============================================================
function nav(page, param) {
S.currentPage = page;
S.currentDevice = param || null;
if (page !== 'device') {
// Отключаем MQTT и авто при уходе со страницы устройства
mqttStopAuto();
mqttCleanup();
S.activeTab = 'creds';
}
render();
}
// =============================================================
// РЕНДЕР — ГЛАВНЫЙ ДИСПЕТЧЕР
// =============================================================
function render() {
if (!S.token || !S.ns) {
renderLogin();
return;
}
if (S.currentPage === 'device' && S.currentDevice) {
renderDevicePage(S.currentDevice);
return;
}
renderDevices();
}
// =============================================================
// СТРАНИЦА — ВХОД
// =============================================================
function renderLogin() {
app().innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px;">
<div class="card" style="width:100%;max-width:420px;">
<div style="text-align:center;margin-bottom:28px;">
<div style="font-size:32px;margin-bottom:8px;">⚡</div>
<div style="font-size:22px;font-weight:800;color:#3b82f6;">IoT Консоль</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">Управление устройствами и телеметрией</div>
</div>
<div id="login-err"></div>
<div class="form-group">
<label>API (адрес оператора)</label>
<input id="f-api" value="${h(S.apiBase)}" placeholder="https://sless.kube5s.ru">
</div>
<div class="form-group">
<label>MQTT Broker WebSocket</label>
<input id="f-mqtt" value="${h(S.mqttBase)}" placeholder="ws://iot.kube5s.ru/mqtt">
</div>
<div class="form-group">
<label>Namespace</label>
<input id="f-ns" value="${h(S.ns)}" placeholder="my-namespace" autocomplete="off">
</div>
<div class="form-group">
<label>Bearer токен</label>
<input id="f-tok" type="password" value="${h(S.token)}" placeholder="eyJ...">
</div>
<button class="btn btn-primary mt8" style="width:100%;" id="login-btn" onclick="doLogin()">
Войти
</button>
</div>
</div>`;
document.getElementById('f-tok').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
}
// doLogin — проверить настройки и перейти к устройствам
async function doLogin() {
const api = (document.getElementById('f-api').value || '').trim().replace(/\/$/, '');
const mqtt = (document.getElementById('f-mqtt').value || '').trim();
const ns = (document.getElementById('f-ns').value || '').trim();
const tok = (document.getElementById('f-tok').value || '').trim();
if (!ns) { loginErr('Укажите namespace'); return; }
if (!tok) { loginErr('Укажите токен'); return; }
S.apiBase = api || 'https://sless.kube5s.ru';
S.mqttBase = mqtt || 'ws://iot.kube5s.ru/mqtt';
S.ns = ns;
S.token = tok;
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Проверка...';
try {
await apiListDevices(); // проверяем что токен и namespace валидны
saveSettings();
nav('devices');
} catch(e) {
S.token = '';
loginErr(e.message);
btn.disabled = false;
btn.textContent = 'Войти';
}
}
function loginErr(msg) {
const el = document.getElementById('login-err');
if (el) el.innerHTML = `<div class="alert alert-error">${h(msg)}</div>`;
}
// =============================================================
// СТРАНИЦА — СПИСОК УСТРОЙСТВ
// =============================================================
async function renderDevices() {
app().innerHTML = `
${navbar()}
<div class="container">
<div class="row mt24" style="margin-bottom:24px;">
<h1 style="font-size:22px;font-weight:700;flex:1;">Устройства</h1>
<button class="btn btn-primary" onclick="showAddModal()">+ Добавить</button>
</div>
<div id="devices-area">
<div style="text-align:center;padding:48px;color:#64748b;">Загрузка...</div>
</div>
</div>
<div id="modal-root"></div>`;
try {
S.devices = await apiListDevices() || [];
document.getElementById('devices-area').innerHTML = devicesTable(S.devices);
} catch(e) {
document.getElementById('devices-area').innerHTML =
`<div class="alert alert-error">Ошибка загрузки: ${h(e.message)}</div>`;
}
}
// devicesTable — отрисовать таблицу или пустой стейт
function devicesTable(devices) {
if (!devices.length) {
return `<div class="card" style="text-align:center;padding:56px;">
<div style="font-size:36px;margin-bottom:12px;">📡</div>
<p style="font-size:16px;font-weight:500;margin-bottom:6px;">Устройств нет</p>
<p class="hint">Нажмите «+ Добавить» чтобы создать первое устройство</p>
</div>`;
}
const rows = devices.map(d => `
<tr>
<td>
<a href="#" onclick="openDevice('${h(d.name)}');return false;">
<strong>${h(d.name)}</strong>
</a>
</td>
<td class="mono" style="font-size:13px;color:#94a3b8;">${h(d.device_id)}</td>
<td>${phaseBadge(d.phase)}</td>
<td class="hint">${d.last_connected || '—'}</td>
<td>
<div class="row" style="gap:6px;">
<button class="btn btn-ghost btn-sm" onclick="openDevice('${h(d.name)}')">Открыть</button>
<button class="btn btn-danger btn-sm" onclick="confirmDel('${h(d.name)}')">✕</button>
</div>
</td>
</tr>`).join('');
return `<div class="card" style="padding:0;overflow:hidden;">
<table>
<thead>
<tr>
<th>Имя</th><th>Device ID</th><th>Статус</th><th>Последнее подключение</th><th></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function openDevice(name) {
S.activeTab = 'creds';
nav('device', name);
}
// Показать модальное окно создания устройства
function showAddModal() {
document.getElementById('modal-root').innerHTML = `
<div class="modal-overlay" onclick="closeOverlay(event)">
<div class="modal">
<div class="modal-title">Добавить устройство</div>
<div id="m-err"></div>
<div class="form-group">
<label>Имя</label>
<input id="m-name" placeholder="sensor-1" autofocus>
</div>
<div class="form-group">
<label>Device ID <span style="color:#475569">(будет в MQTT username)</span></label>
<input id="m-did" placeholder="sensor-1">
</div>
<div class="row mt16" style="justify-content:flex-end;">
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" id="m-save" onclick="doCreate()">Создать</button>
</div>
</div>
</div>`;
const nameEl = document.getElementById('m-name');
const didEl = document.getElementById('m-did');
nameEl.focus();
// Автоподставить device_id = name пока пользователь не трогал поле
nameEl.addEventListener('input', () => {
if (!didEl._user) didEl.value = nameEl.value;
});
didEl.addEventListener('input', () => { didEl._user = true; });
}
async function doCreate() {
const name = document.getElementById('m-name').value.trim();
const did = document.getElementById('m-did').value.trim();
if (!name) { document.getElementById('m-err').innerHTML = '<div class="alert alert-error">Укажите имя</div>'; return; }
if (!did) { document.getElementById('m-err').innerHTML = '<div class="alert alert-error">Укажите device_id</div>'; return; }
const btn = document.getElementById('m-save');
btn.disabled = true; btn.textContent = 'Создание...';
try {
await apiCreateDevice(name, did);
closeModal();
await renderDevices();
} catch(e) {
btn.disabled = false; btn.textContent = 'Создать';
document.getElementById('m-err').innerHTML = `<div class="alert alert-error">${h(e.message)}</div>`;
}
}
async function confirmDel(name) {
if (!confirm(`Удалить устройство "${name}"?\n\nMQTT credentials будут удалены — устройство потеряет доступ.`)) return;
try {
await apiDeleteDevice(name);
await renderDevices();
} catch(e) {
alert('Ошибка: ' + e.message);
}
}
function closeModal() { document.getElementById('modal-root').innerHTML = ''; }
function closeOverlay(e) { if (e.target === e.currentTarget) closeModal(); }
// =============================================================
// СТРАНИЦА — УСТРОЙСТВО
// =============================================================
async function renderDevicePage(name) {
app().innerHTML = `
${navbar()}
<div class="container">
<div style="margin-bottom:20px;">
<a href="#" onclick="nav('devices');return false;" style="font-size:14px;color:#64748b;">
← Все устройства
</a>
</div>
<div id="device-area">
<div style="text-align:center;padding:48px;color:#64748b;">Загрузка...</div>
</div>
</div>`;
try {
S.deviceDetail = await apiGetDevice(name);
document.getElementById('device-area').innerHTML = deviceLayout(S.deviceDetail);
} catch(e) {
document.getElementById('device-area').innerHTML =
`<div class="alert alert-error">Ошибка: ${h(e.message)}</div>`;
}
}
// deviceLayout — заголовок + вкладки + контент вкладки
function deviceLayout(d) {
return `
<div class="row" style="margin-bottom:20px;align-items:flex-start;">
<div>
<h1 style="font-size:22px;font-weight:700;">${h(d.name)}</h1>
<div style="margin-top:6px;font-size:13px;color:#64748b;">
device_id: <span class="mono">${h(d.device_id)}</span>
</div>
</div>
<div style="margin-left:12px;">${phaseBadge(d.phase)}</div>
</div>
<div class="tabs">
<div class="tab ${S.activeTab==='creds' ? 'active':''}" onclick="switchTab('creds')" >Credentials</div>
<div class="tab ${S.activeTab==='emulator' ? 'active':''}" onclick="switchTab('emulator')">Эмулятор</div>
<div class="tab ${S.activeTab==='telemetry'? 'active':''}" onclick="switchTab('telemetry')">Телеметрия</div>
</div>
<div id="tab-body">${tabContent(d, S.activeTab)}</div>`;
}
// switchTab — переключить вкладку без перезагрузки данных устройства
function switchTab(tab) {
if (tab !== 'emulator') {
// Останавливаем авто-отправку при уходе с вкладки эмулятора
mqttStopAuto();
}
S.activeTab = tab;
// Обновляем active-класс на вкладках
document.querySelectorAll('.tab').forEach((el, i) => {
el.classList.toggle('active', ['creds','emulator','telemetry'][i] === tab);
});
const body = document.getElementById('tab-body');
if (body && S.deviceDetail) {
body.innerHTML = tabContent(S.deviceDetail, tab);
}
}
function tabContent(d, tab) {
if (tab === 'creds') return credsTab(d);
if (tab === 'emulator') return emulatorTab(d);
if (tab === 'telemetry') return telemetryTab();
return '';
}
// =============================================================
// ВКЛАДКА — CREDENTIALS
// =============================================================
function credsTab(d) {
if (!d.mqtt_username || !d.mqtt_password) {
return `<div class="card">
<div class="alert alert-info" style="margin-bottom:0;">
Credentials ещё не готовы — устройство в статусе <strong>${h(d.phase || 'Pending')}</strong>.
<a href="#" onclick="renderDevicePage('${h(d.name)}');return false;">Обновить</a>
</div>
</div>`;
}
const topic = (d.topic_prefix || '') + 'telemetry/' + d.device_id;
return `<div class="card">
<div class="card-title">MQTT Credentials</div>
<div class="cred-row">
<div class="cred-label">Username</div>
<div class="cred-value">${h(d.mqtt_username)}</div>
<button class="btn btn-ghost btn-sm" onclick="copyVal('${h(d.mqtt_username)}',this)">Копировать</button>
</div>
<div class="cred-row">
<div class="cred-label">Password</div>
<div class="cred-value" id="pw-display">••••••••••••••••</div>
<button class="btn btn-ghost btn-sm" id="pw-toggle-btn" onclick="togglePw()">Показать</button>
</div>
<div class="cred-row">
<div class="cred-label">Топик</div>
<div class="cred-value">${h(topic)}</div>
<button class="btn btn-ghost btn-sm" onclick="copyVal('${h(topic)}',this)">Копировать</button>
</div>
<div class="cred-row">
<div class="cred-label">Broker URL</div>
<div class="cred-value">${h(S.mqttBase)}</div>
</div>
<hr class="separator">
<div style="font-size:13px;font-weight:600;color:#94a3b8;margin-bottom:10px;">
Подключение физического устройства
</div>
<div class="hint">
<p>1. Запишите параметры в прошивку устройства:</p>
<div class="cred-value" style="margin:8px 0;padding:12px;line-height:1.8;">
broker &nbsp;&nbsp;&nbsp;${h(S.mqttBase)}<br>
username &nbsp;${h(d.mqtt_username)}<br>
password &nbsp;(см. выше)<br>
topic &nbsp;&nbsp;&nbsp;&nbsp;${h(topic)}
</div>
<p>2. Устройство должно публиковать JSON в топик.</p>
<p>3. Чтобы проверить отправку — перейдите на вкладку <strong>Эмулятор</strong>.</p>
</div>
<div class="mt16">
<button class="btn btn-ghost btn-sm" onclick="copyAll('${h(d.mqtt_username)}','${h(topic)}',this)">
📋 Скопировать всё
</button>
</div>
</div>`;
}
// togglePw — показать/скрыть пароль (читаем из S.deviceDetail, не из атрибутов)
function togglePw() {
const el = document.getElementById('pw-display');
const btn = document.getElementById('pw-toggle-btn');
if (!el || !btn || !S.deviceDetail) return;
const revealed = el.dataset.revealed === '1';
if (revealed) {
el.textContent = '••••••••••••••••';
el.dataset.revealed = '0';
btn.textContent = 'Показать';
} else {
el.textContent = S.deviceDetail.mqtt_password;
el.dataset.revealed = '1';
btn.textContent = 'Скрыть';
}
}
function copyAll(user, topic, btn) {
const pass = S.deviceDetail ? S.deviceDetail.mqtt_password : '';
copyVal(`broker: ${S.mqttBase}\nusername: ${user}\npassword: ${pass}\ntopic: ${topic}`, btn);
}
// =============================================================
// ВКЛАДКА — ЭМУЛЯТОР
// =============================================================
function renderConnPill() {
if (S.mqttConnecting) return `<div class="conn-pill ing">Подключение...</div>`;
if (S.mqttConnected) return `<div class="conn-pill on">Подключён</div>`;
return `<div class="conn-pill off">Не подключён</div>`;
}
function renderConnBtns() {
if (S.mqttConnected) return `<button class="btn btn-warning btn-sm" onclick="mqttDisconnect();emulatorPartialUpdate()">Отключиться</button>`;
if (S.mqttConnecting) return `<button class="btn btn-ghost btn-sm" disabled>...</button>`;
return `<button class="btn btn-success btn-sm" onclick="emuConnect()">Подключиться</button>`;
}
function emulatorTab(d) {
const topic = (d.topic_prefix || '') + 'telemetry/' + d.device_id;
const hasCreds = d.mqtt_username && d.mqtt_password;
if (!hasCreds) {
return `<div class="card">
<div class="alert alert-info" style="margin-bottom:0;">
Credentials не готовы. Перейдите на вкладку <strong>Credentials</strong> и убедитесь что устройство активно.
</div>
</div>`;
}
return `<div class="card">
<!-- Статус подключения и кнопки управления -->
<div class="row" style="margin-bottom:20px;">
<div id="emu-status">${renderConnPill()}</div>
<div id="emu-btns">${renderConnBtns()}</div>
</div>
<!-- Скрытые поля с данными устройства для JS -->
<input type="hidden" id="emu-topic" value="${h(topic)}">
<input type="hidden" id="emu-username" value="${h(d.mqtt_username)}">
<!-- Блок отправки — виден только когда подключены -->
<div id="emu-send-box" style="display:${S.mqttConnected ? '' : 'none'}">
<div class="form-group">
<label>Payload (JSON)</label>
<textarea id="emu-payload" rows="4">{"temp": 22.5, "humidity": 60}</textarea>
</div>
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick="emuSendOnce()">Отправить</button>
<div class="row" style="gap:8px;">
<span class="hint">Авто:</span>
<button class="btn btn-ghost btn-sm" id="auto-btn" onclick="toggleAuto()">
${S.autoTimer ? 'Стоп' : 'Запустить'}
</button>
<span class="hint">каждые</span>
<input type="number" id="auto-sec" value="5" min="1" max="300" style="width:64px;text-align:center;">
<span class="hint">сек</span>
</div>
</div>
</div>
<!-- Лог MQTT сообщений -->
<div class="hint" style="margin-bottom:6px;">Лог</div>
<div class="mqtt-log" id="mqtt-log">
${S.mqttLog.map(l => `<div class="${h(l.type)}">${h(l.msg)}</div>`).join('')}
</div>
</div>`;
}
// emuConnect — подключиться как текущее устройство
function emuConnect() {
if (!S.deviceDetail) return;
// Пароль берём из state, не из DOM
mqttConnect(S.deviceDetail.mqtt_username, S.deviceDetail.mqtt_password);
}
// emuSendOnce — разовая отправка
function emuSendOnce() {
const topic = (document.getElementById('emu-topic') || {}).value || '';
const payload = (document.getElementById('emu-payload') || {}).value || '{}';
mqttPublish(topic, payload.trim());
}
// toggleAuto — включить/выключить авто-отправку
function toggleAuto() {
const btn = document.getElementById('auto-btn');
if (S.autoTimer) {
mqttStopAuto();
if (btn) btn.textContent = 'Запустить';
return;
}
const sec = parseInt((document.getElementById('auto-sec') || {}).value) || 5;
const topic = (document.getElementById('emu-topic') || {}).value || '';
if (btn) btn.textContent = 'Стоп';
mqttStartAuto(topic, () => (document.getElementById('emu-payload') || {value:'{}'}).value.trim(), sec);
}
// =============================================================
// ВКЛАДКА — ТЕЛЕМЕТРИЯ
// =============================================================
function telemetryTab() {
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;">
Требует: Postgres StatefulSet в namespace <span class="mono">iot</span>
</p>
</div>
</div>`;
}
// =============================================================
// NAVBAR
// =============================================================
function navbar() {
return `
<nav class="navbar">
<div class="navbar-brand" onclick="nav('devices')">⚡ IoT Консоль</div>
<div class="navbar-ns">${h(S.ns)}</div>
<div class="navbar-spacer"></div>
<button class="btn btn-ghost btn-sm" onclick="doLogout()">Выйти</button>
</nav>`;
}
function doLogout() {
mqttStopAuto();
mqttCleanup();
S.token = '';
localStorage.removeItem('iot_token');
nav('login');
}
// =============================================================
// HELPERS
// =============================================================
// h — экранирование HTML (XSS защита)
function h(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// app — ссылка на корневой DOM-элемент
function app() { return document.getElementById('app'); }
// phaseBadge — бейдж статуса устройства
function phaseBadge(phase) {
const map = { Active:'active', Disabled:'disabled', Error:'error' };
const cls = map[phase] || 'pending';
return `<span class="badge badge-${cls}">${h(phase || 'Pending')}</span>`;
}
// copyVal — скопировать текст в буфер обмена с визуальной обратной связью
function copyVal(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = '✓';
setTimeout(() => { btn.textContent = orig; }, 1500);
}).catch(() => alert('Скопируйте вручную:\n' + text));
}
// =============================================================
// СТАРТ
// =============================================================
render();
</script>
</body>
</html>