'use strict'; import {Component, h, html, render, useEffect, useState, useRef} from './preact.min.js'; const MaxMetricsDataPoints = 50; // This simple publish/subscribe is used to pass notifications that were // received from the server, to all child components of the app. var PubSub = (function() { var handlers = {}, id = 0; return { subscribe: function(fn) { handlers[id++] = fn; }, unsubscribe: function(id) { delete handlers[id]; }, publish: function(data) { for (var k in handlers) handlers[k](data); } }; })(); const Nav = props => html`
Your Product
Logged in as: ${props.user} logout
`; const Hero = props => html`

Interactive Device Dashboard

This device dashboard is developed using the modern and compact Preact framework, in order to fit on very small devices. This is a hybrid server which provides both static and dynamic content. Static files, like CSS/JS/HTML or images, are compiled into the server binary. This UI uses the REST API implemented by the device, which you can examine using curl command-line utility:

curl -u admin:pass0 localhost:8000/api/config/get
curl -u admin:pass0 localhost:8000/api/config/set -d 'pub=mg/topic'
curl -u admin:pass0 localhost:8000/api/message/send -d 'message=hello'

The device can send notifications to this dashboard at anytime. Notifications are sent over WebSocket at URI /api/watch as JSON strings: {"name": "..", "data": ...}

Try wscat --auth user1:pass1 --connect ws://localhost:8000/api/watch

`; const Login = function(props) { const [user, setUser] = useState(''); const [pass, setPass] = useState(''); const login = ev => fetch( '/api/login', {headers: {Authorization: 'Basic ' + btoa(user + ':' + pass)}}) .then(r => r.json()) .then(r => r && props.login(r)) .catch(err => err); return html`

Device Dashboard Login

setUser(ev.target.value)} value=${user} />
setPass(ev.target.value)} value=${pass} onchange=${login} />
Valid logins: admin:pass0, user1:pass1, user2:pass2
`; }; const Configuration = function(props) { const [url, setUrl] = useState(props.config.url || ''); const [pub, setPub] = useState(props.config.pub || ''); const [sub, setSub] = useState(props.config.sub || ''); useEffect(() => { setUrl(props.config.url); setPub(props.config.pub); setSub(props.config.sub); }, [props.config]); const update = (name, val) => fetch('/api/config/set', { method: 'post', body: `${name}=${encodeURIComponent(val)}` }).catch(err => err); const updateurl = ev => update('url', url); const updatepub = ev => update('pub', pub); const updatesub = ev => update('sub', sub); // console.log(props, [url, pub, sub]); return html`

Device Configuration

MQTT server: setUrl(ev.target.value)} />
Subscribe topic: setSub(ev.target.value)} />
Publish topic: setPub(ev.target.value)} />
You can use HiveMQ Websocket web client to send messages to this console.
The device keeps a persistent connection to the configured MQTT server. Changes to this configuration are propagated to all dashboards: try changing them in this dashboard and observe changes in other opened dashboards.
Note: administrators can see this section and can change device configuration, whilst users cannot.
`; }; const Message = m => html`
qos: ${m.message.qos} topic: ${m.message.topic} data: ${m.message.data}
`; const Messages = function(props) { const [messages, setMessages] = useState([]); const [txt, setTxt] = useState(''); useEffect(() => { const id = PubSub.subscribe(function(msg) { if (msg.name == 'message') setMessages(x => x.concat([msg.data])); }); return PubSub.unsubscribe(id); }, []); const sendmessage = ev => fetch('/api/message/send', { method: 'post', body: `message=${encodeURIComponent(txt)}` }).then(r => setTxt('')); const connstatus = props.config.connected ? 'connected' : 'disconnected'; return html`

MQTT messages

