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