Компоненты: - 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 адаптированы.
1331 lines
57 KiB
HTML
1331 lines
57 KiB
HTML
<!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)} <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>
|
||
<span style="color:#a0bcd8;font-size:13px;">— браузерный MQTT-клиент, ничего не нужно устанавливать</span><br>
|
||
<strong style="color:#fff;">1.</strong>
|
||
Host: <code style="background:#0a1e30;padding:2px 7px;border-radius:4px;color:#7dd3fc;font-size:13px;">iot.kube5s.ru</code>
|
||
Port: <code style="background:#0a1e30;padding:2px 7px;border-radius:4px;color:#7dd3fc;font-size:13px;">443</code>
|
||
Protocol: <code style="background:#0a1e30;padding:2px 7px;border-radius:4px;color:#7dd3fc;font-size:13px;">wss</code>
|
||
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> Username / Password — скопируйте выше по кнопкам.<br>
|
||
<strong style="color:#fff;">3.</strong> После коннекта — New Subscription на топик выше, затем Publish.<br>
|
||
<span style="color:#f59e0b;font-size:15px;">⚠️</span>
|
||
<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,'&').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>
|