Files
IoT/internal/api/ui/iot-console.html
Naeel 1a94241c62 feat: перенос IoT managed service из sless в отдельную репу
Компоненты:
- iot-operator: controller-manager (IoTDevice CRD) + REST API (порт 9090)
- mqtt-bridge: MQTT (EMQX) → Kafka bridge
- kafka-consumer: Kafka → Postgres pipeline

Модуль: gitea.services.ngcloud.ru/Nail/IoT
Все 3 бинарника собираются, import paths адаптированы.
2026-04-12 14:29:43 +03:00

1331 lines
57 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<!-- Создано: 2026-04-04
Изменено: 2026-04-04 (design: Nubes brand, #001C34 palette, logo)
IoT Консоль — SPA для управления IoT устройствами.
Стек: ванильный HTML/CSS/JS + mqtt.js (CDN).
Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
Встраивается в оператор через go:embed, раздаётся по GET /console.
Доступна по: https://iot.kube5s.ru/console -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nubes IoT Console</title>
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style>
/* ——— Nubes brand palette ———
#001C34 — Nubes navy (brand primary, navbar, logo)
#001120 — page background (deeper than brand)
#001929 — card surface
#0b2d50 — borders, dividers
#1a7fd4 — action accent (bright blue on navy)
#e2ecf6 — text primary
#6b8eaa — text secondary
#2d5070 — text muted / disabled */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #001120; color: #e2ecf6; min-height: 100vh;
}
/* ——— Navbar ——— */
.navbar {
background: #001C34;
border-bottom: 1px solid #0b2d50;
padding: 0 28px; height: 58px;
display: flex; align-items: center; gap: 14px;
}
/* Логотип Nubes — SVG с инвертированием для белого цвета на тёмном фоне */
.navbar-logo {
display: flex; align-items: center; gap: 10px;
cursor: pointer; user-select: none; text-decoration: none;
}
.navbar-logo img {
height: 18px;
/* SVG залит #001C34, инвертируем до белого */
filter: brightness(0) invert(1);
}
.navbar-logo-sep {
width: 1px; height: 18px; background: #1a4a73; margin: 0 4px;
}
.navbar-logo-product {
font-size: 13px; font-weight: 600; color: #7aafd4;
letter-spacing: 0.04em; text-transform: uppercase;
}
.navbar-spacer { flex: 1; }
/* ——— Layout ——— */
.container { max-width: 940px; margin: 0 auto; padding: 32px 24px; }
/* ——— Card ——— */
.card {
background: #001929;
border-radius: 8px;
border: 1px solid #0b2d50;
padding: 24px; margin-bottom: 16px;
}
.card-title {
font-size: 15px; font-weight: 600;
color: #b8d4ec;
margin-bottom: 16px;
letter-spacing: 0.01em;
}
/* ——— Form ——— */
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 12px; font-weight: 500; color: #6b8eaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
input, textarea, select {
width: 100%;
background: #001120;
border: 1px solid #0b2d50;
border-radius: 5px; padding: 10px 12px;
color: #e2ecf6;
font-size: 14px; font-family: inherit;
transition: border-color 0.15s;
}
input:focus, textarea:focus { outline: none; border-color: #1a7fd4; box-shadow: 0 0 0 2px rgba(26,127,212,0.15); }
textarea { resize: vertical; font-family: 'Courier New', monospace; line-height: 1.5; }
input[type="number"] { width: auto; }
/* ——— Buttons ——— */
.btn {
border: none; border-radius: 5px; padding: 10px 18px; font-size: 13px;
font-weight: 600; cursor: pointer; transition: opacity 0.15s, background 0.15s;
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
letter-spacing: 0.02em;
}
.btn:hover:not(:disabled) { opacity: 0.85; }
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-primary { background: #1a7fd4; color: #fff; }
.btn-danger { background: #c0392b; color: #fff; }
.btn-success { background: #1a9e5c; color: #fff; }
.btn-warning { background: #d48f0a; color: #fff; }
.btn-ghost { background: transparent; color: #6b8eaa; border: 1px solid #0b2d50; }
.btn-ghost:hover:not(:disabled) { color: #b8d4ec; border-color: #1a4a73; opacity: 1; }
.btn-sm { padding: 5px 11px; font-size: 12px; }
/* ——— Table ——— */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; font-size: 11px; font-weight: 600; color: #2d5070;
text-transform: uppercase; letter-spacing: 0.07em;
padding: 0 14px 10px; border-bottom: 1px solid #0b2d50;
}
td { padding: 13px 14px; border-bottom: 1px solid rgba(11,45,80,0.6); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(26,127,212,0.04); }
/* ——— Badges ——— */
.badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 600; border-radius: 4px;
padding: 3px 9px; letter-spacing: 0.04em; text-transform: uppercase;
}
.badge::before { content: '●'; font-size: 6px; }
.badge-active { background: rgba(26,158,92,0.15); color: #2ecc8a; }
.badge-active::before { color: #2ecc8a; }
.badge-disabled { background: rgba(45,80,112,0.3); color: #2d5070; }
.badge-disabled::before { color: #2d5070; }
.badge-error { background: rgba(192,57,43,0.15); color: #e55042; }
.badge-error::before { color: #e55042; }
.badge-pending { background: rgba(212,143,10,0.15); color: #d4a017; }
.badge-pending::before { color: #d4a017; }
/* ——— Tabs ——— */
.tabs {
display: flex; border-bottom: 1px solid #0b2d50; margin-bottom: 24px;
}
.tab {
padding: 11px 20px; font-size: 13px; font-weight: 600;
cursor: pointer; color: #2d5070;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
user-select: none; letter-spacing: 0.03em;
}
.tab:hover { color: #7aafd4; }
.tab.active { color: #1a7fd4; border-bottom-color: #1a7fd4; }
/* ——— Credentials ——— */
.cred-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.cred-label { font-size: 13px; font-weight: 600; color: #7eb8e0; min-width: 110px; }
.cred-value {
font-family: 'Courier New', monospace; font-size: 14px;
background: #0a1e30; padding: 9px 14px; border-radius: 6px;
border: 1px solid #1a4a6e; flex: 1; color: #e2f0ff;
overflow-x: auto; word-break: break-all;
}
/* ——— Connection pill ——— */
.conn-pill {
display: inline-flex; align-items: center; gap: 7px;
font-size: 12px; font-weight: 600; padding: 4px 13px;
border-radius: 4px; letter-spacing: 0.04em; text-transform: uppercase;
}
.conn-pill::before { content: '●'; font-size: 6px; }
.conn-pill.off { background: rgba(45,80,112,0.25); color: #2d5070; }
.conn-pill.off::before { color: #2d5070; }
.conn-pill.ing { background: rgba(212,143,10,0.15); color: #d4a017; }
.conn-pill.ing::before { color: #d4a017; animation: blink 1s step-start infinite; }
.conn-pill.on { background: rgba(26,158,92,0.15); color: #2ecc8a; }
.conn-pill.on::before { color: #2ecc8a; }
@keyframes blink { 50% { opacity: 0; } }
/* ——— MQTT log ——— */
.mqtt-log {
background: #000d18; border: 1px solid #0b2d50; border-radius: 5px;
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: #2ecc8a; }
.mqtt-log .err { color: #e55042; }
.mqtt-log .info { color: #2d5070; }
/* ——— Modal ——— */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,8,16,0.82);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.modal {
background: #001929; border: 1px solid #0b2d50; border-radius: 8px;
padding: 28px; width: 100%; max-width: 460px;
}
.modal-title { font-size: 15px; font-weight: 700; margin-bottom: 20px; color: #b8d4ec; }
/* ——— Alerts ——— */
.alert { padding: 11px 15px; border-radius: 5px; font-size: 13px; margin-bottom: 14px; }
.alert-error { background: rgba(192,57,43,0.1); color: #e55042; border: 1px solid rgba(192,57,43,0.25); }
.alert-success { background: rgba(26,158,92,0.1); color: #2ecc8a; border: 1px solid rgba(26,158,92,0.25); }
.alert-info { background: rgba(26,127,212,0.1); color: #5aaae8; border: 1px solid rgba(26,127,212,0.25); }
/* ——— 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 #0b2d50; margin: 20px 0; }
.hint { font-size: 14px; color: #a0bcd8; line-height: 1.7; }
.mono { font-family: 'Courier New', monospace; }
/* ——— Device list empty state ——— */
.coming-soon { text-align: center; padding: 56px 24px; color: #2d5070; }
.coming-soon .icon { font-size: 36px; margin-bottom: 12px; opacity: 0.5; }
.coming-soon h3 { font-size: 17px; font-weight: 600; margin-bottom: 8px; color: #1a4a73; }
a { color: #1a7fd4; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ——— Help block ——— */
.help-block {
border: 1px solid #0b2d50; border-radius: 8px;
padding: 16px 20px; background: #001929;
}
.help-block summary::-webkit-details-marker { display: none; }
.help-block[open] summary .help-arrow { transform: rotate(90deg); }
.help-arrow { display: inline-block; transition: transform 0.15s; margin-right: 6px; font-size: 10px; }
.help-step { display: flex; gap: 14px; align-items: flex-start; font-size: 13px; color: #6b8eaa; line-height: 1.6; }
.help-num {
flex-shrink: 0; width: 22px; height: 22px; border-radius: 4px;
background: #001C34; border: 1px solid #1a4a73;
color: #1a7fd4; font-weight: 700; font-size: 11px;
display: flex; align-items: center; justify-content: center; margin-top: 2px;
}
.help-step strong { color: #b8d4ec; }
.help-step em { color: #1a7fd4; font-style: normal; }
</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 вычисляется из токена автоматически (SHA256(sub) → "sless-{hex}"), не вводится вручную
ns: localStorage.getItem('iot_ns') || '',
apiBase: localStorage.getItem('iot_api_base') || 'https://sless.kube5s.ru',
mqttBase: localStorage.getItem('iot_mqtt_base')|| 'wss://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,
autoTopic: '', // топик сохраняется при старте авто — не зависит от DOM
// Телеметрия
telemetryRows: [], // загруженные строки [{id, device_id, ts, payload}]
telemetryLoading: false,
telemetryAutoRefresh: false,
telemetryRefreshTimer: null,
telemetryDeviceFilter: '', // '' = все устройства
// Отображаемое имя пользователя: email из JWT или plain token
displayName: localStorage.getItem('iot_display_name') || '',
};
// Сохраняем 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);
localStorage.setItem('iot_display_name', S.displayName);
}
// displayNameFromToken — возвращает человекочитаемое имя для отображения в navbar.
// JWT: берём email (предпочтительно) или sub. Plain token: сама строка.
function displayNameFromToken(token) {
const parts = token.split('.');
if (parts.length === 3) {
try {
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4) payload += '=';
const claims = JSON.parse(atob(payload));
return claims.email || claims.sub || token;
} catch(e) {
return token;
}
}
// Plain token — показываем как есть
return token;
}
// namespaceFromToken — вычисляет k8s namespace из токена.
// Алгоритм ИДЕНТИЧЕН Terraform провайдеру (client.NamespaceFromSub):
// SHA256(sub) → первые 8 байт → "sless-{16 hex символов}"
//
// Поддерживает два режима (соответствует authTestMode на сервере):
// - Режим JWT (прод): токен вида xxx.yyy.zzz → извлекаем sub из payload → SHA256(sub)
// - Тестовый режим: любая строка без пробелов → используем её саму как sub → SHA256(token)
//
// Чтобы вернуться только к JWT: убрать ветку isPlainToken ниже.
async function namespaceFromToken(token) {
let sub;
const parts = token.split('.');
if (parts.length === 3) {
// ── JWT путь (боевой режим) ──────────────────────────────────────────────
// base64url → base64 (заменяем символы, добавляем padding)
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4) payload += '=';
let claims;
try { claims = JSON.parse(atob(payload)); }
catch(e) { throw new Error('Не удалось декодировать токен: ' + e.message); }
if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается');
sub = claims.sub;
} else {
// ── Тестовый режим: plain token ─────────────────────────────────────────
// Строка без пробелов используется напрямую как идентификатор (sub).
// Сервер (authTestMode=true) тоже принимает такой токен без JWT-проверки.
// ВАЖНО: каждая уникальная строка → отдельный изолированный namespace.
if (/\s/.test(token)) throw new Error('Токен не должен содержать пробелы');
sub = token;
}
// SHA256(sub) через Web Crypto API (встроен во все современные браузеры)
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sub));
// Берём первые 8 байт → 16 hex символов — идентично Go: hash[:8]
const hex = Array.from(new Uint8Array(buf).slice(0, 8))
.map(b => b.toString(16).padStart(2, '0')).join('');
return 'sless-' + hex;
}
// =============================================================
// API
// =============================================================
// sanitizeApiError — убирает служебные детали из сообщений об ошибках API.
// Пользователь не должен видеть namespace, k8s-пути, internal имена объектов.
function sanitizeApiError(msg) {
if (!msg) return 'Внутренняя ошибка. Попробуйте ещё раз.';
// Скрываем k8s-сообщения с namespace/not found
if (/namespaces?.*not found/i.test(msg)) return 'Ошибка инициализации. Выйдите и войдите снова.';
if (/not found/i.test(msg) && /sless-/i.test(msg)) return 'Ресурс не найден.';
if (/already exists/i.test(msg)) return 'Устройство с таким именем уже существует.';
if (/device.*id.*already/i.test(msg) || /device_id.*exists/i.test(msg)) return 'Device ID уже занят.';
if (/cannot decode jwt/i.test(msg) || /not a jwt/i.test(msg)) return 'Неверный токен.';
// HTTP 5xx — общая фраза
if (/HTTP 5/i.test(msg)) return 'Ошибка сервера. Попробуйте ещё раз.';
return msg;
}
// 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:32px;">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg"
alt="Nubes" style="height:24px;filter:brightness(0) invert(1);margin-bottom:16px;">
<div style="font-size:11px;font-weight:700;color:#1a7fd4;letter-spacing:0.18em;text-transform:uppercase;margin-bottom:6px;">IoT Console</div>
<div style="font-size:12px;color:#2d5070;">Управление устройствами и телеметрией</div>
</div>
<div id="login-err"></div>
<div class="form-group">
<label>Bearer токен <span style="color:#e08020;font-size:10px;font-weight:600;">· тестовый режим</span></label>
<input id="f-tok" type="text" value="${h(S.token)}" placeholder="любая-уникальная-строка или eyJ...JWT">
<div class="hint" style="margin-top:6px;">
<b>Тест:</b> введите любую строку без пробелов — например <code>alice</code> или <code>my-device-test</code>.<br>
Каждая строка → отдельный изолированный namespace.<br>
Реальный JWT nubes тоже принимается без изменений.
</div>
</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 — вычислить namespace из токена, проверить доступность API, перейти к устройствам.
// Пользователь НИКОГДА не вводит namespace — он однозначно определяется из токена.
async function doLogin() {
const tok = (document.getElementById('f-tok').value || '').trim();
if (!tok) { loginErr('Укажите токен'); return; }
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Проверка...';
// Вычисляем namespace из токена — SHA256(sub) → "sless-{hex}"
let ns;
try {
ns = await namespaceFromToken(tok);
} catch(e) {
loginErr('Ошибка токена: ' + e.message);
btn.disabled = false;
btn.textContent = 'Войти';
return;
}
S.apiBase = 'https://sless.kube5s.ru';
S.mqttBase = 'wss://iot.kube5s.ru/mqtt';
S.ns = ns;
S.token = tok;
S.displayName = displayNameFromToken(tok);
try {
// Создаём namespace если не существует — идемпотентный вызов.
// Terraform-провайдер делает это в Configure(), консоль — при первом логине.
// Без этого шага создание устройства упадёт с "namespace not found".
await apiCall('POST', `/v1/namespaces/${S.ns}/ensure`, {});
await apiListDevices(); // проверяем что токен валиден и API доступен
saveSettings();
nav('devices');
} catch(e) {
S.token = '';
S.ns = '';
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 {
// EnsureNamespace — идемпотентный вызов.
// Страховка на случай если сессия была сохранена до автосоздания namespace при логине.
await apiCall('POST', `/v1/namespaces/${S.ns}/ensure`, {});
await apiCreateDevice(name, did);
closeModal();
await renderDevices();
} catch(e) {
btn.disabled = false; btn.textContent = 'Создать';
// Скрываем служебные детали (namespace, k8s-пути) от пользователя
const msg = sanitizeApiError(e.message);
document.getElementById('m-err').innerHTML = `<div class="alert alert-error">${h(msg)}</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>
<details class="help-block" style="margin-top:24px;">
<summary style="cursor:pointer;font-size:13px;font-weight:600;color:#64748b;user-select:none;list-style:none;display:flex;align-items:center;gap:6px;">
<span class="help-arrow">▶</span> Как это работает — краткая инструкция
</summary>
<div style="margin-top:14px;display:grid;gap:12px;">
<div class="help-step">
<div class="help-num">1</div>
<div>
<strong>Credentials — параметры подключения</strong><br>
Вкладка <em>Credentials</em> содержит: Broker URL, Username, Password, Топик.
Каждый параметр копируется отдельной кнопкой. Broker URL всегда на порту <strong>443</strong>.
</div>
</div>
<div class="help-step">
<div class="help-num">2</div>
<div>
<strong>Публикация данных — формат сообщения</strong><br>
Устройство должно публиковать JSON в указанный топик. Структура произвольная:<br>
<code class="mono" style="display:inline-block;margin-top:6px;padding:6px 10px;background:#0f172a;border-radius:6px;font-size:12px;">
{"temp": 22.5, "humidity": 60, "status": "ok"}
</code>
</div>
</div>
<div class="help-step">
<div class="help-num">3</div>
<div>
<strong>Эмулятор — проверить без железа</strong><br>
Вкладка <em>Эмулятор</em> работает прямо из браузера как настоящее устройство.
Нажмите <strong>«Подключиться»</strong>, отредактируйте JSON, нажмите <strong>«Отправить»</strong>.
Зелёная запись в логе = брокер принял сообщение.
</div>
</div>
<div class="help-step">
<div class="help-num">4</div>
<div>
<strong>Автоотправка — имитировать поток данных</strong><br>
В Эмуляторе можно включить <strong>«Авто»</strong> — сообщения будут уходить каждые N секунд.
Удобно для проверки что данные стабильно принимаются.
</div>
</div>
<div class="help-step">
<div class="help-num">5</div>
<div>
<strong>Телеметрия — история данных (в разработке)</strong><br>
Вкладка <em>Телеметрия</em> — скоро здесь будет таблица с историей всех сообщений
от этого устройства с фильтрами по времени.
</div>
</div>
</div>
</details>`;
}
// switchTab — переключить вкладку без перезагрузки данных устройства.
// Авто-отправка (autoTimer) НЕ останавливается — она фоновый процесс.
// Останавливается только при явном "Стоп" или уходе со страницы устройства (nav()).
function switchTab(tab) {
// Останавливаем авто-рефреш телеметрии только когда УХОДИМ с вкладки телеметрии
if (S.activeTab === 'telemetry' && tab !== 'telemetry') {
clearInterval(S.telemetryRefreshTimer);
S.telemetryRefreshTimer = null;
S.telemetryAutoRefresh = false;
}
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);
}
// При переходе на телеметрию — сразу загружаем данные
if (tab === 'telemetry') {
loadTelemetry();
}
}
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>
<button class="btn btn-ghost btn-sm" onclick="copyPw(this)">Копировать</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)} &nbsp;<span class="hint">(порт 443)</span></div>
<button class="btn btn-ghost btn-sm" onclick="copyVal('${h(S.mqttBase)}',this)">Копировать</button>
</div>
<hr class="separator">
<!-- Инструкция тестирования через MQTTX Web (браузер, без установки) -->
<div style="font-size:15px;font-weight:700;color:#c8dff0;margin-bottom:12px;">Проверить подключение</div>
<div style="font-size:14px;color:#c8dff0;line-height:2;">
<a href="https://mqttx.app/web-client" target="_blank" rel="noopener"
style="display:inline-block;background:#1a4a6e;color:#7dd3fc;font-weight:700;font-size:14px;padding:6px 14px;border-radius:6px;border:1px solid #4db3ff;text-decoration:none;">
🔗 Открыть MQTTX Web ↗
</a>
&nbsp;<span style="color:#a0bcd8;font-size:13px;">— браузерный MQTT-клиент, ничего не нужно устанавливать</span><br>
<strong style="color:#fff;">1.</strong>&nbsp;
Host: <code style="background:#0a1e30;padding:2px 7px;border-radius:4px;color:#7dd3fc;font-size:13px;">iot.kube5s.ru</code>&nbsp;
Port: <code style="background:#0a1e30;padding:2px 7px;border-radius:4px;color:#7dd3fc;font-size:13px;">443</code>&nbsp;
Protocol: <code style="background:#0a1e30;padding:2px 7px;border-radius:4px;color:#7dd3fc;font-size:13px;">wss</code>&nbsp;
Path: <code style="background:#0a1e30;padding:2px 7px;border-radius:4px;color:#7dd3fc;font-size:13px;">/mqtt</code><br>
<strong style="color:#fff;">2.</strong>&nbsp; Username / Password — скопируйте выше по кнопкам.<br>
<strong style="color:#fff;">3.</strong>&nbsp; После коннекта — New Subscription на топик выше, затем Publish.<br>
<span style="color:#f59e0b;font-size:15px;">⚠️</span>&nbsp;
<span style="color:#fcd34d;">Если коннект не проходит — убедитесь что порт <strong>443</strong>, а не 8084/1883.</span>
</div>
</div>`;
}
// copyPw — копировать пароль из S.deviceDetail без показа на экране
function copyPw(btn) {
if (!S.deviceDetail) return;
copyVal(S.deviceDetail.mqtt_password, btn);
}
// 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 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>
<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>
<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());
}
// 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');
if (S.autoTimer) {
mqttStopAuto();
if (btn) btn.textContent = 'Запустить';
return;
}
const sec = parseInt((document.getElementById('auto-sec') || {}).value) || 5;
// Топик читаем из DOM один раз при старте и сохраняем в S.autoTopic —
// чтобы авто-отправка продолжалась при переходе на вкладку Телеметрии
S.autoTopic = (document.getElementById('emu-topic') || {}).value || '';
if (!S.autoTopic) return;
if (btn) btn.textContent = 'Стоп';
// getPayload генерирует случайные данные каждый тик — не зависит от DOM
mqttStartAuto(S.autoTopic, generateSensorPayload, sec);
}
// =============================================================
// ВКЛАДКА — ТЕЛЕМЕТРИЯ
// =============================================================
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="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
// =============================================================
function navbar() {
return `
<nav class="navbar">
<div class="navbar-logo" onclick="nav('devices')">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<div class="navbar-logo-sep"></div>
<span class="navbar-logo-product">IoT Console</span>
</div>
<div class="navbar-spacer"></div>
${S.displayName ? `<span style="font-size:13px;color:#7eb8e0;margin-right:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${h(S.displayName)}">${h(S.displayName)}</span>` : ''}
<button class="btn btn-ghost btn-sm" onclick="doLogout()">Выйти</button>
</nav>`;
}
function doLogout() {
mqttStopAuto();
mqttCleanup();
S.token = '';
S.displayName = '';
localStorage.removeItem('iot_token');
localStorage.removeItem('iot_display_name');
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>