mirror of
https://github.com/cesanta/mongoose.git
synced 2026-11-01 13:10:43 +00:00
Refactor device-dashboard
This commit is contained in:
1 parent
380d6ac5c2
commit
49a9bb5a8b
60 files changed
+7752
-5228
No files matched your search
@@ -1,385 +1,252 @@
|
||||
'use strict';
|
||||
import {Component, h, html, render, useEffect, useState, useRef} from './preact.min.js';
|
||||
import { h, render, useState, useEffect, useRef, html, Router } from './bundle.js';
|
||||
import { Icons, Login, Setting, Button, Stat, tipColors, Colored, Notification } from './components.js';
|
||||
|
||||
var devaddr = "address:port";
|
||||
const Logo = props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12.87 12.85"><defs><style>.ll-cls-1{fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:0.5px;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="ll-cls-1" d="M12.62,1.82V8.91A1.58,1.58,0,0,1,11,10.48H4a1.44,1.44,0,0,1-1-.37A.69.69,0,0,1,2.84,10l-.1-.12a.81.81,0,0,1-.15-.48V5.57a.87.87,0,0,1,.86-.86H4.73V7.28a.86.86,0,0,0,.86.85H9.42a.85.85,0,0,0,.85-.85V3.45A.86.86,0,0,0,10.13,3,.76.76,0,0,0,10,2.84a.29.29,0,0,0-.12-.1,1.49,1.49,0,0,0-1-.37H2.39V1.82A1.57,1.57,0,0,1,4,.25H11A1.57,1.57,0,0,1,12.62,1.82Z"/><path class="ll-cls-1" d="M10.48,10.48V11A1.58,1.58,0,0,1,8.9,12.6H1.82A1.57,1.57,0,0,1,.25,11V3.94A1.57,1.57,0,0,1,1.82,2.37H8.9a1.49,1.49,0,0,1,1,.37l.12.1a.76.76,0,0,1,.11.14.86.86,0,0,1,.14.47V7.28a.85.85,0,0,1-.85.85H8.13V5.57a.86.86,0,0,0-.85-.86H3.45a.87.87,0,0,0-.86.86V9.4a.81.81,0,0,0,.15.48l.1.12a.69.69,0,0,0,.13.11,1.44,1.44,0,0,0,1,.37Z"/></g></g></svg>`;
|
||||
|
||||
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`
|
||||
<div style="background: #333; padding: 0.5em; color: #fff;">
|
||||
<div class="container d-flex">
|
||||
<div style="flex: 1 1 auto; display: flex; align-items: center;">
|
||||
<b>Your Product</b>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; flex: 0 0 auto; ">
|
||||
<span>Logged in as:</span>
|
||||
<span style="padding: 0 0.5em;"><img src="user.png" height="22" /></span>
|
||||
<span>${props.user}</span>
|
||||
<a class="btn" onclick=${props.logout}
|
||||
style="margin-left: 1em; font-size: 0.8em; background: #8aa;">logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
|
||||
const Hero = props => html`
|
||||
<div class="section">
|
||||
<div style="margin-top: 1em; background: #eee; padding: 1em; border-radius: 0.5em; color: #777; ">
|
||||
<h1 style="margin: 0.2em 0;">Interactive Device Dashboard</h1>
|
||||
|
||||
<p>
|
||||
This device dashboard is developed using the modern and compact Preact framework,
|
||||
in order to fit on very small devices. This is
|
||||
a <a href="https://mongoose.ws/tutorials/http-server/">hybrid server</a> 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 <code>curl</code> command-line utility:
|
||||
</p>
|
||||
|
||||
<div><code>curl -u admin:pass0 ${devaddr}/api/config/get</code> </div>
|
||||
<div><code>curl -u admin:pass0 ${devaddr}/api/config/set -d 'pub=mg/topic'</code> </div>
|
||||
<div><code>curl -u admin:pass0 ${devaddr}/api/message/send -d 'message=hello'</code> </div>
|
||||
|
||||
<p>
|
||||
The device can send notifications to this dashboard at anytime. Notifications
|
||||
are sent over WebSocket at URI <code>/api/watch</code> as JSON strings: <code>{"name": "..", "data": ...}</code>
|
||||
<div>Try <code>wscat --auth user1:pass1 --connect ws://${devaddr}/api/watch</code></div>
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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);
|
||||
function Header({logout, user, setShowSidebar, showSidebar}) {
|
||||
return html`
|
||||
<div class="rounded border" style="max-width: 480px; margin: 0 auto; margin-top: 5em; background: #eee; ">
|
||||
<div style="padding: 2em; ">
|
||||
<h1 style="color: #666;">Device Dashboard Login </h1>
|
||||
<div style="margin: 0.5em 0;">
|
||||
<input type='text' placeholder='Name' style="width: 100%;"
|
||||
oninput=${ev => setUser(ev.target.value)} value=${user} />
|
||||
</div>
|
||||
<div style="margin: 0.5em 0;">
|
||||
<input type="password" placeholder="Password" style="width: 100%;"
|
||||
oninput=${ev => setPass(ev.target.value)} value=${pass}
|
||||
onchange=${login} />
|
||||
</div>
|
||||
<div style="margin: 1em 0;">
|
||||
<button class="btn" style="width: 100%; background: #8aa;"
|
||||
disabled=${!user || !pass} onclick=${login}> Login </button>
|
||||
</div>
|
||||
<div style="color: #777; margin-top: 2em;">
|
||||
Valid logins: admin:pass0, user1:pass1, user2:pass2
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
<div class="bg-white sticky top-0 z-[48] xw-full border-b py-2 ${showSidebar && 'pl-72'} transition-all duration-300 transform">
|
||||
<div class="px-2 w-full py-0 my-0 flex items-center">
|
||||
<button type="button" onclick=${ev => setShowSidebar(v => !v)} class="text-slate-400">
|
||||
<${Icons.bars3} class="h-6" />
|
||||
<//>
|
||||
<div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<div class="relative flex flex-1"><//>
|
||||
<div class="flex items-center gap-x-4 lg:gap-x-6">
|
||||
<span class="text-sm text-slate-400">logged in as: ${user}<//>
|
||||
<div class="hidden lg:block lg:h-4 lg:w-px lg:bg-gray-200" aria-hidden="true"><//>
|
||||
<${Button} title="Logout" icon=${Icons.logout} onclick=${logout} />
|
||||
<//>
|
||||
<//>
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
|
||||
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`
|
||||
<div class="section">
|
||||
<h3 style="background: #c03434; color: #fff; padding: 0.4em;">
|
||||
Device Configuration</h3>
|
||||
<div style="margin: 0.5em 0; display: flex;">
|
||||
<span class="addon nowrap">MQTT server:</span>
|
||||
<input type="text" style="flex: 1 100%;"
|
||||
value=${url} onchange=${updateurl}
|
||||
oninput=${ev => setUrl(ev.target.value)} />
|
||||
<button class="btn" disabled=${!url} onclick=${updateurl}
|
||||
style="margin-left: 1em; background: #8aa;">Update</button>
|
||||
</div>
|
||||
<div style="margin: 0.5em 0; display: flex; ">
|
||||
<span class="addon nowrap">Subscribe topic:</span>
|
||||
<input type="text" style="flex: 1 100%;"
|
||||
value=${sub} onchange=${updatesub}
|
||||
oninput=${ev => setSub(ev.target.value)} />
|
||||
<button class="btn" disabled=${!sub} onclick=${updatesub}
|
||||
style="margin-left: 1em; background: #8aa;">Update</button>
|
||||
</div>
|
||||
<div style="margin: 0.5em 0; display: flex;">
|
||||
<span class="addon nowrap">Publish topic:</span>
|
||||
<input type="text" style="flex: 1 100%;"
|
||||
value=${pub} onchange=${updatepub}
|
||||
oninput=${ev => setPub(ev.target.value)} />
|
||||
<button class="btn" disabled=${!pub} onclick=${updatepub}
|
||||
style="margin-left: 1em; background: #8aa;">Update</button>
|
||||
</div>
|
||||
function Sidebar({url, show}) {
|
||||
const NavLink = ({title, icon, href, url}) => html`
|
||||
<div>
|
||||
You can use <a href="http://www.hivemq.com/demos/websocket-client/">
|
||||
HiveMQ Websocket web client</a> to send messages to this console.
|
||||
</div>
|
||||
<div class="msg">
|
||||
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.
|
||||
</div><div class="msg">
|
||||
Note: administrators can see this section and can change device
|
||||
configuration, whilst users cannot.
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
|
||||
const Message = m => html`<div style="margin: 0.5em 0;">
|
||||
<span class="qos">qos: ${m.message.qos} </span>
|
||||
<span class="topic">topic: ${m.message.topic} </span>
|
||||
<span class="data">data: ${m.message.data}</span>
|
||||
</div>`;
|
||||
|
||||
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 routing = "connected" in props.config;
|
||||
const connstatus = !routing ? 'This device has no MQTT functionality' : props.config.connected ? 'connected' : 'disconnected';
|
||||
<a href="#${href}" class="${href == url ? 'bg-slate-50 text-blue-600 group' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50 group'} flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
|
||||
<${icon} class="w-6 h-6"/>
|
||||
${title}
|
||||
<///>
|
||||
<//>`;
|
||||
return html`
|
||||
<div class="section">
|
||||
<h3 style="background: #30c040; color: #fff; padding: 0.4em;">MQTT messages</h3>
|
||||
<div>
|
||||
MQTT server status: <b>${connstatus}</b>
|
||||
</div>
|
||||
<div style="height: 10em; overflow: auto; padding: 0.5em; " class="border">
|
||||
${messages.map(message => h(Message, {message}))}
|
||||
</div>
|
||||
<div style="margin: 0.5em 0; display: flex">
|
||||
<span class="addon nowrap">Publish message:</span>
|
||||
<input placeholder="type and press enter..." style="flex: 1 100%;"
|
||||
value=${txt} onchange=${sendmessage} disabled=${!routing}
|
||||
oninput=${ev => setTxt(ev.target.value)} />
|
||||
</div>
|
||||
<div class="msg">
|
||||
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.
|
||||
</div>
|
||||
</div>`;
|
||||
<div class="bg-violet-100 hs-overlay hs-overlay-open:translate-x-0
|
||||
-translate-x-full transition-all duration-300 transform
|
||||
fixed top-0 left-0 bottom-0 z-[60] w-72 bg-white border-r
|
||||
border-gray-200 overflow-y-auto scrollbar-y
|
||||
${show && 'translate-x-0'} right-auto bottom-0">
|
||||
<div class="flex flex-col m-4 gap-y-6">
|
||||
<div class="flex h-10 shrink-0 items-center gap-x-4 font-bold text-xl text-slate-500">
|
||||
<${Logo} class="h-full"/> Your Brand
|
||||
<//>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<${NavLink} title="Dashboard" icon=${Icons.home} href="/" url=${url} />
|
||||
<${NavLink} title="Settings" icon=${Icons.settings} href="/settings" url=${url} />
|
||||
<//>
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
// 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]));
|
||||
function Events({}) {
|
||||
const [events, setEvents] = useState([]);
|
||||
const refresh = () => fetch('api/events/get').then(r => r.json()).then(r => setEvents(r)).catch(e => console.log(e));
|
||||
useEffect(refresh, []);
|
||||
|
||||
// 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`<line x1=${w1} y1=${y} x2=${w} y2=${y} class="tick"/>`;
|
||||
let ytt = (y, v) => html`<text x=0 y=${y + 5} class="label">${v}</text>`;
|
||||
|
||||
// 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`<path d="M ${x},${h1} L ${x},${h - h2}" class="tick"/>`;
|
||||
let xtt = (x, v) =>
|
||||
html`<text x=${x - 15} y=${h - 2} class="label">${v}</text>`;
|
||||
|
||||
// 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 <path> 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]}`) : [];
|
||||
const Th = props => html`<th scope="col" class="sticky top-0 z-10 border-b border-slate-300 bg-white bg-opacity-75 py-1.5 px-4 text-left text-sm font-semibold text-slate-900 backdrop-blur backdrop-filter">${props.title}</th>`;
|
||||
const Td = props => html`<td class="whitespace-nowrap border-b border-slate-200 py-2 px-4 pr-3 text-sm text-slate-900">${props.text}</td>`;
|
||||
const Prio = ({prio}) => {
|
||||
const text = ['high', 'medium', 'low'][prio];
|
||||
const colors = [tipColors.red, tipColors.yellow, tipColors.green][prio];
|
||||
return html`<${Colored} colors=${colors} text=${text} />`;
|
||||
};
|
||||
const Event = ({e}) => html`
|
||||
<tr>
|
||||
<${Td} text=${['power', 'hardware', 'tier3', 'tier4'][e.type]} />
|
||||
<${Td} text=${html`<${Prio} prio=${e.prio}/>`} />
|
||||
<${Td} text=${e.time || '1970-01-01'} />
|
||||
<${Td} text=${e.text} />
|
||||
<//>`;
|
||||
//console.log(events);
|
||||
|
||||
return html`
|
||||
<svg viewBox="0 0 ${w} ${h}">
|
||||
<style>
|
||||
.axis { stroke: #aaa; fill: none; }
|
||||
.label { stroke: #aaa; font-size: 13px; }
|
||||
.tick { stroke: #ccc; fill: none; stroke-dasharray: 5; }
|
||||
.seriesbg { stroke: none; fill: rgba(200,225,255, 0.25)}
|
||||
.series { stroke: #25a; fill: none; }
|
||||
</style>
|
||||
${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))) : ''}
|
||||
<path d="${begin} ${series.join(' ')} ${end}" class="seriesbg" />
|
||||
<path d="${begin0} ${series.join(' ')}" class="series" />
|
||||
</svg>`;
|
||||
<div class="my-4 h-64 divide-y divide-gray-200 rounded bg-white overflow-auto">
|
||||
<div class="font-light uppercase flex items-center text-slate-600 px-4 py-2">
|
||||
Event Log
|
||||
<//>
|
||||
<div class="">
|
||||
<table class="">
|
||||
<thead>
|
||||
<tr>
|
||||
<${Th} title="Type" />
|
||||
<${Th} title="Prio" />
|
||||
<${Th} title="Time" />
|
||||
<${Th} title="Description" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${events.map(e => h(Event, {e}))}
|
||||
</tbody>
|
||||
</table>
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
function Chart({data}) {
|
||||
const n = data.length /* entries */, w = 20 /* entry width */, ls = 15/* left space */;
|
||||
const h = 100 /* graph height */, yticks = 5 /* Y axis ticks */, bs = 10 /* bottom space */;
|
||||
const ymax = 25;
|
||||
const yt = i => (h - bs) / yticks * (i + 1);
|
||||
const bh = p => (h - bs) * p / 100; // Bar height
|
||||
const by = p => (h - bs) - bh(p);
|
||||
const range = (start, size, step) => Array.from({length: size}, (_, i) => i * (step || 1) + start);
|
||||
// console.log(ds);
|
||||
return html`
|
||||
<div class="section">
|
||||
<h3 style="background: #ec3; color: #fff; padding: 0.4em;">Data Chart</h3>
|
||||
<div style="overflow: auto; padding: 0.5em;" class="">
|
||||
<${SVG} height=240 width=600 ymin=0 ymax=20 xmax=${xmax} data=${data} />
|
||||
</div>
|
||||
<div class="msg">
|
||||
This chart plots live sensor data, sent by the device via /api/watch.
|
||||
</div>
|
||||
</div>`;
|
||||
<div class="my-4 divide-y divide-gray-200 overflow-auto rounded bg-white">
|
||||
<div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
|
||||
Temperature, last 24h
|
||||
<//>
|
||||
<div class="relative">
|
||||
<svg class="bg-yellow-x50 w-full p-4" viewBox="0 0 ${n*w+ls} ${h}">
|
||||
${range(0, yticks).map(i => html`
|
||||
<line x1=0 y1=${yt(i)} x2=${ls+n*w} y2=${yt(i)} stroke-width=0.3 class="stroke-slate-300" stroke-dasharray="1,1" />
|
||||
<text x=0 y=${yt(i)-2} class="text-[6px] fill-slate-400">${ymax-ymax/yticks*(i+1)}<//>
|
||||
`)}
|
||||
${range(0, n).map(x => html`
|
||||
<rect x=${ls+x*w} y=${by(data[x]*100/ymax)} width=12 height=${bh(data[x]*100/ymax)} rx=2 class="fill-cyan-500" />
|
||||
<text x=${ls+x*w} y=100 class="text-[6px] fill-slate-400">${x*2}:00<//>
|
||||
`)}
|
||||
<//>
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
const App = function(props) {
|
||||
function DeveloperNote({text}) {
|
||||
return html`
|
||||
<div class="flex p-4 gap-2">
|
||||
<${Icons.info} class="self-start basis-[30px] grow-0 shrink-0 text-green-600" />
|
||||
<div class="text-sm">
|
||||
<div class="font-semibold mt-1">Developer Note<//>
|
||||
${text.split('.').map(v => html` <p class="my-2 text-slate-500">${v}<//>`)}
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
function Main({}) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const refresh = () => fetch('api/stats/get').then(r => r.json()).then(r => setStats(r));
|
||||
useEffect(refresh, []);
|
||||
if (!stats) return '';
|
||||
return html`
|
||||
<div class="p-2">
|
||||
<div class="p-4 sm:p-2 mx-auto grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<${Stat} title="Temperature" text="${stats.temperature} °C" tipText="good" tipIcon=${Icons.ok} tipColors=${tipColors.green} />
|
||||
<${Stat} title="Humidity" text="${stats.humidity} %" tipText="warn" tipIcon=${Icons.warn} tipColors=${tipColors.yellow} />
|
||||
<div class="bg-white col-span-2 border rounded-md shadow-lg" role="alert">
|
||||
<${DeveloperNote} text="Stats data is received from the Mongoose backend" />
|
||||
<//>
|
||||
<//>
|
||||
<div class="p-4 sm:p-2 mx-auto grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<${Events} />
|
||||
|
||||
<div class="my-4 hx-24 bg-white border rounded-md shadow-lg" role="alert">
|
||||
<${DeveloperNote}
|
||||
text="Events data is also received from the backend,
|
||||
via the /api/events/get API call, which returns an array of objects each
|
||||
representing an event. Events table is scrollable,
|
||||
Table header is sticky" />
|
||||
<//>
|
||||
|
||||
<${Chart} data=${stats.points} />
|
||||
|
||||
<div class="my-4 hx-24 bg-white border rounded-md shadow-lg" role="alert">
|
||||
<${DeveloperNote}
|
||||
text="This chart is an SVG image, generated on the fly from the
|
||||
data returned by the /api/stats/get API call" />
|
||||
<//>
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
function Settings({}) {
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [saveResult, setSaveResult] = useState(null);
|
||||
const refresh = () => fetch('api/settings/get')
|
||||
.then(r => r.json())
|
||||
.then(r => setSettings(r));
|
||||
useEffect(refresh, []);
|
||||
|
||||
const mksetfn = k => (v => setSettings(x => Object.assign({}, x, {[k]: v})));
|
||||
const onsave = ev => fetch('api/settings/set', {
|
||||
method: 'post', body: JSON.stringify(settings)
|
||||
}).then(r => r.json())
|
||||
.then(r => setSaveResult(r))
|
||||
.then(refresh);
|
||||
|
||||
if (!settings) return '';
|
||||
const logOptions = [[0, 'Disable'], [1, 'Error'], [2, 'Info'], [3, 'Debug']];
|
||||
return html`
|
||||
<div class="m-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
|
||||
<div class="py-1 divide-y border rounded bg-white flex flex-col">
|
||||
<div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
|
||||
Device Settings
|
||||
<//>
|
||||
<div class="py-2 px-5 flex-1 flex flex-col relative">
|
||||
${saveResult && html`<${Notification} ok=${saveResult.status}
|
||||
text=${saveResult.message} close=${() => setSaveResult(null)} />`}
|
||||
|
||||
<${Setting} title="Enable Logs" value=${settings.log_enabled} setfn=${mksetfn('log_enabled')} type="switch" />
|
||||
<${Setting} title="Log Level" value=${settings.log_level} setfn=${mksetfn('log_level')} type="select" addonLeft="0-3" disabled=${!settings.log_enabled} options=${logOptions}/>
|
||||
<${Setting} title="Brightness" value=${settings.brightness} setfn=${mksetfn('brightness')} type="number" addonRight="%" />
|
||||
<${Setting} title="Device Name" value=${settings.device_name} setfn=${mksetfn('device_name')} type="" />
|
||||
<div class="mb-1 mt-3 flex place-content-end"><${Button} icon=${Icons.save} onclick=${onsave} title="Save Settings" /><//>
|
||||
<//>
|
||||
<//>
|
||||
|
||||
<div class="bg-white border rounded-md text-ellipsis overflow-auto" role="alert">
|
||||
<${DeveloperNote}
|
||||
text="A variety of controls are pre-defined to ease the development:
|
||||
toggle button, dropdown select, input field with left and right
|
||||
addons. Device settings are received by calling
|
||||
/api/settings/get API call, which returns settings JSON object.
|
||||
Clicking on a save button calls /api/settings/set
|
||||
API call" />
|
||||
<//>
|
||||
|
||||
<//>`;
|
||||
};
|
||||
|
||||
const App = function({}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [url, setUrl] = useState('/');
|
||||
const [user, setUser] = useState('');
|
||||
const [config, setConfig] = useState({});
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
const getconfig = () =>
|
||||
fetch('api/config/get', {headers: {Authorization: ''}})
|
||||
.then(r => r.json())
|
||||
.then(r => setConfig(r))
|
||||
.catch(err => console.log(err));
|
||||
const logout = () => fetch('api/logout').then(r => setUser(''));
|
||||
const login = r => !r.ok ? setLoading(false) && setUser(null) : r.json()
|
||||
.then(r => setUser(r.user))
|
||||
.finally(r => setLoading(false));
|
||||
|
||||
// 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 + l.pathname + '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();
|
||||
};
|
||||
useEffect(() => fetch('api/login').then(login), []);
|
||||
|
||||
const login = function(u) {
|
||||
document.cookie = `access_token=${u.token}; Secure, HttpOnly; SameSite=Lax; path=/; max-age=3600`;
|
||||
setUser(u.user);
|
||||
if (location.search.substring(1) == 'nows') {
|
||||
// If query string is ?nows, then do not connect to websocket. For debug.
|
||||
} else {
|
||||
watch(); // Connect to websocket, receive constant graph updates
|
||||
}
|
||||
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} />`;
|
||||
if (loading) return ''; // Show blank page on initial load
|
||||
if (!user) return html`<${Login} loginFn=${login} logoIcon=${Logo}
|
||||
title="Device Dashboard Login"
|
||||
tipText="To login, use: admin/admin, user1/user1, user2/user2" />`; // If not logged in, show login screen
|
||||
|
||||
return html`
|
||||
<${Nav} user=${user} logout=${logout} />
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col col-6"><${Hero} /></div>
|
||||
<div class="col col-6"><${Chart} /></div>
|
||||
<div class="col col-6">
|
||||
${user == 'admin' && h(Configuration, {config})}
|
||||
</div>
|
||||
<div class="col col-6"><${Messages} config=${config} /></div>
|
||||
</div>
|
||||
</div>`;
|
||||
<div class="min-h-screen bg-slate-100">
|
||||
<${Sidebar} url=${url} show=${showSidebar} />
|
||||
<${Header} logout=${logout} user=${user} showSidebar=${showSidebar} setShowSidebar=${setShowSidebar} />
|
||||
<div class="${showSidebar && 'pl-72'} transition-all duration-300 transform">
|
||||
<${Router} onChange=${ev => setUrl(ev.url)} history=${History.createHashHistory()} >
|
||||
<${Main} default=${true} />
|
||||
<${Settings} path="settings" />
|
||||
<//>
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
window.onload = () => render(h(App), document.body);
|
||||
Reference in new issue
Block a user