MQTT server status: ${connstatus}
${messages.map(message => h(Message, {message}))}
Publish message: setTxt(ev.target.value)} />
The message gets passed to the device via REST. Then the device sends it to the MQTT server over MQTT. All MQTT messages on a subscribed topic received by the device, are propagated to this dashboard via /api/watch.
`; }; // Expected arguments: // data: timeseries, e.g. [ [1654361352, 19], [1654361353, 18], ... ] // width, height, yticks, xticks, ymin, ymax, xmin, xmax const SVG = function(props) { // w // +---------------------+ // | h1 | // | +-----------+ | // | | | | h // | w1 | | w2 | // | +-----------+ | // | h2 | // +---------------------+ // let w = props.width, h = props.height, w1 = 30, w2 = 0, h1 = 8, h2 = 18; let yticks = props.yticks || 4, xticks = props.xticks || 5; let data = props.data || []; let ymin = props.ymin || 0; let ymax = props.ymax || Math.max.apply(null, data.map(p => p[1])); let xmin = props.xmin || Math.min.apply(null, data.map(p => p[0])); let xmax = props.xmax || Math.max.apply(null, data.map(p => p[0])); // Y-axis tick lines and labels let yta = (new Array(yticks + 1)).fill(0).map((_, i) => i); // indices let yti = i => h - h2 - (h - h1 - h2) * i / yticks; // index's Y let ytv = i => (ymax - ymin) * i / yticks; let ytl = y => html``; let ytt = (y, v) => html`${v}`; // X-axis tick lines and labels let datefmt = unix => (new Date(unix * 1000)).toISOString().substr(14, 5); let xta = (new Array(xticks + 1)).fill(0).map((_, i) => i); // indices let xti = i => w1 + (w - w1 - w2) * i / xticks; // index's X let xtv = i => datefmt(xmin + (xmax - xmin) * i / xticks); let xtl = x => html``; let xtt = (x, v) => html`${v}`; // Transform data points array into coordinate let dx = v => w1 + (v - xmin) / ((xmax - xmin) || 1) * (w - w1 - w2); let dy = v => h - h2 - (v - ymin) / ((ymax - ymin) || 1) * (h - h1 - h2); let dd = data.map(p => [Math.round(dx(p[0])), Math.round(dy(p[1]))]); let ddl = dd.length; // And plot the data as element let begin0 = ddl ? `M ${dd[0][0]},${dd[0][1]}` : `M 0,0`; let begin = `M ${w1},${h - h2}`; // Initial point let end = ddl ? `L ${dd[ddl - 1][0]},${h - h2}` : `L ${w1},${h - h2}`; let series = ddl ? dd.map(p => `L ${p[0]} ${p[1]}`) : []; return html` ${yta.map(i => ytl(yti(i)))} ${yta.map(i => ytt(yti(i), ytv(i)))} ${xta.map(i => xtl(xti(i)))} ${data.length ? xta.map(i => xtt(xti(i), xtv(i))) : ''} `; }; const Chart = function(props) { const [data, setData] = useState([]); useEffect(() => { const id = PubSub.subscribe(function(msg) { if (msg.name != 'metrics') return; setData(x => x.concat([msg.data]).splice(-MaxMetricsDataPoints)); }); return PubSub.unsubscribe(id); }, []); let xmax = 0, missing = MaxMetricsDataPoints - data.length; if (missing > 0) xmax = Math.round(Date.now() / 1000) + missing; return html`

Data Chart

<${SVG} height=240 width=600 ymin=0 ymax=20 xmax=${xmax} data=${data} />
This chart plots live sensor data, sent by the device via /api/watch.
`; }; const App = function(props) { const [user, setUser] = useState(''); const [config, setConfig] = useState({}); const getconfig = () => fetch('/api/config/get', {headers: {Authorization: ''}}) .then(r => r.json()) .then(r => setConfig(r)) .catch(err => console.log(err)); // Watch for notifications. As soon as a notification arrives, pass it on // to all subscribed components const watch = function() { var l = window.location, proto = l.protocol.replace('http', 'ws'); var tid, wsURI = proto + '//' + l.host + '/api/watch' var reconnect = function() { var ws = new WebSocket(wsURI); // ws.onopen = () => console.log('ws connected'); ws.onmessage = function(ev) { try { var msg = JSON.parse(ev.data); PubSub.publish(msg); // if (msg.name != 'metrics') console.log('ws->', msg); } catch (e) { console.log('Invalid ws frame:', ev.data); // eslint-disable-line } }; ws.onclose = function() { clearTimeout(tid); tid = setTimeout(reconnect, 1000); console.log('ws disconnected'); }; }; reconnect(); }; const login = function(u) { document.cookie = `access_token=${u.token}; Secure, HttpOnly; SameSite=Lax; path=/; max-age=3600`; setUser(u.user); watch(); return getconfig(); }; const logout = ev => { document.cookie = `access_token=; Secure, HttpOnly; SameSite=Lax; path=/; max-age=0`; setUser(''); }; useEffect(() => { // Called once at init time PubSub.subscribe(msg => msg.name == 'config' && getconfig()); fetch('/api/login', {headers: {Authorization: ''}}) .then(r => r.json()) .then(r => login(r)) .catch(err => setUser('')); }, []); if (!user) return html`<${Login} login=${login} />`; return html` <${Nav} user=${user} logout=${logout} />
<${Hero} />
<${Chart} />
${user == 'admin' && h(Configuration, {config})}
<${Messages} config=${config} />
`; }; window.onload = () => render(h(App), document.body);