mirror of
https://github.com/cesanta/mongoose.git
synced 2026-10-14 10:20:01 +00:00
Dash updates
This commit is contained in:
1 parent
0c296a17f8
commit
aee72b3fbf
8 files changed
+541
-188
No files matched your search
+122
-12
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; ">
|
||||
|
||||
Reference in new issue
Block a user