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:
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ${h(S.mqttBase)}<br>
|
||||
username ${h(d.mqtt_username)}<br>
|
||||
password (см. выше)<br>
|
||||
topic ${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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 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>
|
||||
Reference in New Issue
Block a user