@@ -0,0 +1,972 @@
<!DOCTYPE html>
<!-- Создано: 2026 - 04 - 04
IoT Консоль — SPA для управления IoT устройствами.
Стек: ванильный HTML/CSS/JS + mqtt.js (CDN).
Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
Встраивается в оператор через go:embed, раздаётся по GET /console.
Доступна по: http://iot.kube5s.ru/console -->
< html lang = "ru" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > IoT Консоль< / title >
< style >
* , * :: before , * :: after { box-sizing : border-box ; margin : 0 ; padding : 0 ; }
body {
font-family : - apple-system , BlinkMacSystemFont , 'Segoe UI' , system-ui , sans-serif ;
background : #0f172a ; color : #e2e8f0 ; min-height : 100 vh ;
}
/* Navbar */
. navbar {
background : #1e293b ; border-bottom : 1 px solid #334155 ;
padding : 0 24 px ; height : 56 px ;
display : flex ; align-items : center ; gap : 12 px ;
}
. navbar-brand {
font-weight : 800 ; font-size : 17 px ; color : #3b82f6 ; cursor : pointer ;
display : flex ; align-items : center ; gap : 6 px ; user-select : none ;
}
. navbar-ns {
background : #334155 ; border-radius : 4 px ; padding : 3 px 10 px ;
font-size : 12 px ; color : #94a3b8 ; font-family : monospace ;
}
. navbar-spacer { flex : 1 ; }
/* Layout */
. container { max-width : 920 px ; margin : 0 auto ; padding : 32 px 24 px ; }
/* Card */
. card {
background : #1e293b ; border-radius : 10 px ; border : 1 px solid #334155 ;
padding : 24 px ; margin-bottom : 16 px ;
}
. card-title { font-size : 16 px ; font-weight : 600 ; margin-bottom : 16 px ; }
/* Form */
. form-group { margin-bottom : 16 px ; }
label { display : block ; font-size : 13 px ; color : #94a3b8 ; margin-bottom : 6 px ; }
input , textarea , select {
width : 100 % ; background : #0f172a ; border : 1 px solid #334155 ;
border-radius : 6 px ; padding : 10 px 12 px ; color : #e2e8f0 ;
font-size : 14 px ; 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 : 6 px ; padding : 10 px 18 px ; font-size : 14 px ;
font-weight : 500 ; cursor : pointer ; transition : opacity 0.15 s ; display : inline-flex ;
align-items : center ; gap : 6 px ; 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 : 1 px solid #334155 ; }
. btn-sm { padding : 5 px 12 px ; font-size : 12 px ; }
/* Table */
table { width : 100 % ; border-collapse : collapse ; }
th {
text-align : left ; font-size : 11 px ; color : #64748b ;
text-transform : uppercase ; letter-spacing : 0.06 em ;
padding : 0 14 px 10 px ; border-bottom : 1 px solid #334155 ;
}
td { padding : 13 px 14 px ; border-bottom : 1 px 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 : 5 px ;
font-size : 12 px ; font-weight : 500 ; border-radius : 9999 px ; padding : 2 px 10 px ;
}
. badge :: before { content : '●' ; font-size : 7 px ; }
. 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 : 1 px solid #334155 ; margin-bottom : 24 px ;
}
. tab {
padding : 12 px 20 px ; font-size : 14 px ; font-weight : 500 ; cursor : pointer ;
color : #64748b ; border-bottom : 2 px solid transparent ; transition : all 0.15 s ;
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 : 10 px ; margin-bottom : 12 px ; }
. cred-label { font-size : 12 px ; color : #64748b ; min-width : 130 px ; }
. cred-value {
font-family : 'Courier New' , monospace ; font-size : 13 px ;
background : #0a0f1a ; padding : 8 px 12 px ; border-radius : 4 px ;
border : 1 px 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 : 7 px ;
font-size : 13 px ; font-weight : 500 ; padding : 5 px 14 px ; border-radius : 9999 px ;
}
. conn-pill :: before { content : '●' ; font-size : 7 px ; }
. 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 1 s 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 : 1 px solid #334155 ; border-radius : 6 px ;
padding : 10 px 12 px ; max-height : 180 px ; overflow-y : auto ;
font-family : 'Courier New' , monospace ; font-size : 12 px ;
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 : 1 px solid #334155 ; border-radius : 12 px ;
padding : 28 px ; width : 100 % ; max-width : 460 px ;
}
. modal-title { font-size : 16 px ; font-weight : 600 ; margin-bottom : 20 px ; }
/* Alerts */
. alert { padding : 11 px 15 px ; border-radius : 6 px ; font-size : 13 px ; margin-bottom : 14 px ; }
. alert-error { background : rgba ( 239 , 68 , 68 , 0.12 ) ; color : #f87171 ; border : 1 px solid rgba ( 239 , 68 , 68 , 0.2 ) ; }
. alert-success { background : rgba ( 34 , 197 , 94 , 0.12 ) ; color : #4ade80 ; border : 1 px solid rgba ( 34 , 197 , 94 , 0.2 ) ; }
. alert-info { background : rgba ( 59 , 130 , 246 , 0.12 ) ; color : #60a5fa ; border : 1 px solid rgba ( 59 , 130 , 246 , 0.2 ) ; }
/* Misc */
. row { display : flex ; gap : 10 px ; align-items : center ; flex-wrap : wrap ; }
. flex1 { flex : 1 ; }
. mt8 { margin-top : 8 px ; }
. mt16 { margin-top : 16 px ; }
. mt24 { margin-top : 24 px ; }
. separator { border : none ; border-top : 1 px solid #334155 ; margin : 20 px 0 ; }
. hint { font-size : 12 px ; color : #64748b ; line-height : 1.6 ; }
. mono { font-family : 'Courier New' , monospace ; }
. coming-soon {
text-align : center ; padding : 56 px 24 px ; color : #475569 ;
}
. coming-soon . icon { font-size : 40 px ; margin-bottom : 12 px ; }
. coming-soon h3 { font-size : 18 px ; font-weight : 600 ; margin-bottom : 8 px ; 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 \n MQTT 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 } \n username: ${ user } \n password: ${ pass } \n topic: ${ 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 >