Dash updates

This commit is contained in:
cpq committed 2026-05-03 08:30:16 +01:00
1 parent 0c296a17f8
commit aee72b3fbf
8 files changed
+541 -188

No files matched your search

+122 -12
View File
@@ -124,6 +124,14 @@ fail:
#define MG_NO_CACHE_HEADERS "Cache-Control: no-cache\r\n"
#define MG_JSON_HEADERS "Content-Type: application/json\r\n" MG_NO_CACHE_HEADERS
struct mg_dash_user {
struct mg_dash_user *next;
char name[32]; // User name
char token[21]; // Login token
int level; // Access level
uint64_t expire; // Expiration timestamp
};
struct mg_upload_state {
char marker; // Tells that we're a file upload connection
size_t expected; // POST data length, bytes
@@ -277,8 +285,10 @@ static int mg_dash_parse_field(struct mg_str json, struct mg_field *f) {
ok = mg_json_get_bool(json, json_path, (bool *) f->value);
} else if (f->type == MG_VAL_INT) {
double d;
ok = mg_json_get_num(json, json_path, &d);
if (ok) *(int *) f->value = (int) d;
if (mg_json_get_num(json, json_path, &d) && d == (int) d) {
*(int *) f->value = (int) d;
ok = true;
}
} else if (f->type == MG_VAL_DBL) {
ok = mg_json_get_num(json, json_path, (double *) f->value);
} else if (f->type == MG_VAL_STR) {
@@ -294,24 +304,25 @@ static int mg_dash_apply(struct mg_connection *c, struct mg_dash *dash,
struct mg_str json) {
struct mg_str key, val;
size_t ofs = 0;
int count = 0;
// struct mg_field *f = NULL;
int total_count = 0;
while ((ofs = mg_json_next(json, ofs, &key, &val)) > 0) {
struct mg_field_set *set = mg_dash_find_field_set(dash, trimq(key));
int count = 0;
if (set != NULL) {
size_t i;
for (i = 0; set->fields[i].name != NULL; i++) {
if (mg_dash_parse_field(val, &set->fields[i])) count++;
}
if (count > 0) {
if (count) {
if (set->writer) set->writer();
mg_dash_send_change(c->mgr, set);
total_count += count;
}
} else {
MG_ERROR(("UNKNOWN SET: [%.*s]", key.len, key.buf));
}
}
return count;
return total_count;
}
static void mg_dash_process_msg(struct mg_connection *c,
@@ -405,8 +416,8 @@ static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
mg_http_reply(c, 500, NULL, "File upload error %d\n", errno);
} else {
mg_dash_file_add(hm->uri, hm->body.len);
MG_DEBUG(("Starting upload, [%.*s] %lu", hm->uri.len, hm->uri.buf,
hm->body.len));
MG_DEBUG(("%lu Starting upload, [%.*s] %lu", c->id, hm->uri.len,
hm->uri.buf, hm->body.len));
us->marker = 'U';
us->expected = hm->body.len;
us->received = 0;
@@ -429,7 +440,7 @@ static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
c->recv.len = 0;
if (us->received >= us->expected) {
fclose((FILE *) us->ctx);
MG_DEBUG(("Uploaded %lu", us->expected));
MG_DEBUG(("%lu Uploaded %lu", c->id, us->expected));
mg_http_reply(c, 200, NULL, "%lu uploaded\n", us->expected);
mg_dash_send_change(c->mgr, &set_files);
memset(us, 0, sizeof(*us));
@@ -438,6 +449,91 @@ static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
}
}
static uint64_t mg_dash_make_expiration_time(struct mg_dash *dash) {
unsigned t = (unsigned) dash->session_auto_expiration_seconds;
if (t == 0) t = 3600; // Default expiration time in seconds
return mg_millis() + t * 1000;
}
// Parse HTTP requests, return authenticated user or NULL
static struct mg_dash_user *mg_dash_authenticate(struct mg_http_message *hm,
struct mg_dash *dash) {
static struct mg_dash_user *s_users; // List of authenticated users
char user[100], pass[100];
struct mg_dash_user *u, *tmp, *result = NULL;
if (dash->authenticate == NULL) return NULL;
mg_http_creds(hm, user, sizeof(user), pass, sizeof(pass));
// Remove expired users
for (u = s_users; u != NULL; u = tmp) {
tmp = u->next;
if (u->expire < mg_millis()) {
MG_DEBUG(("Deleting expired auth %s/%d %llu %u", u->name, u->level,
u->expire, mg_millis() - u->expire));
LIST_DELETE(struct mg_dash_user, &s_users, u);
}
}
if (pass[0] != '\0') {
struct mg_str *ah = mg_http_get_header(hm, "Authorization");
if (ah != NULL) {
// Auth header and password are set, auth by user/password via glue API
int num_users = 0, level = dash->authenticate(user, pass);
MG_DEBUG(("user %s, level: %d", user, level));
if (level > 0) { // Proceed only if the firmware authenticated us
for (u = s_users; u != NULL && result == NULL;
u = u->next, num_users++) {
if (strcmp(user, u->name) == 0) {
u->expire = mg_dash_make_expiration_time(dash);
result = u;
}
}
// Not yet authenticated, add to the list
if (result == NULL && num_users < 10) {
result = (struct mg_dash_user *) mg_calloc(1, sizeof(*result));
mg_snprintf(result->name, sizeof(result->name), "%s", user);
mg_random_str(result->token, sizeof(result->token) - 1);
result->level = level, result->next = s_users, s_users = result;
result->expire = mg_dash_make_expiration_time(dash);
}
}
} else if (ah == NULL) {
for (u = s_users; u != NULL && result == NULL; u = u->next) {
if (strcmp(u->token, pass) == 0) {
u->expire = mg_dash_make_expiration_time(dash);
result = u;
}
}
}
}
// MG_DEBUG(("[%s/%s] -> %s", user, pass, result ? "OK" : "FAIL"));
return result;
}
static void mg_handle_login(struct mg_connection *c, struct mg_dash_user *u) {
char cookie[256];
mg_snprintf(cookie, sizeof(cookie),
"Set-Cookie: access_token=%s; Path=/; "
"%sHttpOnly; SameSite=Lax; Max-Age=%d\r\n%s",
u->token, c->is_tls ? "Secure; " : "", 3600 * 24,
MG_JSON_HEADERS);
mg_http_reply(c, 200, cookie, "{%m:%m,%m:%d}\n", //
MG_ESC("user"), MG_ESC(u->name), //
MG_ESC("level"), u->level);
}
static void mg_handle_logout(struct mg_connection *c) {
char cookie[256];
mg_snprintf(cookie, sizeof(cookie),
"Set-Cookie: access_token=; Path=/; "
"Expires=Thu, 01 Jan 1970 00:00:00 UTC; "
"%sHttpOnly; Max-Age=0; \r\n",
c->is_tls ? "Secure; " : "");
mg_http_reply(c, 401, cookie, "Unauthorized\n");
}
void mg_dash_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
struct mg_dash *dash = (struct mg_dash *) c->fn_data;
if (ev == MG_EV_OPEN) {
@@ -446,9 +542,24 @@ void mg_dash_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
}
} else if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
struct mg_dash_user *u = mg_dash_authenticate(hm, dash);
struct mg_str parts[5];
memset(parts, 0, sizeof(parts));
if (mg_match(hm->uri, mg_str("/api/websocket"), NULL)) {
MG_DEBUG(("%lu %p %s", c->id, u, u ? u->name : ""));
if (mg_match(hm->uri, mg_str("/api/hi"), NULL)) {
mg_http_reply(c, 200, MG_JSON_HEADERS, "hi\n");
} else if (mg_match(hm->uri, mg_str("/api/logout"), NULL)) {
mg_handle_logout(c);
mg_ws_printf(c, WEBSOCKET_OP_TEXT, "{%m:%m}", MG_ESC("method"),
MG_ESC("logout"));
} else if (mg_match(hm->uri, mg_str("/api/login"), NULL) && u != NULL) {
mg_handle_login(c, u);
} else if (dash->authenticate && u == NULL &&
mg_match(hm->uri, mg_str("/api/#"), NULL)) {
mg_http_reply(c, 403, MG_JSON_HEADERS, "Not Authorised\n");
} else if (mg_match(hm->uri, mg_str("/api/websocket"), NULL)) {
mg_ws_upgrade(c, hm, NULL);
} else if (mg_match(hm->uri, mg_str("/files/#"), NULL)) {
file_ev_handler(c, ev, ev_data);
@@ -481,8 +592,7 @@ void mg_dash_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
for (fs = dash->sets; fs != NULL; fs = fs->next) {
mg_dash_send_change(c->mgr, fs);
}
// Send change notification with no params to indicate we're done for now
mg_dash_broadcast(c->mgr, "{%m:%m}", MG_ESC("method"), MG_ESC("change"));
mg_dash_broadcast(c->mgr, "{%m:%m}", MG_ESC("method"), MG_ESC("ready"));
} else if (ev == MG_EV_WS_MSG) {
// Add this to automatically handle "get" and "set" JSON-RPC calls
struct mg_ws_message *wm = (struct mg_ws_message *) ev_data;
+2
View File
@@ -3193,6 +3193,8 @@ struct mg_dash_file {
struct mg_dash {
struct mg_field_set *sets;
struct mg_dash_custom_handler *custom_handlers;
int (*authenticate)(const char *user, const char *pass);
int session_auto_expiration_seconds;
//struct mg_dash_file *files;
};
+71 -41
View File
@@ -5,7 +5,7 @@
const Rpc = (function() {
function Rpc(url, onev) {
let ws, id = 1, oncall, q = [];
let ws, id = 1, oncall, q = [], reconnect = true;
const pending = new Map();
const realsend = s => { ws.send(s); onev && onev('send', s); };
const fail = e => { for (const p of pending.values()) p[1](e || 'disconnected'); pending.clear(); };
@@ -15,7 +15,7 @@
const connect = () => {
ws = new WebSocket(url);
ws.onopen = () => { onev && onev('open'); while (q.length) realsend(JSON.stringify(q.shift())); };
ws.onclose = () => { onev && onev('close'); fail(); setTimeout(connect, 1000); };
ws.onclose = () => { onev && onev('close'); fail(); reconnect && setTimeout(connect, 1000); };
ws.onmessage = e => {
onev && onev('message', e.data);
let m;
@@ -43,6 +43,7 @@
connect();
return {
close: () => { reconnect = false; ws?.close(); },
call: (method, params) => new Promise((ok, bad) => {
const i = id++;
pending.set(i, [ok, bad]);
@@ -57,16 +58,75 @@
return Rpc;
})();
function rpc_connect() {
if (isMock) {
setTimeout(() => userhandlers['ready']?.(), 500);
return;
}
if (rpc) return rpc;
//if (settings.auth && !status.authed) return null;
rpc = Rpc('api/websocket', function (evname, args) {
if (evname == 'open') status.online = true, rescan();
if (evname == 'close') status.online = false, rescan();
if (settings.debug) console.log('WS', evname, args);
});
rpc.handle((method, args) => {
if (method == 'change' && args) apply_and_rescan(settings.data, args);
if (method == 'logout') rpc_disconnect();
if (userhandlers[method]) userhandlers[method](args);
});
return rpc;
}
function rpc_disconnect() {
if (!rpc) return;
rpc.close();
rpc = null;
status.online = false;
}
function login(params) {
if (isMock) { rpc_connect(); return Promise.resolve({}); }
const opts = {};
if (params) {
const username = (params?.username ?? '').trim();
const password = params?.password ?? '';
const join = username + ':' + password;
opts.headers = { Authorization: `Basic ${btoa(join)}` };
}
return fetch('api/login', opts)
.then(r => r.ok ? r.json() : r.text().then(t => Promise.reject(t || r.statusText)))
.then(resp => {
status.username = resp?.user || '';
status.userlevel = resp?.level || 0;
//status.authed = !!status.username;
//if (settings.auth && status.authed) {
rpc_connect();
//rescan();
return resp;
});
};
function logout() {
if (isMock) { rpc_disconnect(); return Promise.resolve({}); }
return fetch('api/logout')
.catch(() => true)
.then(() => {
status.username = '';
status.userlevel = 0;
rpc_disconnect();
rescan();
return true;
});
}
if (global.Dashboard) return;
const isMock = !!window.frameElement || (window !== window.top);
const isMock = location.protocol === 'file:' || !!window.frameElement || (window !== window.top);
const settings = {data: {}, edits: {}, debug: true};
const userhandlers = {}; // User event handlers
const status = { online: true, username: '', userlevel: 0, lastseen: '', initialized: false };
const rpc = isMock ? null : Rpc('api/websocket', function (evname, args) {
if (evname == 'open') status.online = true, rescan();
if (evname == 'close') status.online = false, rescan();
if (settings.debug) console.log('WS', evname, args);
});
const status = { online: true, username: '', userlevel: 0, lastseen: '', ready: false };
let rpc = null;
const fromPath = (path, value) => path.split('.').reverse().reduce((acc, k) => ({ [k]: acc }), value);
const get = (obj, path) => path.split('.').reduce((o, k) => o?.[k], obj);
@@ -327,40 +387,10 @@
userhandlers[name] = fn;
};
const setbody = visibility => document.body.style.visibility = visibility;
function init(conf) {
apply_and_rescan(settings, conf);
if (rpc) {
rpc.handle((method, args) => {
if (method == 'change') {
// "initialized" means that the device has sent us all initial
// data change notifications, and our settings.data contain
// actual refreshed device data - not the UI mock
if (!args) {
setbody('visible');
status.initialized = true;
}
if (args) apply_and_rescan(settings.data, args);
}
if (method != 'change') console.log('UNKNOWN REQUEST', method, args);
if (userhandlers[method]) userhandlers[method](args);
});
// After init, fetch all device data
//rpc.call('get').then(r => apply_and_rescan(settings.data, r));
} else {
setTimeout(function() {
status.initialized = true;
setbody('visible');
}, 500);
}
if (!settings.auth) rpc_connect();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => setbody('hidden'), { once: true });
} else {
setbody('hidden');
}
global.Dashboard = { init, call, on, status };
global.Dashboard = { init, call, on, status, login, logout };
})(window);
+122 -12
View File
@@ -3,6 +3,14 @@
#define MG_NO_CACHE_HEADERS "Cache-Control: no-cache\r\n"
#define MG_JSON_HEADERS "Content-Type: application/json\r\n" MG_NO_CACHE_HEADERS
struct mg_dash_user {
struct mg_dash_user *next;
char name[32]; // User name
char token[21]; // Login token
int level; // Access level
uint64_t expire; // Expiration timestamp
};
struct mg_upload_state {
char marker; // Tells that we're a file upload connection
size_t expected; // POST data length, bytes
@@ -156,8 +164,10 @@ static int mg_dash_parse_field(struct mg_str json, struct mg_field *f) {
ok = mg_json_get_bool(json, json_path, (bool *) f->value);
} else if (f->type == MG_VAL_INT) {
double d;
ok = mg_json_get_num(json, json_path, &d);
if (ok) *(int *) f->value = (int) d;
if (mg_json_get_num(json, json_path, &d) && d == (int) d) {
*(int *) f->value = (int) d;
ok = true;
}
} else if (f->type == MG_VAL_DBL) {
ok = mg_json_get_num(json, json_path, (double *) f->value);
} else if (f->type == MG_VAL_STR) {
@@ -173,24 +183,25 @@ static int mg_dash_apply(struct mg_connection *c, struct mg_dash *dash,
struct mg_str json) {
struct mg_str key, val;
size_t ofs = 0;
int count = 0;
// struct mg_field *f = NULL;
int total_count = 0;
while ((ofs = mg_json_next(json, ofs, &key, &val)) > 0) {
struct mg_field_set *set = mg_dash_find_field_set(dash, trimq(key));
int count = 0;
if (set != NULL) {
size_t i;
for (i = 0; set->fields[i].name != NULL; i++) {
if (mg_dash_parse_field(val, &set->fields[i])) count++;
}
if (count > 0) {
if (count) {
if (set->writer) set->writer();
mg_dash_send_change(c->mgr, set);
total_count += count;
}
} else {
MG_ERROR(("UNKNOWN SET: [%.*s]", key.len, key.buf));
}
}
return count;
return total_count;
}
static void mg_dash_process_msg(struct mg_connection *c,
@@ -284,8 +295,8 @@ static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
mg_http_reply(c, 500, NULL, "File upload error %d\n", errno);
} else {
mg_dash_file_add(hm->uri, hm->body.len);
MG_DEBUG(("Starting upload, [%.*s] %lu", hm->uri.len, hm->uri.buf,
hm->body.len));
MG_DEBUG(("%lu Starting upload, [%.*s] %lu", c->id, hm->uri.len,
hm->uri.buf, hm->body.len));
us->marker = 'U';
us->expected = hm->body.len;
us->received = 0;
@@ -308,7 +319,7 @@ static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
c->recv.len = 0;
if (us->received >= us->expected) {
fclose((FILE *) us->ctx);
MG_DEBUG(("Uploaded %lu", us->expected));
MG_DEBUG(("%lu Uploaded %lu", c->id, us->expected));
mg_http_reply(c, 200, NULL, "%lu uploaded\n", us->expected);
mg_dash_send_change(c->mgr, &set_files);
memset(us, 0, sizeof(*us));
@@ -317,6 +328,91 @@ static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
}
}
static uint64_t mg_dash_make_expiration_time(struct mg_dash *dash) {
unsigned t = (unsigned) dash->session_auto_expiration_seconds;
if (t == 0) t = 3600; // Default expiration time in seconds
return mg_millis() + t * 1000;
}
// Parse HTTP requests, return authenticated user or NULL
static struct mg_dash_user *mg_dash_authenticate(struct mg_http_message *hm,
struct mg_dash *dash) {
static struct mg_dash_user *s_users; // List of authenticated users
char user[100], pass[100];
struct mg_dash_user *u, *tmp, *result = NULL;
if (dash->authenticate == NULL) return NULL;
mg_http_creds(hm, user, sizeof(user), pass, sizeof(pass));
// Remove expired users
for (u = s_users; u != NULL; u = tmp) {
tmp = u->next;
if (u->expire < mg_millis()) {
MG_DEBUG(("Deleting expired auth %s/%d %llu %u", u->name, u->level,
u->expire, mg_millis() - u->expire));
LIST_DELETE(struct mg_dash_user, &s_users, u);
}
}
if (pass[0] != '\0') {
struct mg_str *ah = mg_http_get_header(hm, "Authorization");
if (ah != NULL) {
// Auth header and password are set, auth by user/password via glue API
int num_users = 0, level = dash->authenticate(user, pass);
MG_DEBUG(("user %s, level: %d", user, level));
if (level > 0) { // Proceed only if the firmware authenticated us
for (u = s_users; u != NULL && result == NULL;
u = u->next, num_users++) {
if (strcmp(user, u->name) == 0) {
u->expire = mg_dash_make_expiration_time(dash);
result = u;
}
}
// Not yet authenticated, add to the list
if (result == NULL && num_users < 10) {
result = (struct mg_dash_user *) mg_calloc(1, sizeof(*result));
mg_snprintf(result->name, sizeof(result->name), "%s", user);
mg_random_str(result->token, sizeof(result->token) - 1);
result->level = level, result->next = s_users, s_users = result;
result->expire = mg_dash_make_expiration_time(dash);
}
}
} else if (ah == NULL) {
for (u = s_users; u != NULL && result == NULL; u = u->next) {
if (strcmp(u->token, pass) == 0) {
u->expire = mg_dash_make_expiration_time(dash);
result = u;
}
}
}
}
// MG_DEBUG(("[%s/%s] -> %s", user, pass, result ? "OK" : "FAIL"));
return result;
}
static void mg_handle_login(struct mg_connection *c, struct mg_dash_user *u) {
char cookie[256];
mg_snprintf(cookie, sizeof(cookie),
"Set-Cookie: access_token=%s; Path=/; "
"%sHttpOnly; SameSite=Lax; Max-Age=%d\r\n%s",
u->token, c->is_tls ? "Secure; " : "", 3600 * 24,
MG_JSON_HEADERS);
mg_http_reply(c, 200, cookie, "{%m:%m,%m:%d}\n", //
MG_ESC("user"), MG_ESC(u->name), //
MG_ESC("level"), u->level);
}
static void mg_handle_logout(struct mg_connection *c) {
char cookie[256];
mg_snprintf(cookie, sizeof(cookie),
"Set-Cookie: access_token=; Path=/; "
"Expires=Thu, 01 Jan 1970 00:00:00 UTC; "
"%sHttpOnly; Max-Age=0; \r\n",
c->is_tls ? "Secure; " : "");
mg_http_reply(c, 401, cookie, "Unauthorized\n");
}
void mg_dash_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
struct mg_dash *dash = (struct mg_dash *) c->fn_data;
if (ev == MG_EV_OPEN) {
@@ -325,9 +421,24 @@ void mg_dash_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
}
} else if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
struct mg_dash_user *u = mg_dash_authenticate(hm, dash);
struct mg_str parts[5];
memset(parts, 0, sizeof(parts));
if (mg_match(hm->uri, mg_str("/api/websocket"), NULL)) {
MG_DEBUG(("%lu %p %s", c->id, u, u ? u->name : ""));
if (mg_match(hm->uri, mg_str("/api/hi"), NULL)) {
mg_http_reply(c, 200, MG_JSON_HEADERS, "hi\n");
} else if (mg_match(hm->uri, mg_str("/api/logout"), NULL)) {
mg_handle_logout(c);
mg_ws_printf(c, WEBSOCKET_OP_TEXT, "{%m:%m}", MG_ESC("method"),
MG_ESC("logout"));
} else if (mg_match(hm->uri, mg_str("/api/login"), NULL) && u != NULL) {
mg_handle_login(c, u);
} else if (dash->authenticate && u == NULL &&
mg_match(hm->uri, mg_str("/api/#"), NULL)) {
mg_http_reply(c, 403, MG_JSON_HEADERS, "Not Authorised\n");
} else if (mg_match(hm->uri, mg_str("/api/websocket"), NULL)) {
mg_ws_upgrade(c, hm, NULL);
} else if (mg_match(hm->uri, mg_str("/files/#"), NULL)) {
file_ev_handler(c, ev, ev_data);
@@ -360,8 +471,7 @@ void mg_dash_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
for (fs = dash->sets; fs != NULL; fs = fs->next) {
mg_dash_send_change(c->mgr, fs);
}
// Send change notification with no params to indicate we're done for now
mg_dash_broadcast(c->mgr, "{%m:%m}", MG_ESC("method"), MG_ESC("change"));
mg_dash_broadcast(c->mgr, "{%m:%m}", MG_ESC("method"), MG_ESC("ready"));
} else if (ev == MG_EV_WS_MSG) {
// Add this to automatically handle "get" and "set" JSON-RPC calls
struct mg_ws_message *wm = (struct mg_ws_message *) ev_data;
+2
View File
@@ -48,6 +48,8 @@ struct mg_dash_file {
struct mg_dash {
struct mg_field_set *sets;
struct mg_dash_custom_handler *custom_handlers;
int (*authenticate)(const char *user, const char *pass);
int session_auto_expiration_seconds;
//struct mg_dash_file *files;
};
@@ -162,6 +162,17 @@ static struct mg_field_set set_graph2 = {
"graph2", fields_graph2, read_graph2, NULL, 0, 0, NULL,
};
static int authenticate(const char *user, const char *pass) {
int level = 0; // Authentication failure
if (strcmp(pass, "admin") == 0) {
level = 7; // Administrator
} else if (strcmp(pass, "user") == 0) {
level = 3; // Ordinary dude
}
(void) user;
return level;
}
void mg_dash_init(struct mg_mgr *mgr) {
static struct mg_dash dash; // Important: keep it static!
@@ -176,6 +187,9 @@ void mg_dash_init(struct mg_mgr *mgr) {
mg_dash_file_add(mg_str("device-config.json"), 1234);
mg_dash_file_add(mg_str("device-log-2026-04-25.txt"), 1327854);
// Require authentication
dash.authenticate = authenticate;
mg_http_listen(mgr, MG_HTTP_ADDR, mg_dash_ev_handler, &dash);
}
+203 -121
View File
@@ -4,10 +4,14 @@
<title>My Dashboard</title>
<!-- Mongoose device dashboard -->
<!--
<script src="https://cdn.jsdelivr.net/gh/cesanta/mongoose@master/resources/dashboard.js"></script>
-->
<script src="dashboard.js"></script>
<script>
Dashboard.init({
debug: true,
auth: true,
data: {
graph1: {data: []},
graph2: {data: [], report: true},
@@ -22,6 +26,8 @@
<script>
document.addEventListener("DOMContentLoaded", () => {
// Tab switching
const tabs = Array.from(document.querySelectorAll(".header-left .nav-link[href^='#']"));
const panels = Array.from(document.querySelectorAll(".tab-panel"));
@@ -61,6 +67,54 @@
const initialId = window.location.hash.slice(1) || "overview";
activateTab(initialId);
// Auto logout on inactivity
function restartAutoLogout() {
const autologoutTimeoutSeconds = 300;
if (window.autoLogoutTimerId) clearTimeout(window.autoLogoutTimerId);
window.autoLogoutTimerId = setTimeout(() => {
Dashboard.logout().then(() => location.reload());
}, autologoutTimeoutSeconds * 1000);
};
function setupAutoLogout() {
['mousemove', 'keydown', 'scroll', 'touchstart'].forEach(ev => {
document.addEventListener(ev, restartAutoLogout, true);
});
restartAutoLogout();
};
// Login page
const passwordEl = document.getElementById('login-password');
const loginButtonEl = document.getElementById('login-submit');
function showLogin(yes) {
document.getElementById('auth').style.display = yes ? '' : 'none';
document.getElementById('app').style.display = yes ? 'none' : '';
if (yes) {
document.body.style.visibility = 'visible';
passwordEl.focus();
passwordEl.onkeydown = function(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
loginButtonEl.click();
}
};
}
};
loginButtonEl.onclick = function(ev) {
ev.preventDefault();
const username = (document.getElementById('login-username')?.value || '').trim();
const password = passwordEl.value || '';
passwordEl.value = '';
Dashboard.login({username, password}).then(r => showLogin(false));
};
Dashboard.on('logout', function() { showLogin(true); });
Dashboard.on('ready', function() {
setupAutoLogout();
document.body.style.visibility = 'visible';
});
Dashboard.login().catch(err => showLogin(true));
// GRAPHS
// {stroke: '#14B8A6FF', fill: '#14B8A61A'}, // teal
// {stroke: '#F59E0BFF', fill: '#F59E0B1A'}, // amber
@@ -216,7 +270,8 @@ a, a:visited, a:link { text-decoration: none; }
.toggle:checked::after { transform: translateX(calc(100% - 3px)); }
.toggle:disabled, .toggle.disabled { background: #d1d5db; cursor: not-allowed; }
.toggle.edited { border-color: #0fe; }
.u-over { top: 0 !important; }
.u-under { top: 0 !important; }
.hidden { display: none; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } #files .row { flex-direction: column; flex-wrap: nowrap; } #files .panel, #files .note { flex: 1 1 auto; width: 100%; } }
@media (max-width: 900px) { .metrics-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 480px) { .metrics-grid { grid-template-columns: 1fr; } .file-row { grid-template-columns: 1fr; } .file-actions { justify-content: flex-start; } .file-row { gap: 0.65rem; } .file-size { text-align: left; } }
@@ -229,129 +284,155 @@ a, a:visited, a:link { text-decoration: none; }
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div class="header">
<div class="header-container">
<div class="header-left">
<nav class="nav-tabs" role="tablist" aria-label="Primary tabs">
<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 600" width="1200" height="600">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2dd4bf"/>
<stop offset="100%" stop-color="#0f766e"/>
</linearGradient>
</defs>
<g fill="none" stroke="url(#g)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round">
<rect x="160" y="60" width="300" height="300" rx="70"/>
<rect x="20" y="200" width="300" height="300" rx="70"/>
</g>
<text x="560" y="360"
font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
font-size="180"
font-weight="600"
fill="#6b7280">
MyLogo
</text>
</svg>
<a href="#overview" id="tab-overview" class="nav-link nav-link-active" role="tab" aria-controls="overview" aria-selected="true">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5 10.5V20h14v-9.5" />
</svg>
Overview
</a>
<a href="#settings" id="tab-settings" class="nav-link" role="tab" aria-controls="settings" aria-selected="false" tabindex="-1">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-.4-1.1 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H2.8a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.1-.4 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6c.38-.16.72-.4 1-.7.26-.3.4-.69.4-1.1V2.8a2 2 0 1 1 4 0v.1c0 .41.14.8.4 1.1.28.3.62.54 1 .7a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.16.38.4.72.7 1 .3.26.69.4 1.1.4h.1a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.1.4 1.7 1.7 0 0 0-.7 1z" />
</svg>
Settings
</a>
<a href="#firmware" id="tab-firmware" class="nav-link" role="tab" aria-controls="firmware" aria-selected="false" tabindex="-1">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3v10" />
<path d="m8 9 4 4 4-4" />
<rect x="4" y="17" width="16" height="4" rx="2" />
</svg>
Firmware Update
</a>
<a href="#files" id="tab-files" class="nav-link" role="tab" aria-controls="files" aria-selected="false" tabindex="-1">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v2H4Z" />
<path d="M4 11h16v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z" />
<path d="M8 15h8" />
</svg>
File Manager
</a>
</nav>
<div class="connection-badge connection-badge-${__status.online ? 'online' : 'offline'}" aria-label="connection active">
<span class="connection-label">${__status.online ? 'live' : 'offline'}</span>
<span class="connection-dot connection-dot-${__status.online ? 'online' : 'offline'}" aria-hidden="true"></span>
<body style="visibility: hidden;">
<section id="auth" style="display:none; min-height: 100vh; background: radial-gradient(900px 520px at 15% 0%, rgba(56, 189, 248, 0.18), rgba(56, 189, 248, 0)), radial-gradient(760px 460px at 85% 10%, rgba(45, 212, 191, 0.14), rgba(45, 212, 191, 0)), linear-gradient(180deg, #f8fafc, #def2f7);">
<div class="tab-stage" style="min-height: 100vh; display: flex; align-items: center; justify-content: center;">
<div class="panel" style="width: 100%; max-width: 28rem;">
<div class="panel-title">Dashboard Sign In</div>
<div class="panel-body" style="gap: 0.75rem;">
<form id="login-form" autocomplete="on" style="display: flex; flex-direction: column; gap: 0.75rem;">
<div class="field">
<label class="field-label" for="login-password">Password</label>
<input id="login-password" class="field-input" type="password" name="password" autocomplete="current-password" placeholder="••••••" />
</div>
<div id="login-error" class="note" aria-live="polite" style="display:none; background: #fee2e2; border-color: #fca5a5; color: #991b1b;"></div>
<div style="margin: 0; color: #999;">
Use "admin" or "user" to sign in.
</div>
<div style="display:flex; justify-content:flex-end;">
<button class="btn btn-primary" id="login-submit" type="submit">Login</button>
</div>
</form>
</div>
</div>
<div class="header-actions">
<nav class="nav-tabs">
<div class="nav-link">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>admin</span>
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
</div>
</nav>
</div>
</div>
</div>
<main class="tab-stage">
<section id="overview" class="tab-panel tab-panel-active" role="tabpanel" aria-labelledby="tab-overview">
<div class="metrics-grid" aria-label="Device metrics overview">
<article class="metric-tile">
<div class="metric-label">Temperature</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number" data-bind="metrics.temperature">24.8</span>
<span class="metric-unit">°C</span>
</div>
<span class="metric-status metric-status-good">ok</span>
</div>
</article>
<article class="metric-tile metric-tile-warning">
<div class="metric-label">CPU Load</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number" data-bind="metrics.cpu">37</span>
<span class="metric-unit">%</span>
</div>
<span class="metric-status metric-status-warn">moderate</span>
</div>
</article>
<article class="metric-tile metric-tile-alert">
<div class="metric-label">Free Memory</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number">${metrics.ram}</span>
<span class="metric-unit">KB</span>
</div>
<span class="metric-status metric-status-alert" style="${metrics.ram <= 30 ? '': 'display:none;'}">low</span>
<span class="metric-status metric-status-warn" style="${metrics.ram <= 50 && metrics.ram > 30 ? '': 'display:none;'}">ok-ish</span>
</div>
</article>
<article class="metric-tile">
<div class="metric-label">Volume</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number" data-bind="settings.volume">18.7</span>
<span class="metric-unit">dB</span>
</div>
<span class="metric-status metric-status-good">ok</span>
</div>
</article>
</div>
</section>
<div id="app">
<div class="header">
<div class="header-container">
<div class="header-left">
<nav class="nav-tabs" role="tablist" aria-label="Primary tabs">
<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 600" width="1200" height="600">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2dd4bf"/>
<stop offset="100%" stop-color="#0f766e"/>
</linearGradient>
</defs>
<g fill="none" stroke="url(#g)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round">
<rect x="160" y="60" width="300" height="300" rx="70"/>
<rect x="20" y="200" width="300" height="300" rx="70"/>
</g>
<text x="560" y="360"
font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
font-size="180"
font-weight="600"
fill="#6b7280">
MyLogo
</text>
</svg>
<a href="#overview" id="tab-overview" class="nav-link nav-link-active" role="tab" aria-controls="overview" aria-selected="true">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5 10.5V20h14v-9.5" />
</svg>
Overview
</a>
<a href="#settings" id="tab-settings" class="nav-link" role="tab" aria-controls="settings" aria-selected="false" tabindex="-1">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-.4-1.1 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H2.8a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.1-.4 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6c.38-.16.72-.4 1-.7.26-.3.4-.69.4-1.1V2.8a2 2 0 1 1 4 0v.1c0 .41.14.8.4 1.1.28.3.62.54 1 .7a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.16.38.4.72.7 1 .3.26.69.4 1.1.4h.1a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.1.4 1.7 1.7 0 0 0-.7 1z" />
</svg>
Settings
</a>
<a href="#firmware" id="tab-firmware" class="nav-link" role="tab" aria-controls="firmware" aria-selected="false" tabindex="-1">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3v10" />
<path d="m8 9 4 4 4-4" />
<rect x="4" y="17" width="16" height="4" rx="2" />
</svg>
Firmware Update
</a>
<a href="#files" id="tab-files" class="nav-link" role="tab" aria-controls="files" aria-selected="false" tabindex="-1">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v2H4Z" />
<path d="M4 11h16v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z" />
<path d="M8 15h8" />
</svg>
File Manager
</a>
</nav>
<div class="connection-badge connection-badge-${__status.online ? 'online' : 'offline'}" aria-label="connection active">
<span class="connection-label">${__status.online ? 'live' : 'offline'}</span>
<span class="connection-dot connection-dot-${__status.online ? 'online' : 'offline'}" aria-hidden="true"></span>
</div>
</div>
<div class="header-actions">
<nav class="nav-tabs">
<div class="nav-link">
<svg viewBox="0 0 24 24" class="nav-icon" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>${__status.username || 'admin'}</span>
<svg viewBox="0 0 24 24"
onclick="Dashboard.logout()"
style="cursor: pointer;"
class="nav-icon" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
</div>
</nav>
</div>
</div>
</div>
<main class="tab-stage">
<section id="overview" class="tab-panel tab-panel-active" role="tabpanel" aria-labelledby="tab-overview">
<div class="metrics-grid" aria-label="Device metrics overview">
<article class="metric-tile">
<div class="metric-label">Temperature</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number" data-bind="metrics.temperature">24.8</span>
<span class="metric-unit">°C</span>
</div>
<span class="metric-status metric-status-good">ok</span>
</div>
</article>
<article class="metric-tile metric-tile-warning">
<div class="metric-label">CPU Load</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number" data-bind="metrics.cpu">37</span>
<span class="metric-unit">%</span>
</div>
<span class="metric-status metric-status-warn">moderate</span>
</div>
</article>
<article class="metric-tile metric-tile-alert">
<div class="metric-label">Free Memory</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number">${metrics.ram}</span>
<span class="metric-unit">KB</span>
</div>
<span class="metric-status metric-status-alert" style="${metrics.ram <= 30 ? '': 'display:none;'}">low</span>
<span class="metric-status metric-status-warn" style="${metrics.ram <= 50 && metrics.ram > 30 ? '': 'display:none;'}">ok-ish</span>
</div>
</article>
<article class="metric-tile">
<div class="metric-label">Volume</div>
<div class="metric-value-row">
<div class="metric-value">
<span class="metric-number" data-bind="settings.volume">18.7</span>
<span class="metric-unit">dB</span>
</div>
<span class="metric-status metric-status-good">ok</span>
</div>
</article>
</div>
<div class="grid" style="grid-template-columns: 1fr 1fr;">
@@ -596,5 +677,6 @@ a, a:visited, a:link { text-decoration: none; }
</div>
</section>
</main>
</div>
</body>
</html>
@@ -11,6 +11,9 @@
debug: true,
data: { status: { led1: false, version: '1.0.0' } },
});
// Show dashboard only when all device data is reported
Dashboard.on('ready', () => document.body.style.visibility = 'visible');
</script>
<style>
@@ -27,8 +30,8 @@
</style>
</head>
<body>
<main style="margin: 2rem; color: #64748b;">
<body style="visibility: hidden;">
<main id="main" style="margin: 2rem; color: #64748b;">
<h1>LED control version ${status.version}</h1>
<div style="display: flex; gap: 1rem; ">