Rework dashboard

This commit is contained in:
Sergey Lyubka 2022-06-05 14:59:59 +01:00
parent cac7f653c9
commit 31c7d66245
7 changed files with 1104 additions and 920 deletions

View File

@ -1,6 +1,6 @@
PROG ?= example
SOURCES ?= ../../mongoose.c main.c net.c packed_fs.c
CFLAGS ?= -I../.. -DMG_ENABLE_PACKED_FS=1 $(EXTRA)
CFLAGS ?= -I../.. -DMG_ENABLE_PACKED_FS=1 -DMG_ENABLE_LINES=1 $(EXTRA)
FILES_TO_EMBED ?= $(wildcard web_root/*)
ROOT ?= $(realpath $(CURDIR)/../../..)
DOCKER ?= docker run --rm -e Tmp=. -e WINEDEBUG=-all -v $(ROOT):$(ROOT) -w $(CURDIR)

View File

@ -2,15 +2,17 @@
// All rights reserved
#include "mongoose.h"
const char *s_listening_url = "http://0.0.0.0:8000";
void device_dashboard_fn(struct mg_connection *, int, void *, void *);
int main(void) {
struct mg_mgr mgr;
mg_log_set("3");
mg_log_set("2"); // Set to 3 for debug, to 4 for very verbose level
mg_mgr_init(&mgr);
mg_http_listen(&mgr, "http://0.0.0.0:8000", device_dashboard_fn, &mgr);
for (;;) mg_mgr_poll(&mgr, 1000);
mg_http_listen(&mgr, s_listening_url, device_dashboard_fn, &mgr);
MG_INFO(("Listening on %s", s_listening_url));
while (mgr.conns != NULL) mg_mgr_poll(&mgr, 500);
mg_mgr_free(&mgr);
return 0;
}

View File

@ -25,6 +25,7 @@ static struct config {
} s_config;
static struct mg_connection *s_mqtt = NULL; // MQTT connection
static bool s_connected = false; // MQTT connection established
// Try to update a single configuration value
static void update_config(struct mg_str *body, const char *name, char **value) {
@ -66,8 +67,10 @@ static void send_notification(struct mg_mgr *mgr, const char *name,
const char *data) {
struct mg_connection *c;
for (c = mgr->conns; c != NULL; c = c->next) {
if (c->label[0] == 'W')
mg_http_printf_chunk(c, "{\"name\": \"%s\", \"data\": %s}", name, data);
if (c->label[0] != 'W') continue;
// c->is_hexdumping = 1;
mg_ws_printf(c, WEBSOCKET_OP_TEXT, "{\"name\": \"%s\", \"data\": %s}", name,
data);
}
}
@ -76,33 +79,40 @@ static void timer_metrics_fn(void *param) {
char buf[50];
mg_snprintf(buf, sizeof(buf), "[ %lu, %d ]", (unsigned long) time(NULL),
10 + (int) ((double) rand() * 10 / RAND_MAX));
// MG_INFO(("%s", buf));
send_notification(param, "metrics", buf);
// MG_INFO(("%s", buf));
}
// MQTT event handler function
static void mqtt_fn(struct mg_connection *c, int ev, void *evd, void *fnd) {
if (ev == MG_EV_MQTT_OPEN) {
struct mg_str topic = mg_str(s_config.pub);
mg_mqtt_sub(s_mqtt, &topic, 1);
s_connected = true;
// c->is_hexdumping = 1;
mg_mqtt_sub(s_mqtt, mg_str(s_config.sub), 1);
send_notification(c->mgr, "config", "null");
} else if (ev == MG_EV_MQTT_MSG) {
struct mg_mqtt_message *mm = evd;
char buf[256];
snprintf(buf, sizeof(buf), "{\"topic\":\"%.*s\",\"data\":\"%.*s\"}",
(int) mm->topic.len, mm->topic.ptr, (int) mm->data.len,
mm->data.ptr);
send_notification(param, "message", buf);
send_notification(c->mgr, "message", buf);
} else if (ev == MG_EV_CLOSE) {
s_mqtt = NULL;
if (s_connected) {
s_connected = false;
send_notification(c->mgr, "config", "null");
}
}
(void) fnd;
}
// Keep MQTT connection open - reconnect if closed
static void timer_metrics_fn(void *param) {
static void timer_mqtt_fn(void *param) {
struct mg_mgr *mgr = (struct mg_mgr *) param;
if (s_mqtt == NULL) {
struct mg_mqtt_opts opts = {};
s_mqtt = mg_mqtt_connect(mgr, s_config.rl, &opts, mqtt_fn, NULL);
s_mqtt = mg_mqtt_connect(mgr, s_config.url, &opts, mqtt_fn, NULL);
}
}
@ -127,8 +137,10 @@ void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
mg_printf(c, "%s", "HTTP/1.1 403 Denied\r\nContent-Length: 0\r\n\r\n");
} else if (mg_http_match_uri(hm, "/api/config/get")) {
mg_printf(c, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
mg_http_printf_chunk(c, "{\"url\":\"%s\",\"pub\":\"%s\",\"sub\":\"%s\"}",
s_config.url, s_config.pub, s_config.sub);
mg_http_printf_chunk(
c, "{\"url\":\"%s\",\"pub\":\"%s\",\"sub\":\"%s\",\"connected\":%s}",
s_config.url, s_config.pub, s_config.sub,
s_connected ? "true" : "false");
mg_http_printf_chunk(c, "");
} else if (mg_http_match_uri(hm, "/api/config/set")) {
// Admins only
@ -136,6 +148,7 @@ void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
update_config(&hm->body, "url", &s_config.url);
update_config(&hm->body, "pub", &s_config.pub);
update_config(&hm->body, "sub", &s_config.sub);
if (s_mqtt) s_mqtt->is_closing = 1; // Ask to disconnect from MQTT
send_notification(fn_data, "config", "null");
mg_printf(c, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
} else {
@ -143,14 +156,15 @@ void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
}
} else if (mg_http_match_uri(hm, "/api/message/send")) {
char buf[256];
if (mg_http_get_var(&hm->body, "message", buf + 1, sizeof(buf) - 2) > 0) {
buf[0] = buf[strlen(buf)] = '"';
send_notification(fn_data, "message", buf);
if (s_connected &&
mg_http_get_var(&hm->body, "message", buf, sizeof(buf)) > 0) {
mg_mqtt_pub(s_mqtt, mg_str(s_config.pub), mg_str(buf), 1, false);
}
mg_printf(c, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
} else if (mg_http_match_uri(hm, "/api/watch")) {
c->label[0] = 'W'; // Mark ourselves as a event listener
mg_printf(c, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
mg_ws_upgrade(c, hm, NULL);
// mg_printf(c, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
} else if (mg_http_match_uri(hm, "/api/login")) {
mg_printf(c, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
mg_http_printf_chunk(c, "{\"user\":\"%s\",\"token\":\"%s\"}\n", u->name,
@ -158,7 +172,7 @@ void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
mg_http_printf_chunk(c, "");
} else {
struct mg_http_serve_opts opts = {0};
#if 0
#if 1
opts.root_dir = "/web_root";
opts.fs = &mg_fs_packed;
#else

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 282 KiB

View File

@ -3,6 +3,23 @@ import {Component, h, html, render, useEffect, useState, useRef} from './preact.
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">
@ -41,23 +58,69 @@ const Hero = props => html`
using <code>curl</code> command-line utility:
</p>
<div><code>curl localhost:8000/api/config/get</code> - get current device configuration</div>
<div><code>curl localhost:8000/api/config/set -d 'value1=7&value2=hello'</code> - set device configuration</div>
<div><code>curl localhost:8000/api/message/send -d 'msg=hello'</code> - send MQTT message</div>
<div><code>curl localhost:8000/api/watch</code> - get notifications: MQTT messages, configuration, sensor data</div>
<div><code>curl localhost:8000/api/config/get</code> </div>
<div><code>curl localhost:8000/api/config/set -d 'value1=7&value2=hello'</code> </div>
<div><code>curl localhost:8000/api/message/send -d 'msg=hello'</code> </div>
<p>
A device can send notifications to this dashboard at anytime. Notifications
are sent over Websocket at URI <code>curl localhost:8000/api/watch</code>
as JSON strings <code>{"name": "..", "data": ...}</code>
</p>
</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);
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>`;
};
const Configuration = function(props) {
const [url, setUrl] = useState('');
const [pub, setPub] = useState('');
const [sub, setSub] = useState('');
useEffect(() => {
setUrl(props.config.url);
setPub(props.config.pub);
setSub(props.config.sub);
}, [props.config.url, props.config.pub, props.config.sub])
const id = PubSub.subscribe(function(msg) {
if (msg.name == 'newconfig') {
setUrl(msg.data.url);
setPub(msg.data.pub);
setSub(msg.data.sub);
}
});
return PubSub.unsubscribe(id);
}, []);
const update = (name, val) => fetch('/api/config/set', {
method: 'post',
body: `${name}=${encodeURIComponent(val)}`
@ -65,6 +128,7 @@ const Configuration = function(props) {
const updateurl = ev => update('url', url);
const updatepub = ev => update('pub', pub);
const updatesub = ev => update('sub', sub);
return html`
<div style="margin: 0 0.3em;">
<h3 style="background: #c03434; color: #fff; padding: 0.4em;">
@ -105,62 +169,44 @@ const Configuration = function(props) {
</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);
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>`;
};
const Message = text => html`<div style="margin: 0.5em 0;">${text}</div>`;
const Message = m => html`<div style="margin: 0.5em 0;">
<span class="topic">topic: ${m.message.topic} </span>
<span class="data">data: ${m.message.data}</span>
</div>`;
const Messages = function(props) {
const [message, setMessage] = useState('');
const [messages, setMessages] = useState([]);
const [cfg, setCfg] = useState({});
const [txt, setTxt] = useState('');
useEffect(() => {
const id = PubSub.subscribe(function(msg) {
if (msg.name == 'newconfig') setCfg(x => msg.data);
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(message)}`
}).then(r => setMessage(''));
body: `message=${encodeURIComponent(txt)}`
}).then(r => setTxt(''));
const messages = props.messages.map(
text => html`<div style="margin: 0.5em 0;">${text}</div>`);
return html`
<div style="margin: 0 0.3em;">
<h3 style="background: #30c040; color: #fff; padding: 0.4em;">MQTT messages</h3>
<div>
MQTT server status: <b>${cfg.connected ? 'connected' : 'diconnected'}</b>
</div>
<div style="height: 10em; overflow: auto; padding: 0.5em; " class="border">
${messages}
${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=${message} onchange=${sendmessage}
oninput=${ev => setMessage(ev.target.value)} />
value=${txt} onchange=${sendmessage}
oninput=${ev => setTxt(ev.target.value)} />
</div>
<div class="msg">
Message gets passed to the device via REST. Then a device sends it to
@ -240,14 +286,22 @@ const SVG = function(props) {
const Chart = function(props) {
let xmax = 0, missing = MaxMetricsDataPoints - props.metrics.length;
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`
<div style="margin: 0 0.3em;">
<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=${props.metrics} />
<${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 a device via /api/watch.
@ -256,19 +310,17 @@ const Chart = function(props) {
};
const App = function(props) {
const [messages, setMessages] = useState([]);
const [metrics, setMetrics] = useState([]);
const [user, setUser] = useState('');
const [config, setConfig] = useState({});
const refresh = () => fetch('/api/config/get', {headers: {Authorization: ''}})
.then(r => r.json())
.then(r => setConfig(r));
const getconfig = () =>
fetch('/api/config/get', {headers: {Authorization: ''}})
.then(r => r.json())
.then(r => PubSub.publish({name: 'newconfig', data: r}));
const login = function(u) {
document.cookie = `access_token=${u.token};path=/;max-age=3600`;
setUser(u.user);
refresh();
return getconfig();
};
const logout = ev => {
@ -276,48 +328,50 @@ const App = function(props) {
setUser('');
};
// Watch for notifications. As soon as a notification arrives, pass it on
// to all subscribed components
const watch = function() {
var f = function(reader) {
return reader.read().then(function(result) {
var data = String.fromCharCode.apply(null, result.value);
var msg = JSON.parse(data);
if (msg.name == 'config') {
refresh();
} else if (msg.name == 'message') {
setMessages(m => m.concat([msg.data]));
} else if (msg.name == 'metrics') {
setMetrics(m => m.concat([msg.data]).splice(-MaxMetricsDataPoints));
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.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
}
// console.log(msg);
if (!result.done) return f(reader);
});
};
ws.onclose = function() {
clearTimeout(tid);
tid = setTimeout(reconnect, 1000);
};
};
fetch('/api/watch', {headers: {Authorization: ''}})
.then(r => r.body.getReader())
.then(f)
.catch(e => setTimeout(watch, 1000));
reconnect();
};
useEffect(() => {
// Called once at init time
fetch('/api/login', {headers: {Authorization: ''}})
.then(r => r.json())
.then(r => login(r))
.catch(err => setUser(''));
refresh();
watch();
PubSub.subscribe(msg => msg.name == 'config' && getconfig());
}, []);
if (!user) return html`<${Login} login=${login} />`;
const admin = user == 'admin';
const cs = admin ? html`<${Configuration} config=${config} />` : '';
return html`
<${Nav} user=${user} logout=${logout} />
<div class="container">
<${Hero} />
<div class="row">
<div class="col c6"><${Chart} metrics=${metrics} /></div>
<div class="col c6">${cs}</div>
<div class="col c6"><${Messages} messages=${messages} /></div>
<div class="col col-6"><${Hero} /></div>
<div class="col col-6"><${Chart} /></div>
<div class="col col-6">${user == 'admin' && h(Configuration)}</div>
<div class="col col-6"><${Messages} /></div>
</div>
<${Footer} />
</div>`;

View File

@ -17,21 +17,24 @@ a, a:visited, a:active { color: #55f; }
.border { border: 1px solid #ddd; }
.rounded { border-radius: 0.5em; }
.nowrap { white-space: nowrap; }
.msg { background: #def; border-left: 5px solid #59d; padding: 1em; margin: 1em 0; }
.row { margin: 1% 0; overflow: auto; }
.col { float: left; }
.table, .c12 { width: 100%; }
.c11 { width: 91.66%; }
.c10 { width: 83.33%; }
.c9 { width: 75%; }
.c8 { width: 66.66%; }
.c7 { width: 58.33%; }
.c6 { width: 50%; }
.c5 { width: 41.66%; }
.c4 { width: 33.33%; }
.c3 { width: 25%; }
.c2 { width: 16.66%; }
.c1 { width: 8.33%; }
.msg { background: #def; border-left: 5px solid #59d; padding: 0.5em; font-size: 90%; margin: 1em 0; }
.row { display: flex; flex-wrap: wrap; }
.col { margin: 0; padding: 0; overflow: auto; }
.col-12 { width: 100%; }
.col-11 { width: 91.66%; }
.col-10 { width: 83.33%; }
.col-9 { width: 75%; }
.col-8 { width: 66.66%; }
.col-7 { width: 58.33%; }
.col-6 { width: 50%; }
.col-5 { width: 41.66%; }
.col-4 { width: 33.33%; }
.col-3 { width: 25%; }
.col-2 { width: 16.66%; }
.col-1 { width: 8.33%; }
.topic { background: #fea; padding: 0.2em 1em; border-radius: 0.4em; }
.data { background: #aef; padding: 0.2em 1em; border-radius: 0.4em; }
@media (min-width: 1310px) { .container { margin: auto; width: 1270px; } }
@media (max-width: 870px) { .row .col { width: 100%; } }
@media (max-width: 920px) { .row .col { width: 100%; } }