'use strict'; import {h, html, render, useEffect, useRef, useSignal} from './bundle.js'; const DefaultTopic = 'mg_mqtt_dashboard'; const DefaultUrl = location.protocol == 'https:' ? 'wss://broker.hivemq.com:8884/mqtt' : 'ws://broker.hivemq.com:8000/mqtt'; // const Delay = (ms, val) => new Promise(resolve => setTimeout(resolve, ms, val)); // const handleFetchError = r => r.ok || alert(`Error: ${r.statusText}`); const LabelClass = 'text-sm truncate font-medium my-auto whitespace-nowrap'; const BadgeClass = 'flex-inline text-sm rounded-md rounded px-2 py-0.5 ring-1 ring-inset'; const InputClass = 'font-normal text-sm rounded w-full flex-1 py-0.5 px-2 text-gray-700 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 rounded border'; const TitleClass = 'font-semibold'; const Colors = { green: 'bg-green-100 text-green-900 ring-green-300', yellow: 'bg-yellow-100 text-yellow-900 ring-yellow-300', info: 'bg-zinc-100 text-zinc-900 ring-zinc-300', red: 'bg-red-100 text-red-900 ring-red-300', }; let MqttClient; const Help = () => html`
This is a simple demonstration of the functional device dashboard that manages a fleet of devices via an MQTT server. Source code is available on GitHub.
For the sake of simplicity, this dashboard does not implement authentication. No external storage is used either to keep device list: for that, retained MQTT messages are utilised. When a device goes online, it publishes its state to the {root_topic}/{device_id}/status topic - LED status and firmware version.
The last will message triggers the "offline" message to the same topic. This is how this web page is able to track online/offline status of devices. diagram
See developer console for the list of MQTT messages exchanged with the MQTT server. `; export function Button({title, onclick, disabled, extraClass, ref, colors}) { const sigSpin = useSignal(false); const cb = function(ev) { const res = onclick ? onclick() : null; if (res && typeof (res.catch) === 'function') { sigSpin.value = true; res.catch(() => false).then(() => sigSpin.value = false); } }; if (!colors) colors = 'bg-blue-600 hover:bg-blue-500 disabled:bg-blue-400'; return html` `; }; function Header({sigTopic, sigUrl, connected}) { const forbiddenChars = ['$', '*', '+', '#', '/']; const onClick = () => { const isValidTopic = val => !forbiddenChars.some(char => val.includes(char)); if (isValidTopic(topic)) { localStorage.setItem('topic', topic) sigTopic.value = topic; window.location.reload(); } else { setSaveResult('Error: The topic cannot contain these characters: ' + forbiddenChars); } }; return html`

IoT Fleet Management Dashboard

