Files
sless/internal/api/ui/iot-console.html
T
Naeel fb6f9d48cd feat(tls): HTTPS + wss:// для iot.kube5s.ru
- emqx-ws-ingress.yaml: TLS секция + cert-manager letsencrypt-prod, ssl-redirect=true
- router.go: CORS Allow-Origin: http → https://iot.kube5s.ru
- iot-console.html: дефолт MQTT брокера ws:// → wss://
- operator.yaml: v0.1.53 → v0.1.54
- crypto.subtle теперь работает (HTTPS страница)
2026-04-04 19:56:31 +03:00

1009 lines
39 KiB
HTML
Raw 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
IoT Консоль — SPA для управления IoT устройствами.
Стек: ванильный HTML/CSS/JS + mqtt.js (CDN).
Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
Встраивается в оператор через go:embed, раздаётся по GET /console.
Доступна по: http://iot.kube5s.ru/console -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IoT Консоль</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0f172a; color: #e2e8f0; min-height: 100vh;
}
/* Navbar */
.navbar {
background: #1e293b; border-bottom: 1px solid #334155;
padding: 0 24px; height: 56px;
display: flex; align-items: center; gap: 12px;
}
.navbar-brand {
font-weight: 800; font-size: 17px; color: #3b82f6; cursor: pointer;
display: flex; align-items: center; gap: 6px; user-select: none;
}
.navbar-ns {
background: #334155; border-radius: 4px; padding: 3px 10px;
font-size: 12px; color: #94a3b8; font-family: monospace;
}
.navbar-spacer { flex: 1; }
/* Layout */
.container { max-width: 920px; margin: 0 auto; padding: 32px 24px; }
/* Card */
.card {
background: #1e293b; border-radius: 10px; border: 1px solid #334155;
padding: 24px; margin-bottom: 16px;
}
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
/* Form */
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 13px; color: #94a3b8; margin-bottom: 6px; }
input, textarea, select {
width: 100%; background: #0f172a; border: 1px solid #334155;
border-radius: 6px; padding: 10px 12px; color: #e2e8f0;
font-size: 14px; font-family: inherit;
}
input:focus, textarea:focus { outline: none; border-color: #3b82f6; }
textarea { resize: vertical; font-family: 'Courier New', monospace; line-height: 1.5; }
input[type="number"] { width: auto; }
/* Buttons */
.btn {
border: none; border-radius: 6px; padding: 10px 18px; font-size: 14px;
font-weight: 500; cursor: pointer; transition: opacity 0.15s; display: inline-flex;
align-items: center; gap: 6px; white-space: nowrap;
}
.btn:hover:not(:disabled) { opacity: 0.82; }
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-success { background: #22c55e; color: #0f172a; font-weight: 600; }
.btn-warning { background: #f59e0b; color: #0f172a; font-weight: 600; }
.btn-ghost { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
/* Table */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; font-size: 11px; color: #64748b;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 0 14px 10px; border-bottom: 1px solid #334155;
}
td { padding: 13px 14px; border-bottom: 1px solid rgba(51,65,85,0.5); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(255,255,255,0.02); }
/* Badges */
.badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 500; border-radius: 9999px; padding: 2px 10px;
}
.badge::before { content: '●'; font-size: 7px; }
.badge-active { background: rgba(34,197,94,0.12); color: #22c55e; }
.badge-active::before { color: #22c55e; }
.badge-disabled { background: rgba(100,116,139,0.12); color: #64748b; }
.badge-disabled::before { color: #64748b; }
.badge-error { background: rgba(239,68,68,0.12); color: #ef4444; }
.badge-error::before { color: #ef4444; }
.badge-pending { background: rgba(251,191,36,0.12); color: #fbbf24; }
.badge-pending::before { color: #fbbf24; }
/* Tabs */
.tabs {
display: flex; border-bottom: 1px solid #334155; margin-bottom: 24px;
}
.tab {
padding: 12px 20px; font-size: 14px; font-weight: 500; cursor: pointer;
color: #64748b; border-bottom: 2px solid transparent; transition: all 0.15s;
user-select: none;
}
.tab:hover { color: #cbd5e1; }
.tab.active { color: #3b82f6; border-bottom-color: #3b82f6; }
/* Credentials rows */
.cred-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.cred-label { font-size: 12px; color: #64748b; min-width: 130px; }
.cred-value {
font-family: 'Courier New', monospace; font-size: 13px;
background: #0a0f1a; padding: 8px 12px; border-radius: 4px;
border: 1px solid #334155; flex: 1; color: #cbd5e1;
overflow-x: auto; word-break: break-all;
}
/* Connection status pill */
.conn-pill {
display: inline-flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 500; padding: 5px 14px; border-radius: 9999px;
}
.conn-pill::before { content: '●'; font-size: 7px; }
.conn-pill.off { background: rgba(100,116,139,0.12); color: #64748b; }
.conn-pill.off::before { color: #64748b; }
.conn-pill.ing { background: rgba(251,191,36,0.12); color: #fbbf24; }
.conn-pill.ing::before { color: #fbbf24; animation: blink 1s step-start infinite; }
.conn-pill.on { background: rgba(34,197,94,0.12); color: #22c55e; }
.conn-pill.on::before { color: #22c55e; }
@keyframes blink { 50% { opacity: 0; } }
/* MQTT log */
.mqtt-log {
background: #080d18; border: 1px solid #334155; border-radius: 6px;
padding: 10px 12px; max-height: 180px; overflow-y: auto;
font-family: 'Courier New', monospace; font-size: 12px;
line-height: 1.6;
}
.mqtt-log .ok { color: #22c55e; }
.mqtt-log .err { color: #ef4444; }
.mqtt-log .info { color: #64748b; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.modal {
background: #1e293b; border: 1px solid #334155; border-radius: 12px;
padding: 28px; width: 100%; max-width: 460px;
}
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 20px; }
/* Alerts */
.alert { padding: 11px 15px; border-radius: 6px; font-size: 13px; margin-bottom: 14px; }
.alert-error { background: rgba(239,68,68,0.12); color: #f87171; border: 1px solid rgba(239,68,68,0.2); }
.alert-success { background: rgba(34,197,94,0.12); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
.alert-info { background: rgba(59,130,246,0.12); color: #60a5fa; border: 1px solid rgba(59,130,246,0.2); }
/* Misc */
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.flex1 { flex: 1; }
.mt8 { margin-top: 8px; }
.mt16 { margin-top: 16px; }
.mt24 { margin-top: 24px; }
.separator { border: none; border-top: 1px solid #334155; margin: 20px 0; }
.hint { font-size: 12px; color: #64748b; line-height: 1.6; }
.mono { font-family: 'Courier New', monospace; }
.coming-soon {
text-align: center; padding: 56px 24px; color: #475569;
}
.coming-soon .icon { font-size: 40px; margin-bottom: 12px; }
.coming-soon h3 { font-size: 18px; font-weight: 600; margin-bottom: 8px; color: #64748b; }
a { color: #3b82f6; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div id="app"></div>
<!-- mqtt.js CDN — подключение к EMQX через WebSocket напрямую из браузера -->
<script src="https://unpkg.com/mqtt@5.3.5/dist/mqtt.min.js"></script>
<script>
// =============================================================
// STATE — всё состояние приложения в одном объекте
// =============================================================
const S = {
// Сохраняемые настройки
token: localStorage.getItem('iot_token') || '',
// ns вычисляется из токена автоматически (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,
};
// Сохраняем 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);
}
// namespaceFromToken — вычисляет k8s namespace из JWT токена.
// Алгоритм ИДЕНТИЧЕН Terraform провайдеру (client.NamespaceFromSub):
// JWT payload → sub claim → SHA256(sub) → первые 8 байт → "sless-{16 hex символов}"
// Пользователь НИКОГДА не вводит namespace вручную — это гарантирует изоляцию.
async function namespaceFromToken(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Неверный формат токена (не 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 — токен не поддерживается');
// SHA256(sub) через Web Crypto API (встроен в все современные браузеры)
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(claims.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
// =============================================================
// apiCall — универсальная обёртка для REST запросов к оператору
async function apiCall(method, path, body) {
const opts = {
method,
headers: {
'Authorization': 'Bearer ' + S.token,
'Content-Type': 'application/json',
},
};
if (body !== undefined) opts.body = JSON.stringify(body);
let resp;
try {
resp = await fetch(S.apiBase + path, opts);
} catch (e) {
throw new Error('Сеть недоступна: ' + e.message);
}
if (resp.status === 204) return null;
let data;
try { data = await resp.json(); } catch(e) { data = {}; }
if (!resp.ok) throw new Error(data.error || 'HTTP ' + resp.status);
return data;
}
// Загрузить список устройств (без паролей)
function apiListDevices() {
return apiCall('GET', `/v1/namespaces/${S.ns}/iot/devices`);
}
// Получить одно устройство с паролем
function apiGetDevice(name) {
return apiCall('GET', `/v1/namespaces/${S.ns}/iot/devices/${name}`);
}
// Создать устройство
function apiCreateDevice(name, deviceId) {
return apiCall('POST', `/v1/namespaces/${S.ns}/iot/devices`, {
name, device_id: deviceId, enabled: true,
});
}
// Удалить устройство
function apiDeleteDevice(name) {
return apiCall('DELETE', `/v1/namespaces/${S.ns}/iot/devices/${name}`);
}
// =============================================================
// MQTT ЭМУЛЯТОР
// =============================================================
// mqttConnect — подключить браузер к EMQX как устройство
function mqttConnect(username, password) {
mqttCleanup();
S.mqttConnecting = true;
S.mqttConnected = false;
S.mqttLog = [];
const clientId = 'console-emu-' + Math.random().toString(16).slice(2, 10);
S.mqttClient = mqtt.connect(S.mqttBase, {
username,
password,
clientId,
clean: true,
connectTimeout: 12000,
reconnectPeriod: 0, // не переподключаться автоматически
});
S.mqttClient.on('connect', () => {
S.mqttConnecting = false;
S.mqttConnected = true;
mqttLogEntry('ok', 'Подключён к ' + S.mqttBase);
emulatorPartialUpdate();
});
S.mqttClient.on('error', (err) => {
S.mqttConnecting = false;
S.mqttConnected = false;
mqttLogEntry('err', 'Ошибка: ' + (err.message || String(err)));
emulatorPartialUpdate();
});
S.mqttClient.on('close', () => {
if (S.mqttConnected) mqttLogEntry('info', 'Соединение закрыто');
S.mqttConnected = false;
S.mqttConnecting = false;
mqttStopAuto();
emulatorPartialUpdate();
});
mqttLogEntry('info', 'Подключение к ' + S.mqttBase + '...');
emulatorPartialUpdate();
}
// mqttPublish — опубликовать JSON сообщение в топик
function mqttPublish(topic, payloadStr) {
if (!S.mqttClient || !S.mqttConnected) {
mqttLogEntry('err', 'Не подключён');
emulatorRefreshLog();
return;
}
// Проверяем что payload — валидный JSON
try { JSON.parse(payloadStr); } catch(e) {
mqttLogEntry('err', 'Неверный JSON: ' + e.message);
emulatorRefreshLog();
return;
}
S.mqttClient.publish(topic, payloadStr, { qos: 1 }, (err) => {
if (err) {
mqttLogEntry('err', 'Отправка не удалась: ' + err.message);
} else {
mqttLogEntry('ok', new Date().toLocaleTimeString('ru') + ' → ' + topic);
}
emulatorRefreshLog();
});
}
// mqttDisconnect — отключить клиент
function mqttDisconnect() {
mqttStopAuto();
mqttCleanup();
emulatorPartialUpdate();
}
// mqttCleanup — принудительно разорвать соединение
function mqttCleanup() {
if (S.mqttClient) {
try { S.mqttClient.end(true); } catch(_) {}
S.mqttClient = null;
}
S.mqttConnected = false;
S.mqttConnecting = false;
}
// mqttStartAuto — запустить авто-отправку каждые N секунд
function mqttStartAuto(topic, getPayload, intervalSec) {
mqttStopAuto();
S.autoTimer = setInterval(() => mqttPublish(topic, getPayload()), intervalSec * 1000);
}
// mqttStopAuto — остановить авто-отправку
function mqttStopAuto() {
if (S.autoTimer) { clearInterval(S.autoTimer); S.autoTimer = null; }
}
// mqttLogEntry — добавить запись в лог эмулятора (FIFO max 60)
function mqttLogEntry(type, msg) {
S.mqttLog.push({ type, msg });
if (S.mqttLog.length > 60) S.mqttLog.shift();
}
// =============================================================
// PARTIAL DOM UPDATES — обновление без полного перерисовки
// =============================================================
// Обновить только UI статуса подключения и кнопки без полного ре-рендера
function emulatorPartialUpdate() {
const statusEl = document.getElementById('emu-status');
const btnsEl = document.getElementById('emu-btns');
const sendBox = document.getElementById('emu-send-box');
if (statusEl) statusEl.innerHTML = renderConnPill();
if (btnsEl) btnsEl.innerHTML = renderConnBtns();
if (sendBox) sendBox.style.display = S.mqttConnected ? '' : 'none';
emulatorRefreshLog();
}
// Перерисовать только лог
function emulatorRefreshLog() {
const el = document.getElementById('mqtt-log');
if (!el) return;
el.innerHTML = S.mqttLog
.map(l => `<div class="${l.type}">${h(l.msg)}</div>`)
.join('');
el.scrollTop = el.scrollHeight;
}
// =============================================================
// НАВИГАЦИЯ
// =============================================================
function nav(page, param) {
S.currentPage = page;
S.currentDevice = param || null;
if (page !== 'device') {
// Отключаем MQTT и авто при уходе со страницы устройства
mqttStopAuto();
mqttCleanup();
S.activeTab = 'creds';
}
render();
}
// =============================================================
// РЕНДЕР — ГЛАВНЫЙ ДИСПЕТЧЕР
// =============================================================
function render() {
if (!S.token || !S.ns) {
renderLogin();
return;
}
if (S.currentPage === 'device' && S.currentDevice) {
renderDevicePage(S.currentDevice);
return;
}
renderDevices();
}
// =============================================================
// СТРАНИЦА — ВХОД
// =============================================================
function renderLogin() {
app().innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px;">
<div class="card" style="width:100%;max-width:420px;">
<div style="text-align:center;margin-bottom:28px;">
<div style="font-size:32px;margin-bottom:8px;">⚡</div>
<div style="font-size:22px;font-weight:800;color:#3b82f6;">IoT Консоль</div>
<div style="font-size:13px;color:#64748b;margin-top:4px;">Управление устройствами и телеметрией</div>
</div>
<div id="login-err"></div>
<div class="form-group">
<label>API (адрес оператора)</label>
<input id="f-api" value="${h(S.apiBase)}" placeholder="https://sless.kube5s.ru">
</div>
<div class="form-group">
<label>MQTT Broker WebSocket</label>
<input id="f-mqtt" value="${h(S.mqttBase)}" placeholder="wss://iot.kube5s.ru/mqtt">
</div>
<div class="form-group">
<label>Bearer токен</label>
<input id="f-tok" type="password" value="${h(S.token)}" placeholder="eyJ...">
<div class="hint" style="margin-top:6px;">Данные вашего аккаунта определяются автоматически</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 api = (document.getElementById('f-api').value || '').trim().replace(/\/$/, '');
const mqtt = (document.getElementById('f-mqtt').value || '').trim();
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 = api || 'https://sless.kube5s.ru';
S.mqttBase = mqtt || 'wss://iot.kube5s.ru/mqtt';
S.ns = ns;
S.token = tok;
try {
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 {
await apiCreateDevice(name, did);
closeModal();
await renderDevices();
} catch(e) {
btn.disabled = false; btn.textContent = 'Создать';
document.getElementById('m-err').innerHTML = `<div class="alert alert-error">${h(e.message)}</div>`;
}
}
async function confirmDel(name) {
if (!confirm(`Удалить устройство "${name}"?\n\nMQTT credentials будут удалены — устройство потеряет доступ.`)) return;
try {
await apiDeleteDevice(name);
await renderDevices();
} catch(e) {
alert('Ошибка: ' + e.message);
}
}
function closeModal() { document.getElementById('modal-root').innerHTML = ''; }
function closeOverlay(e) { if (e.target === e.currentTarget) closeModal(); }
// =============================================================
// СТРАНИЦА — УСТРОЙСТВО
// =============================================================
async function renderDevicePage(name) {
app().innerHTML = `
${navbar()}
<div class="container">
<div style="margin-bottom:20px;">
<a href="#" onclick="nav('devices');return false;" style="font-size:14px;color:#64748b;">
← Все устройства
</a>
</div>
<div id="device-area">
<div style="text-align:center;padding:48px;color:#64748b;">Загрузка...</div>
</div>
</div>`;
try {
S.deviceDetail = await apiGetDevice(name);
document.getElementById('device-area').innerHTML = deviceLayout(S.deviceDetail);
} catch(e) {
document.getElementById('device-area').innerHTML =
`<div class="alert alert-error">Ошибка: ${h(e.message)}</div>`;
}
}
// deviceLayout — заголовок + вкладки + контент вкладки
function deviceLayout(d) {
return `
<div class="row" style="margin-bottom:20px;align-items:flex-start;">
<div>
<h1 style="font-size:22px;font-weight:700;">${h(d.name)}</h1>
<div style="margin-top:6px;font-size:13px;color:#64748b;">
device_id: <span class="mono">${h(d.device_id)}</span>
</div>
</div>
<div style="margin-left:12px;">${phaseBadge(d.phase)}</div>
</div>
<div class="tabs">
<div class="tab ${S.activeTab==='creds' ? 'active':''}" onclick="switchTab('creds')" >Credentials</div>
<div class="tab ${S.activeTab==='emulator' ? 'active':''}" onclick="switchTab('emulator')">Эмулятор</div>
<div class="tab ${S.activeTab==='telemetry'? 'active':''}" onclick="switchTab('telemetry')">Телеметрия</div>
</div>
<div id="tab-body">${tabContent(d, S.activeTab)}</div>`;
}
// switchTab — переключить вкладку без перезагрузки данных устройства
function switchTab(tab) {
if (tab !== 'emulator') {
// Останавливаем авто-отправку при уходе с вкладки эмулятора
mqttStopAuto();
}
S.activeTab = tab;
// Обновляем active-класс на вкладках
document.querySelectorAll('.tab').forEach((el, i) => {
el.classList.toggle('active', ['creds','emulator','telemetry'][i] === tab);
});
const body = document.getElementById('tab-body');
if (body && S.deviceDetail) {
body.innerHTML = tabContent(S.deviceDetail, tab);
}
}
function tabContent(d, tab) {
if (tab === 'creds') return credsTab(d);
if (tab === 'emulator') return emulatorTab(d);
if (tab === 'telemetry') return telemetryTab();
return '';
}
// =============================================================
// ВКЛАДКА — CREDENTIALS
// =============================================================
function credsTab(d) {
if (!d.mqtt_username || !d.mqtt_password) {
return `<div class="card">
<div class="alert alert-info" style="margin-bottom:0;">
Credentials ещё не готовы — устройство в статусе <strong>${h(d.phase || 'Pending')}</strong>.
<a href="#" onclick="renderDevicePage('${h(d.name)}');return false;">Обновить</a>
</div>
</div>`;
}
const topic = (d.topic_prefix || '') + 'telemetry/' + d.device_id;
return `<div class="card">
<div class="card-title">MQTT Credentials</div>
<div class="cred-row">
<div class="cred-label">Username</div>
<div class="cred-value">${h(d.mqtt_username)}</div>
<button class="btn btn-ghost btn-sm" onclick="copyVal('${h(d.mqtt_username)}',this)">Копировать</button>
</div>
<div class="cred-row">
<div class="cred-label">Password</div>
<div class="cred-value" id="pw-display">••••••••••••••••</div>
<button class="btn btn-ghost btn-sm" id="pw-toggle-btn" onclick="togglePw()">Показать</button>
</div>
<div class="cred-row">
<div class="cred-label">Топик</div>
<div class="cred-value">${h(topic)}</div>
<button class="btn btn-ghost btn-sm" onclick="copyVal('${h(topic)}',this)">Копировать</button>
</div>
<div class="cred-row">
<div class="cred-label">Broker URL</div>
<div class="cred-value">${h(S.mqttBase)}</div>
</div>
<hr class="separator">
<div style="font-size:13px;font-weight:600;color:#94a3b8;margin-bottom:10px;">
Подключение физического устройства
</div>
<div class="hint">
<p>1. Запишите параметры в прошивку устройства:</p>
<div class="cred-value" style="margin:8px 0;padding:12px;line-height:1.8;">
broker &nbsp;&nbsp;&nbsp;${h(S.mqttBase)}<br>
username &nbsp;${h(d.mqtt_username)}<br>
password &nbsp;(см. выше)<br>
topic &nbsp;&nbsp;&nbsp;&nbsp;${h(topic)}
</div>
<p>2. Устройство должно публиковать JSON в топик.</p>
<p>3. Чтобы проверить отправку — перейдите на вкладку <strong>Эмулятор</strong>.</p>
</div>
<div class="mt16">
<button class="btn btn-ghost btn-sm" onclick="copyAll('${h(d.mqtt_username)}','${h(topic)}',this)">
📋 Скопировать всё
</button>
</div>
</div>`;
}
// togglePw — показать/скрыть пароль (читаем из S.deviceDetail, не из атрибутов)
function togglePw() {
const el = document.getElementById('pw-display');
const btn = document.getElementById('pw-toggle-btn');
if (!el || !btn || !S.deviceDetail) return;
const revealed = el.dataset.revealed === '1';
if (revealed) {
el.textContent = '••••••••••••••••';
el.dataset.revealed = '0';
btn.textContent = 'Показать';
} else {
el.textContent = S.deviceDetail.mqtt_password;
el.dataset.revealed = '1';
btn.textContent = 'Скрыть';
}
}
function copyAll(user, topic, btn) {
const pass = S.deviceDetail ? S.deviceDetail.mqtt_password : '';
copyVal(`broker: ${S.mqttBase}\nusername: ${user}\npassword: ${pass}\ntopic: ${topic}`, btn);
}
// =============================================================
// ВКЛАДКА — ЭМУЛЯТОР
// =============================================================
function renderConnPill() {
if (S.mqttConnecting) return `<div class="conn-pill ing">Подключение...</div>`;
if (S.mqttConnected) return `<div class="conn-pill on">Подключён</div>`;
return `<div class="conn-pill off">Не подключён</div>`;
}
function renderConnBtns() {
if (S.mqttConnected) return `<button class="btn btn-warning btn-sm" onclick="mqttDisconnect();emulatorPartialUpdate()">Отключиться</button>`;
if (S.mqttConnecting) return `<button class="btn btn-ghost btn-sm" disabled>...</button>`;
return `<button class="btn btn-success btn-sm" onclick="emuConnect()">Подключиться</button>`;
}
function emulatorTab(d) {
const topic = (d.topic_prefix || '') + 'telemetry/' + d.device_id;
const hasCreds = d.mqtt_username && d.mqtt_password;
if (!hasCreds) {
return `<div class="card">
<div class="alert alert-info" style="margin-bottom:0;">
Credentials не готовы. Перейдите на вкладку <strong>Credentials</strong> и убедитесь что устройство активно.
</div>
</div>`;
}
return `<div class="card">
<!-- Статус подключения и кнопки управления -->
<div class="row" style="margin-bottom:20px;">
<div id="emu-status">${renderConnPill()}</div>
<div id="emu-btns">${renderConnBtns()}</div>
</div>
<!-- Скрытые поля с данными устройства для JS -->
<input type="hidden" id="emu-topic" value="${h(topic)}">
<input type="hidden" id="emu-username" value="${h(d.mqtt_username)}">
<!-- Блок отправки — виден только когда подключены -->
<div id="emu-send-box" style="display:${S.mqttConnected ? '' : 'none'}">
<div class="form-group">
<label>Payload (JSON)</label>
<textarea id="emu-payload" rows="4">{"temp": 22.5, "humidity": 60}</textarea>
</div>
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick="emuSendOnce()">Отправить</button>
<div class="row" style="gap:8px;">
<span class="hint">Авто:</span>
<button class="btn btn-ghost btn-sm" id="auto-btn" onclick="toggleAuto()">
${S.autoTimer ? 'Стоп' : 'Запустить'}
</button>
<span class="hint">каждые</span>
<input type="number" id="auto-sec" value="5" min="1" max="300" style="width:64px;text-align:center;">
<span class="hint">сек</span>
</div>
</div>
</div>
<!-- Лог MQTT сообщений -->
<div class="hint" style="margin-bottom:6px;">Лог</div>
<div class="mqtt-log" id="mqtt-log">
${S.mqttLog.map(l => `<div class="${h(l.type)}">${h(l.msg)}</div>`).join('')}
</div>
</div>`;
}
// emuConnect — подключиться как текущее устройство
function emuConnect() {
if (!S.deviceDetail) return;
// Пароль берём из state, не из DOM
mqttConnect(S.deviceDetail.mqtt_username, S.deviceDetail.mqtt_password);
}
// emuSendOnce — разовая отправка
function emuSendOnce() {
const topic = (document.getElementById('emu-topic') || {}).value || '';
const payload = (document.getElementById('emu-payload') || {}).value || '{}';
mqttPublish(topic, payload.trim());
}
// toggleAuto — включить/выключить авто-отправку
function toggleAuto() {
const btn = document.getElementById('auto-btn');
if (S.autoTimer) {
mqttStopAuto();
if (btn) btn.textContent = 'Запустить';
return;
}
const sec = parseInt((document.getElementById('auto-sec') || {}).value) || 5;
const topic = (document.getElementById('emu-topic') || {}).value || '';
if (btn) btn.textContent = 'Стоп';
mqttStartAuto(topic, () => (document.getElementById('emu-payload') || {value:'{}'}).value.trim(), sec);
}
// =============================================================
// ВКЛАДКА — ТЕЛЕМЕТРИЯ
// =============================================================
function telemetryTab() {
return `<div class="card">
<div class="coming-soon">
<div class="icon">📊</div>
<h3>Телеметрия</h3>
<p class="hint" style="margin-top:8px;">Скоро — хранение данных и API чтения</p>
<p class="hint" style="margin-top:4px;color:#334155;">
В разработке — появится в следующем обновлении
</p>
</div>
</div>`;
}
// =============================================================
// NAVBAR
// =============================================================
function navbar() {
return `
<nav class="navbar">
<div class="navbar-brand" onclick="nav('devices')">⚡ IoT Консоль</div>
<div class="navbar-spacer"></div>
<button class="btn btn-ghost btn-sm" onclick="doLogout()">Выйти</button>
</nav>`;
}
function doLogout() {
mqttStopAuto();
mqttCleanup();
S.token = '';
localStorage.removeItem('iot_token');
nav('login');
}
// =============================================================
// HELPERS
// =============================================================
// h — экранирование HTML (XSS защита)
function h(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// app — ссылка на корневой DOM-элемент
function app() { return document.getElementById('app'); }
// phaseBadge — бейдж статуса устройства
function phaseBadge(phase) {
const map = { Active:'active', Disabled:'disabled', Error:'error' };
const cls = map[phase] || 'pending';
return `<span class="badge badge-${cls}">${h(phase || 'Pending')}</span>`;
}
// copyVal — скопировать текст в буфер обмена с визуальной обратной связью
function copyVal(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = '✓';
setTimeout(() => { btn.textContent = orig; }, 1500);
}).catch(() => alert('Скопируйте вручную:\n' + text));
}
// =============================================================
// СТАРТ
// =============================================================
render();
</script>
</body>
</html>