MQTT Server sigUrl.value = ev.target.value} class=${InputClass} />
Root Topic sigTopic.value = ev.target.value} class=${InputClass} /> <${Button} extraClass="w-32" onclick=${onClick} title=${connected ? 'Disconnect' : 'Connect'} /> `; }; function Sidebar({devices, onclick}) { const Td = props => html` ${props.text}`; const Device = ({d}) => html`
onclick(d.id)}> ${d.id} ${d.online ? 'online' : 'offline'} `; return html`
Device List ${(devices ? devices : []).map(d => h(Device, {d}))} `; }; export function File({accept, fn, ...rest}) { const btn = useRef(null); const input = useRef(null); const oncancel = function(ev) { input.resolve(); input.resolve = null; }; const onchange = function(ev) { if (!ev.target.files[0]) { input.resolve(); input.resolve = null; return; } const f = ev.target.files[0]; const reader = new FileReader(); reader.readAsArrayBuffer(f); reader.onload = function() { fn && fn(reader.result, f.type).then(() => { input.resolve(); }).finally(() => { input.resolve = null; }); }; btn.current && btn.current.base.click(); ev.target.value = ''; ev.preventDefault(); }; const onclick = function() { if (!input.fn) input.current.click(); return new Promise(resolve => { input.resolve = resolve; }); }; return html` <${Button} onclick=${onclick} ref=${btn} ...${rest} /> `; }; function arrayBufferToBase64Async(buffer) { return new Promise((resolve, reject) => { const blob = new Blob([buffer]); const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result; // reader.result is like "data:application/octet-stream;base64,AAAA..." resolve(dataUrl.split(",", 2)[1]); }; reader.onerror = reject; reader.readAsDataURL(blob); }); } function FirmwareUpdatePanel({device, rpc, connected}) { const fn = function (fileData) { // Split file in chunks let chunkSize = 4096, offset = 0; const chunks = Array.from({ length: Math.ceil(fileData.byteLength / chunkSize) }, (_, i) => fileData.slice(i * chunkSize, i * chunkSize + chunkSize) ); function next(resolve, reject) { const chunk = chunks.shift(); if (chunks.length == 0) resolve(); return arrayBufferToBase64Async(chunk) .then(encoded => rpc('ota.upload', { offset: offset, total: fileData.byteLength, chunk: encoded, })) .then(response => { if (response.result == 'ok') { next(resolve, reject); offset += chunkSize; } else { reject(); } }); }; return new Promise((resolve, reject) => next(resolve, reject)); }; return html`
Firmware Update
Current firmware version
${device.firmware_version || '??'}
Upload new firmware <${File} title="..." disabled=${!device.online || !connected} fn=${fn} /> `; }; function LedControlPanel({device, rpc, connected}) { const ontoggle = () => rpc('state.set', {led_status: !device.led_status}); return html`
LED Control Panel
Toggle LED <${Toggle} onclick=${ontoggle} disabled=${!device.online || !connected} value=${device.led_status} /> `; }; function DeviceDashboard({device, rpc, connected}) { // To delete device, set an empty retained message const onforget = function(ev) { MqttClient.publish(device.topic, '', {retain: true}); location.reload(); }; if (!device) { return html`
No device selected. Click on a device on a sidebar `; } return html`
Device ${device.id} <${Button} title="Forget this device" onclick=${onforget}/>
<${LedControlPanel} device=${device} rpc=${rpc} connected=${connected} /> <${FirmwareUpdatePanel} device=${device} rpc=${rpc} connected=${connected} /> `; } const App = function() { const sigDevices = useSignal([]); const sigCurrentDevID = useSignal(localStorage.getItem('currentDevID') || ''); const sigUrl = useSignal(localStorage.getItem('url') || DefaultUrl); const sigTopic = useSignal(localStorage.getItem('topic') || DefaultTopic); const sigError = useSignal(null); const sigLoading = useSignal(true); const sigConnected = useSignal(false); const responseHandlers = useRef({}); const getDeviceByID = (deviceID) => sigDevices.value.find(d => d.id === deviceID); function addResponseHandler(correlationId, handler) { responseHandlers[correlationId] = handler; } function removeResponseHandler(correlationId) { delete responseHandlers[correlationId]; } const onRefresh = () => window.location.reload(); const initConn = () => { MqttClient = mqtt.connect(sigUrl.value, {connectTimeout: 5000, reconnectPeriod: 0}); MqttClient.on('connect', () => { //console.log('Connected to the broker'); sigLoading.value = false; sigError.value = null; // Reset error state upon successful connection sigConnected.value = true; const statusTopic = sigTopic.value + '/+/status' const txTopic = sigTopic.value + '/+/tx' const subscribe = (topic) => { MqttClient.subscribe(topic, (err) => { if (err) { console.error('Error subscribing to topic:', err); setError('Error subscribing to topic'); } else { //console.log('Successfully subscribed to ', topic); } }); }; subscribe(statusTopic); subscribe(txTopic); }); MqttClient.on('message', (topic, message) => { console.log(`Received message from ${topic}: ${message.toString()}`); if (message.length == 0) return; let response; try { response = JSON.parse(message.toString()); } catch (err) { console.error(err); return; } if (topic.endsWith('/status')) { if (!response.params) { console.error('Invalid response'); return; } let device = Object.assign(response.params, {topic: topic, id: topic.split('/')[1]}); device.online = response.params.status === 'online'; const devices = sigDevices.value.filter(d => d.id !== device.id); devices.push(device); devices.sort((a, b) => a.online && !b.online ? -1 :!a.online && b.online ? 1 : a.id < b.id ? -1 : 1); sigDevices.value = devices; } else if (topic.endsWith('/tx')) { if (!response.id) { console.error('Invalid response'); return; } const handler = responseHandlers[response.id]; if (handler) { handler(response); removeResponseHandler(response.id); } } }); MqttClient.on('error', (err) => { console.error('Connection error:', err); sigError.value = 'Connection cannot be established.'; }); MqttClient.on('close', () => { if (!sigConnected.value) { console.error('Failed to connect to the broker.'); sigError.value = 'Connection cannot be established.'; sigLoading.value = false; } }); }; useEffect(() => initConn(), []); const handlePublish = (methodName, parameters, timeout = 5000) => { return new Promise((resolve, reject) => { const randomIdGenerator = function(length) { return Math.random().toString(36).substring(2, length + 2); }; const randomID = randomIdGenerator(40); const timeoutID = setTimeout(() => { removeResponseHandler(randomID); reject(new Error('Request timed out')); }, timeout); addResponseHandler(randomID, (messageData) => { clearTimeout(timeoutID); resolve(messageData); }); if (sigCurrentDevID.value) { const rxTopic = sigTopic.value + `/${sigCurrentDevID.value}/rx`; const rpcPayload = {method: methodName, id: randomID}; if (parameters) { rpcPayload.params = parameters; } console.log(`Sending message to ${rxTopic}: ${JSON.stringify(rpcPayload)}`); MqttClient.publish(rxTopic, JSON.stringify(rpcPayload)); } }); }; const onDeviceClick = (deviceID) => { const device = getDeviceByID(deviceID); if (device) { sigCurrentDevID.value = device.id; localStorage.setItem('currentDevID', device.id); } }; if (sigError.value) { return html`
<${Header} sigTopic=${sigTopic} sigUrl=${sigUrl} />

Connection Error

Unable to connect to the MQTT broker.

<${Button} title="Retry" onclick=${onRefresh} class="absolute top-4 right-4" />
`; } return html`
<${Header} sigTopic=${sigTopic} sigUrl=${sigUrl} connected=${sigConnected.value} />
<${Sidebar} devices=${sigDevices.value} onclick=${onDeviceClick} />
<${DeviceDashboard} device=${getDeviceByID(sigCurrentDevID.value)} connected=${sigConnected.value} rpc=${handlePublish} /> <${Help} /> `; }; window.onload = () => render(h(App), document.body);