mirror of
https://github.com/cesanta/mongoose.git
synced 2026-10-17 10:49:47 +00:00
Device dashboard refactoring
This commit is contained in:
1 parent
609a0bd905
commit
1045635d81
13 files changed
+624
-1081
No files matched your search
+125
-13
@@ -3170,7 +3170,8 @@ struct mg_field {
|
||||
struct mg_field_set {
|
||||
const char *name;
|
||||
struct mg_field *fields;
|
||||
void (*sync)(bool is_reading);
|
||||
void (*reader)(void);
|
||||
void (*writer)(void);
|
||||
int read_level;
|
||||
int write_level;
|
||||
};
|
||||
@@ -3179,20 +3180,29 @@ struct mg_dash_custom_handler {
|
||||
struct mg_dash_custom_handler *next;
|
||||
struct mg_str uri_pattern;
|
||||
mg_event_handler_t handler;
|
||||
void *handler_data;
|
||||
};
|
||||
|
||||
struct mg_dash_file {
|
||||
struct mg_dash_file *next;
|
||||
char *name;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
struct mg_dash {
|
||||
struct mg_field_set *sets;
|
||||
struct mg_dash_custom_handler *custom_handlers;
|
||||
struct mg_dash_file *files;
|
||||
};
|
||||
|
||||
#define MG_DASH_REGISTER_CUSTOM_HANDLER(dash_, uri_, fn_) \
|
||||
do { \
|
||||
static struct mg_dash_custom_handler ch_; \
|
||||
ch_.next = (dash_)->custom_handlers; \
|
||||
ch_.uri_pattern = mg_str(uri_); \
|
||||
ch_.handler = (fn_); \
|
||||
(dash_)->custom_handlers = &ch_; \
|
||||
#define MG_DASH_REGISTER_CUSTOM_HANDLER(dash_, uri_, fn_, data_) \
|
||||
do { \
|
||||
static struct mg_dash_custom_handler ch_; \
|
||||
ch_.next = (dash_)->custom_handlers; \
|
||||
ch_.uri_pattern = mg_str(uri_); \
|
||||
ch_.handler = (fn_); \
|
||||
ch_.handler_data = (data_); \
|
||||
(dash_)->custom_handlers = &ch_; \
|
||||
} while (0)
|
||||
|
||||
static inline struct mg_str trimq(struct mg_str s) { // Trim double quotes
|
||||
@@ -3298,14 +3308,14 @@ static inline size_t mg_dash_print_name(mg_pfn_t fn, void *arg, va_list *ap) {
|
||||
if (name->len == 0) {
|
||||
n += mg_xprintf(fn, arg, "{");
|
||||
for (i = 0; dash->sets != NULL && dash->sets[i].name != NULL; i++) {
|
||||
if (dash->sets[i].sync) dash->sets[i].sync(false);
|
||||
if (dash->sets[i].reader) dash->sets[i].reader();
|
||||
if (i > 0) n += mg_xprintf(fn, arg, ",");
|
||||
n += mg_xprintf(fn, arg, "%m:", MG_ESC(dash->sets[i].name));
|
||||
n += mg_xprintf(fn, arg, "%M", mg_print_field_set, &dash->sets[i]);
|
||||
}
|
||||
n += mg_xprintf(fn, arg, "}");
|
||||
} else if (set != NULL) {
|
||||
if (set->sync) set->sync(false);
|
||||
if (set->reader) set->reader();
|
||||
n += mg_xprintf(fn, arg, "%M", mg_print_field_set, set);
|
||||
} else {
|
||||
n += mg_xprintf(fn, arg, "null");
|
||||
@@ -3324,7 +3334,7 @@ static inline void mg_dash_send_change(struct mg_mgr *mgr, struct mg_dash *dash,
|
||||
const char *name) {
|
||||
struct mg_field_set *set = mg_dash_find_field_set(dash, mg_str(name));
|
||||
if (set != NULL) {
|
||||
if (set->sync) set->sync(false);
|
||||
if (set->reader) set->reader();
|
||||
mg_dash_broadcast(mgr, "{%m:%m,%m:{%m:%M}}", MG_ESC("method"),
|
||||
MG_ESC("change"), MG_ESC("params"), MG_ESC(set->name),
|
||||
mg_print_field_set, set);
|
||||
@@ -3365,8 +3375,8 @@ static inline int mg_dash_apply(struct mg_connection *c, struct mg_dash *dash,
|
||||
for (i = 0; set->fields[i].name != NULL; i++) {
|
||||
if (mg_dash_parse_field(val, &set->fields[i])) count++;
|
||||
}
|
||||
if (count > 0 && set->sync != NULL) {
|
||||
set->sync(true);
|
||||
if (count > 0 && set->writer != NULL) {
|
||||
set->writer();
|
||||
mg_dash_send_change(c->mgr, dash, set->name);
|
||||
}
|
||||
} else {
|
||||
@@ -3395,6 +3405,96 @@ static inline void mg_dash_process_msg(struct mg_connection *c,
|
||||
#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_upload_state {
|
||||
char marker; // Tells that we're a file upload connection
|
||||
size_t expected; // POST data length, bytes
|
||||
size_t received; // Already received bytes
|
||||
void *ctx; // OTA context
|
||||
};
|
||||
|
||||
static inline void mg_dash_file_add(struct mg_dash *dash, struct mg_str name,
|
||||
size_t size) {
|
||||
struct mg_dash_file *f = mg_calloc(1, sizeof(*f));
|
||||
f->name = mg_strdup(name).buf;
|
||||
f->size = size;
|
||||
f->next = dash->files;
|
||||
dash->files = f;
|
||||
}
|
||||
|
||||
static inline void mg_dash_file_del(struct mg_dash *dash, struct mg_str name) {
|
||||
struct mg_dash_file **head, *f;
|
||||
for (head = &dash->files, f = *head; f; head = &(*head)->next, f = *head) {
|
||||
if (mg_strcmp(mg_str(f->name), name) == 0) {
|
||||
MG_INFO(("Deleting %s", f->name));
|
||||
*head = f->next;
|
||||
mg_free(f->name);
|
||||
mg_free(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
|
||||
struct mg_upload_state *us = (struct mg_upload_state *) c->data;
|
||||
if ((ev == MG_EV_HTTP_HDRS || ev == MG_EV_HTTP_MSG) && us->marker == 0) {
|
||||
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
|
||||
if (hm->uri.len > 7) hm->uri.len -= 7, hm->uri.buf += 7; // Strip /files/
|
||||
if (sizeof(*us) > sizeof(c->data)) {
|
||||
mg_error(c,
|
||||
"FAILURE: sizeof(c->data) == %lu, need %lu."
|
||||
" Set #define MG_DATA_SIZE XXX",
|
||||
sizeof(c->data), sizeof(*us));
|
||||
} else if (mg_strcmp(hm->query, mg_str("action=delete")) == 0) {
|
||||
// Query string ?action=delete - file deletion
|
||||
mg_dash_file_del(c->fn_data, hm->uri);
|
||||
mg_dash_send_change(c->mgr, c->fn_data, "files");
|
||||
mg_http_reply(c, 200, NULL, "true");
|
||||
} else if (mg_path_is_sane(hm->uri) == false) {
|
||||
// Bad file name, return error. Protect from traversal, etc
|
||||
mg_http_reply(c, 400, NULL, "Bad name");
|
||||
} else if (mg_match(hm->method, mg_str("POST"), NULL) ||
|
||||
mg_match(hm->method, mg_str("PUT"), NULL)) {
|
||||
// File upload
|
||||
char path[128];
|
||||
mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf);
|
||||
us->ctx = fopen(path, "wb+");
|
||||
if (us->ctx == NULL) {
|
||||
mg_http_reply(c, 500, NULL, "File upload error %d\n", errno);
|
||||
} else {
|
||||
mg_dash_file_add(c->fn_data, hm->uri, hm->body.len);
|
||||
MG_DEBUG(("Starting upload, [%.*s] %lu", hm->uri.len, hm->uri.buf,
|
||||
hm->body.len));
|
||||
us->marker = 'U';
|
||||
us->expected = hm->body.len;
|
||||
us->received = 0;
|
||||
mg_iobuf_del(&c->recv, 0, hm->head.len);
|
||||
c->pfn = NULL;
|
||||
c->fn = file_ev_handler;
|
||||
mg_call(c, MG_EV_READ, &c->recv.len);
|
||||
}
|
||||
} else {
|
||||
// Serve file
|
||||
char path[128];
|
||||
mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf);
|
||||
mg_http_serve_file(c, ev_data, path, NULL);
|
||||
}
|
||||
} else if (ev == MG_EV_READ && us->marker == 'U') {
|
||||
// Write uploaded data
|
||||
MG_DEBUG(("Uploading.. recv.len=%u", c->recv.len));
|
||||
us->received += c->recv.len;
|
||||
fwrite(c->recv.buf, 1, c->recv.len, us->ctx);
|
||||
c->recv.len = 0;
|
||||
if (us->received >= us->expected) {
|
||||
fclose(us->ctx);
|
||||
MG_DEBUG(("Uploaded %lu", us->expected));
|
||||
mg_http_reply(c, 200, NULL, "%lu uploaded\n", us->expected);
|
||||
mg_dash_send_change(c->mgr, c->fn_data, "files");
|
||||
memset(us, 0, sizeof(*us));
|
||||
c->is_draining = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static inline void mg_dash_ev_handler(struct mg_connection *c, int ev,
|
||||
void *ev_data) {
|
||||
struct mg_dash *dash = (struct mg_dash *) c->fn_data;
|
||||
@@ -3404,6 +3504,8 @@ static inline void mg_dash_ev_handler(struct mg_connection *c, int ev,
|
||||
memset(parts, 0, sizeof(parts));
|
||||
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);
|
||||
} else if (mg_match(hm->uri, mg_str("/api/get/#"), parts) ||
|
||||
mg_match(hm->uri, mg_str("/api/get"), NULL)) {
|
||||
mg_http_reply(c, 200, MG_JSON_HEADERS, "%M\n", mg_dash_print_name, dash,
|
||||
@@ -3438,6 +3540,16 @@ static inline void mg_dash_ev_handler(struct mg_connection *c, int ev,
|
||||
}
|
||||
}
|
||||
|
||||
#if MG_ARCH == MG_ARCH_UNIX || MG_ARCH == MG_ARCH_WIN32
|
||||
#define MG_HTTP_ADDR "http://0.0.0.0:8000"
|
||||
#define MG_HTTPS_ADDR "https://0.0.0.0:8443"
|
||||
#define MG_MODBUS_ADDR "tcp://0.0.0.0:8502"
|
||||
#else
|
||||
#define MG_HTTP_ADDR "http://0.0.0.0:80"
|
||||
#define MG_HTTPS_ADDR "http://0.0.0.0:443"
|
||||
#define MG_MODBUS_ADDR "tcp://0.0.0.0:502"
|
||||
#endif
|
||||
|
||||
// Helper forward declarations for Mongoose CMSIS pack modules
|
||||
extern struct mg_mgr g_mgr;
|
||||
extern void mg_dash_init(struct mg_mgr *);
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
}
|
||||
}
|
||||
for (const attr of el.attributes) {
|
||||
const cv = decodeURIComponent(attr.value || '');
|
||||
const cv = attr.value || '';
|
||||
if (attr.originalValue || cv.includes('${')) {
|
||||
if (!attr.originalValue) attr.originalValue = cv;
|
||||
const v = substituteExpressions(attr.originalValue, context);
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@ async function processHtml(content) {
|
||||
processHtml(html).then(result => {
|
||||
if (outputFile) {
|
||||
fs.writeFileSync(outputFile, result);
|
||||
console.log(`Written to ${outputFile}`);
|
||||
//console.log(`Written to ${outputFile}`);
|
||||
} else {
|
||||
console.log(result);
|
||||
}
|
||||
|
||||
+125
-13
@@ -25,7 +25,8 @@ struct mg_field {
|
||||
struct mg_field_set {
|
||||
const char *name;
|
||||
struct mg_field *fields;
|
||||
void (*sync)(bool is_reading);
|
||||
void (*reader)(void);
|
||||
void (*writer)(void);
|
||||
int read_level;
|
||||
int write_level;
|
||||
};
|
||||
@@ -34,20 +35,29 @@ struct mg_dash_custom_handler {
|
||||
struct mg_dash_custom_handler *next;
|
||||
struct mg_str uri_pattern;
|
||||
mg_event_handler_t handler;
|
||||
void *handler_data;
|
||||
};
|
||||
|
||||
struct mg_dash_file {
|
||||
struct mg_dash_file *next;
|
||||
char *name;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
struct mg_dash {
|
||||
struct mg_field_set *sets;
|
||||
struct mg_dash_custom_handler *custom_handlers;
|
||||
struct mg_dash_file *files;
|
||||
};
|
||||
|
||||
#define MG_DASH_REGISTER_CUSTOM_HANDLER(dash_, uri_, fn_) \
|
||||
do { \
|
||||
static struct mg_dash_custom_handler ch_; \
|
||||
ch_.next = (dash_)->custom_handlers; \
|
||||
ch_.uri_pattern = mg_str(uri_); \
|
||||
ch_.handler = (fn_); \
|
||||
(dash_)->custom_handlers = &ch_; \
|
||||
#define MG_DASH_REGISTER_CUSTOM_HANDLER(dash_, uri_, fn_, data_) \
|
||||
do { \
|
||||
static struct mg_dash_custom_handler ch_; \
|
||||
ch_.next = (dash_)->custom_handlers; \
|
||||
ch_.uri_pattern = mg_str(uri_); \
|
||||
ch_.handler = (fn_); \
|
||||
ch_.handler_data = (data_); \
|
||||
(dash_)->custom_handlers = &ch_; \
|
||||
} while (0)
|
||||
|
||||
static inline struct mg_str trimq(struct mg_str s) { // Trim double quotes
|
||||
@@ -153,14 +163,14 @@ static inline size_t mg_dash_print_name(mg_pfn_t fn, void *arg, va_list *ap) {
|
||||
if (name->len == 0) {
|
||||
n += mg_xprintf(fn, arg, "{");
|
||||
for (i = 0; dash->sets != NULL && dash->sets[i].name != NULL; i++) {
|
||||
if (dash->sets[i].sync) dash->sets[i].sync(false);
|
||||
if (dash->sets[i].reader) dash->sets[i].reader();
|
||||
if (i > 0) n += mg_xprintf(fn, arg, ",");
|
||||
n += mg_xprintf(fn, arg, "%m:", MG_ESC(dash->sets[i].name));
|
||||
n += mg_xprintf(fn, arg, "%M", mg_print_field_set, &dash->sets[i]);
|
||||
}
|
||||
n += mg_xprintf(fn, arg, "}");
|
||||
} else if (set != NULL) {
|
||||
if (set->sync) set->sync(false);
|
||||
if (set->reader) set->reader();
|
||||
n += mg_xprintf(fn, arg, "%M", mg_print_field_set, set);
|
||||
} else {
|
||||
n += mg_xprintf(fn, arg, "null");
|
||||
@@ -179,7 +189,7 @@ static inline void mg_dash_send_change(struct mg_mgr *mgr, struct mg_dash *dash,
|
||||
const char *name) {
|
||||
struct mg_field_set *set = mg_dash_find_field_set(dash, mg_str(name));
|
||||
if (set != NULL) {
|
||||
if (set->sync) set->sync(false);
|
||||
if (set->reader) set->reader();
|
||||
mg_dash_broadcast(mgr, "{%m:%m,%m:{%m:%M}}", MG_ESC("method"),
|
||||
MG_ESC("change"), MG_ESC("params"), MG_ESC(set->name),
|
||||
mg_print_field_set, set);
|
||||
@@ -220,8 +230,8 @@ static inline int mg_dash_apply(struct mg_connection *c, struct mg_dash *dash,
|
||||
for (i = 0; set->fields[i].name != NULL; i++) {
|
||||
if (mg_dash_parse_field(val, &set->fields[i])) count++;
|
||||
}
|
||||
if (count > 0 && set->sync != NULL) {
|
||||
set->sync(true);
|
||||
if (count > 0 && set->writer != NULL) {
|
||||
set->writer();
|
||||
mg_dash_send_change(c->mgr, dash, set->name);
|
||||
}
|
||||
} else {
|
||||
@@ -250,6 +260,96 @@ static inline void mg_dash_process_msg(struct mg_connection *c,
|
||||
#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_upload_state {
|
||||
char marker; // Tells that we're a file upload connection
|
||||
size_t expected; // POST data length, bytes
|
||||
size_t received; // Already received bytes
|
||||
void *ctx; // OTA context
|
||||
};
|
||||
|
||||
static inline void mg_dash_file_add(struct mg_dash *dash, struct mg_str name,
|
||||
size_t size) {
|
||||
struct mg_dash_file *f = mg_calloc(1, sizeof(*f));
|
||||
f->name = mg_strdup(name).buf;
|
||||
f->size = size;
|
||||
f->next = dash->files;
|
||||
dash->files = f;
|
||||
}
|
||||
|
||||
static inline void mg_dash_file_del(struct mg_dash *dash, struct mg_str name) {
|
||||
struct mg_dash_file **head, *f;
|
||||
for (head = &dash->files, f = *head; f; head = &(*head)->next, f = *head) {
|
||||
if (mg_strcmp(mg_str(f->name), name) == 0) {
|
||||
MG_INFO(("Deleting %s", f->name));
|
||||
*head = f->next;
|
||||
mg_free(f->name);
|
||||
mg_free(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
|
||||
struct mg_upload_state *us = (struct mg_upload_state *) c->data;
|
||||
if ((ev == MG_EV_HTTP_HDRS || ev == MG_EV_HTTP_MSG) && us->marker == 0) {
|
||||
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
|
||||
if (hm->uri.len > 7) hm->uri.len -= 7, hm->uri.buf += 7; // Strip /files/
|
||||
if (sizeof(*us) > sizeof(c->data)) {
|
||||
mg_error(c,
|
||||
"FAILURE: sizeof(c->data) == %lu, need %lu."
|
||||
" Set #define MG_DATA_SIZE XXX",
|
||||
sizeof(c->data), sizeof(*us));
|
||||
} else if (mg_strcmp(hm->query, mg_str("action=delete")) == 0) {
|
||||
// Query string ?action=delete - file deletion
|
||||
mg_dash_file_del(c->fn_data, hm->uri);
|
||||
mg_dash_send_change(c->mgr, c->fn_data, "files");
|
||||
mg_http_reply(c, 200, NULL, "true");
|
||||
} else if (mg_path_is_sane(hm->uri) == false) {
|
||||
// Bad file name, return error. Protect from traversal, etc
|
||||
mg_http_reply(c, 400, NULL, "Bad name");
|
||||
} else if (mg_match(hm->method, mg_str("POST"), NULL) ||
|
||||
mg_match(hm->method, mg_str("PUT"), NULL)) {
|
||||
// File upload
|
||||
char path[128];
|
||||
mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf);
|
||||
us->ctx = fopen(path, "wb+");
|
||||
if (us->ctx == NULL) {
|
||||
mg_http_reply(c, 500, NULL, "File upload error %d\n", errno);
|
||||
} else {
|
||||
mg_dash_file_add(c->fn_data, hm->uri, hm->body.len);
|
||||
MG_DEBUG(("Starting upload, [%.*s] %lu", hm->uri.len, hm->uri.buf,
|
||||
hm->body.len));
|
||||
us->marker = 'U';
|
||||
us->expected = hm->body.len;
|
||||
us->received = 0;
|
||||
mg_iobuf_del(&c->recv, 0, hm->head.len);
|
||||
c->pfn = NULL;
|
||||
c->fn = file_ev_handler;
|
||||
mg_call(c, MG_EV_READ, &c->recv.len);
|
||||
}
|
||||
} else {
|
||||
// Serve file
|
||||
char path[128];
|
||||
mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf);
|
||||
mg_http_serve_file(c, ev_data, path, NULL);
|
||||
}
|
||||
} else if (ev == MG_EV_READ && us->marker == 'U') {
|
||||
// Write uploaded data
|
||||
MG_DEBUG(("Uploading.. recv.len=%u", c->recv.len));
|
||||
us->received += c->recv.len;
|
||||
fwrite(c->recv.buf, 1, c->recv.len, us->ctx);
|
||||
c->recv.len = 0;
|
||||
if (us->received >= us->expected) {
|
||||
fclose(us->ctx);
|
||||
MG_DEBUG(("Uploaded %lu", us->expected));
|
||||
mg_http_reply(c, 200, NULL, "%lu uploaded\n", us->expected);
|
||||
mg_dash_send_change(c->mgr, c->fn_data, "files");
|
||||
memset(us, 0, sizeof(*us));
|
||||
c->is_draining = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static inline void mg_dash_ev_handler(struct mg_connection *c, int ev,
|
||||
void *ev_data) {
|
||||
struct mg_dash *dash = (struct mg_dash *) c->fn_data;
|
||||
@@ -259,6 +359,8 @@ static inline void mg_dash_ev_handler(struct mg_connection *c, int ev,
|
||||
memset(parts, 0, sizeof(parts));
|
||||
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);
|
||||
} else if (mg_match(hm->uri, mg_str("/api/get/#"), parts) ||
|
||||
mg_match(hm->uri, mg_str("/api/get"), NULL)) {
|
||||
mg_http_reply(c, 200, MG_JSON_HEADERS, "%M\n", mg_dash_print_name, dash,
|
||||
@@ -293,6 +395,16 @@ static inline void mg_dash_ev_handler(struct mg_connection *c, int ev,
|
||||
}
|
||||
}
|
||||
|
||||
#if MG_ARCH == MG_ARCH_UNIX || MG_ARCH == MG_ARCH_WIN32
|
||||
#define MG_HTTP_ADDR "http://0.0.0.0:8000"
|
||||
#define MG_HTTPS_ADDR "https://0.0.0.0:8443"
|
||||
#define MG_MODBUS_ADDR "tcp://0.0.0.0:8502"
|
||||
#else
|
||||
#define MG_HTTP_ADDR "http://0.0.0.0:80"
|
||||
#define MG_HTTPS_ADDR "http://0.0.0.0:443"
|
||||
#define MG_MODBUS_ADDR "tcp://0.0.0.0:502"
|
||||
#endif
|
||||
|
||||
// Helper forward declarations for Mongoose CMSIS pack modules
|
||||
extern struct mg_mgr g_mgr;
|
||||
extern void mg_dash_init(struct mg_mgr *);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Environment setup: https://mongoose.ws/docs/getting-started/build-environment/
|
||||
|
||||
PROG ?= example
|
||||
MONGOOSE_DIR ?= ../..
|
||||
SOURCES = main.c mongoose_dashboard.c dashboard.html.c $(MONGOOSE_DIR)/mongoose.c
|
||||
CFLAGS ?= -W -Wall -Wextra -Werror -Wundef -Wshadow -g3 -O0
|
||||
PFLAGS = -I$(MONGOOSE_DIR) -DMG_DATA_SIZE=64 -DMG_TLS=MG_TLS_BUILTIN -DMG_ENABLE_LINES=1
|
||||
AFLAGS ?= -fsanitize=signed-integer-overflow,address,undefined,alignment
|
||||
|
||||
all: $(PROG)
|
||||
./$(PROG)
|
||||
|
||||
# Rebuild the program
|
||||
$(PROG): $(SOURCES) $(MONGOOSE_DIR)/mongoose.h
|
||||
$(CC) $(SOURCES) $(CFLAGS) $(PFLAGS) $(AFLAGS) -o $@
|
||||
|
||||
# Generate a single, self-contained dashboard.html from the UI in web_root/
|
||||
dashboard.html: $(wildcard web_root/*)
|
||||
node $(MONGOOSE_DIR)/resources/inline.js web_root/index.html -o $@
|
||||
|
||||
# Create an embedded filesystem containing compressed dashboard.html
|
||||
dashboard.html.c: dashboard.html
|
||||
node $(MONGOOSE_DIR)/resources/pack.js dashboard.html::gzip > $@
|
||||
@echo "On-flash size: `grep -oE '[0-9]+' $@ | wc -l`"
|
||||
|
||||
purge:
|
||||
curl -s https://purge.jsdelivr.net/gh/cesanta/mongoose@master/resources/dashboard.js
|
||||
|
||||
# For development: rebuild and restart if any of the sources change
|
||||
watch:
|
||||
node $(MONGOOSE_DIR)/resources/watch.js -w main.c -w mongoose_dashboard.c -w $(MONGOOSE_DIR)/mongoose.h -w web_root -- make
|
||||
|
||||
clean:
|
||||
rm -rf $(PROG) *.exe *.obj *.dSYM mongoose dashboard.*
|
||||
@@ -0,0 +1,31 @@
|
||||
# Environment setup: https://mongoose.ws/docs/getting-started/build-environment/
|
||||
|
||||
PROG ?= example
|
||||
TOP ?= ../../..
|
||||
SOURCES = main.c dashboard.c file_data.c $(TOP)/mongoose.c
|
||||
CFLAGS ?= -W -Wall -Wextra -Werror -Wundef -Wshadow -O0
|
||||
PFLAGS = -I$(TOP) -DMG_DATA_SIZE=64 -DMG_TLS=MG_TLS_BUILTIN -DMG_ENABLE_LINES=1
|
||||
AFLAGS ?= -fsanitize=signed-integer-overflow,address,undefined,alignment
|
||||
|
||||
all: $(PROG)
|
||||
./$(PROG)
|
||||
|
||||
# Rebuild the program
|
||||
$(PROG): $(SOURCES) $(TOP)/mongoose.h
|
||||
$(CC) $(SOURCES) $(CFLAGS) $(PFLAGS) $(AFLAGS) -o $@
|
||||
|
||||
# Create an embedded filesystem containing compressed dashboard.html
|
||||
file_data.c: dashboard.html
|
||||
node $(TOP)/resources/inline.js dashboard.html -o out.html
|
||||
node $(TOP)/resources/pack.js out.html:dashboard.html:gzip > file_data.c
|
||||
@echo "On-flash size: `grep -oE '[0-9]+' file_data.c | wc -l`"
|
||||
|
||||
purge:
|
||||
curl -s https://purge.jsdelivr.net/gh/cesanta/mongoose@master/resources/dashboard.js
|
||||
|
||||
# For development: rebuild and restart if any of the sources change
|
||||
watch:
|
||||
node $(TOP)/resources/watch.js -w main.c -w dashboard.c -w $(TOP)/mongoose.h -w dashboard.html -- make
|
||||
|
||||
clean:
|
||||
rm -rf $(PROG) *.exe *.obj *.dSYM mongoose out.* file_data.c
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-FileCopyrightText: 2026 Cesanta Software Limited
|
||||
// SPDX-License-Identifier: GPL-2.0-only or commercial
|
||||
|
||||
#include "mongoose.h"
|
||||
|
||||
// Control panel
|
||||
// s_led1, s_led2, s_led3 are used to communicate LED status
|
||||
static bool s_led1, s_led2, s_led3;
|
||||
|
||||
static struct mg_field fields_leds[] = {
|
||||
{"led1", MG_VAL_BOOL, &s_led1, sizeof(s_led1)},
|
||||
{"led2", MG_VAL_BOOL, &s_led2, sizeof(s_led2)},
|
||||
{"led3", MG_VAL_BOOL, &s_led3, sizeof(s_led3)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
static void write_leds(void) {
|
||||
// gpio_write(LED1_PIN, s_led1);
|
||||
// gpio_write(LED2_PIN, s_led2);
|
||||
// gpio_write(LED3_PIN, s_led3);
|
||||
}
|
||||
|
||||
static void read_leds(void) {
|
||||
// s_led1 = gpio_read(LED1_PIN);
|
||||
// s_led2 = gpio_read(LED2_PIN);
|
||||
// s_led3 = gpio_read(LED3_PIN);
|
||||
}
|
||||
|
||||
// Read-only device Metrics
|
||||
static int s_ram = 32, s_cpu = 7;
|
||||
static double s_temperature = 24.8;
|
||||
|
||||
static struct mg_field fields_metrics[] = {
|
||||
{"ram", MG_VAL_INT, &s_ram, sizeof(s_ram)},
|
||||
{"cpu", MG_VAL_INT, &s_cpu, sizeof(s_cpu)},
|
||||
{"temperature", MG_VAL_DBL, &s_temperature, sizeof(s_temperature)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
static void read_metrics(void) {
|
||||
s_ram = 25 + (rand() % 16); // Sumulate metric change
|
||||
s_cpu = 7 + (rand() % 21);
|
||||
s_temperature = 14.8 + ((double) rand() / RAND_MAX) * 20.0;
|
||||
}
|
||||
|
||||
// Read-write device settings
|
||||
static double s_volume = 17.2;
|
||||
static int s_log_level = MG_LL_DEBUG;
|
||||
static char s_name[20] = "Dublin";
|
||||
|
||||
static struct mg_field fields_settings[] = {
|
||||
{"volume", MG_VAL_DBL, &s_volume, sizeof(s_volume)},
|
||||
{"name", MG_VAL_STR, &s_name, sizeof(s_name)},
|
||||
{"log_level", MG_VAL_INT, &s_log_level, sizeof(s_log_level)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
static void write_settings(void) {
|
||||
mg_log_level = s_log_level;
|
||||
}
|
||||
|
||||
static void read_settings(void) {
|
||||
s_log_level = mg_log_level;
|
||||
}
|
||||
|
||||
#define NUM_POINTS 7 // How many data points to send
|
||||
|
||||
struct point {
|
||||
uint16_t x; // X coordinate of the data point, usually this is time
|
||||
uint16_t y1; // We can plot multiple values per graph,
|
||||
uint16_t y2; // let's plot 2 values: y1 and y2.
|
||||
};
|
||||
|
||||
// Serialise chart data into a string: [[x,y1,y2],[x,y1,y2],...]
|
||||
// One uint16_t takes 5 character max, so one entry is 19 bytes max.
|
||||
// We could be more efficient by storing hex or base64-encoded data, but
|
||||
// let's keep it simple. However if we do, update data parsing logic in
|
||||
// the web_root/main.js
|
||||
#define POINT_SIZE 19
|
||||
static char s_chart1_data[NUM_POINTS * POINT_SIZE + 2 + 1];
|
||||
|
||||
static struct mg_field fields_chart1[] = {
|
||||
{"data", MG_VAL_RAW, s_chart1_data, sizeof(s_chart1_data)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
// Randomly generate graph points and serialise them into a string
|
||||
void read_chart1(void) {
|
||||
size_t i, len = 0;
|
||||
len += mg_snprintf(s_chart1_data + len, sizeof(s_chart1_data) - len, "[");
|
||||
for (i = 0; i < NUM_POINTS; i++) {
|
||||
struct point p;
|
||||
p.x = (uint16_t) i;
|
||||
p.y1 = 85 - (uint16_t) ((49 * i) / NUM_POINTS) + (rand() % 9);
|
||||
p.y2 = 20 + (uint16_t) ((69 * i) / NUM_POINTS) + (rand() % 5);
|
||||
len += mg_snprintf(s_chart1_data + len, sizeof(s_chart1_data) - len,
|
||||
"%s[%hu,%hu,%hu]", i > 0 ? "," : "", p.x, p.y1, p.y2);
|
||||
}
|
||||
len += mg_snprintf(s_chart1_data + len, sizeof(s_chart1_data) - len, "]");
|
||||
}
|
||||
|
||||
// Files array: [{"name": "foo.txt", "size": 1234}]
|
||||
static char s_files[1024];
|
||||
static struct mg_field fields_files[] = {
|
||||
{"data", MG_VAL_RAW, s_files, sizeof(s_files)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
static struct mg_dash s_dashboard;
|
||||
static void read_files(void) {
|
||||
size_t len = 0;
|
||||
struct mg_dash_file *f;
|
||||
len += mg_snprintf(s_files + len, sizeof(s_files) - len, "[");
|
||||
for (f = s_dashboard.files; f; f = f->next) {
|
||||
len += mg_snprintf(s_files + len, sizeof(s_files) - len, "%s{%m:%m,%m:%u}",
|
||||
len > 1 ? "," : "", MG_ESC("name"), MG_ESC(f->name),
|
||||
MG_ESC("size"), f->size);
|
||||
}
|
||||
len += mg_snprintf(s_files + len, sizeof(s_files) - len, "]");
|
||||
}
|
||||
|
||||
// Each field set exports REST HTTP API /api/NAME
|
||||
static struct mg_field_set field_sets[] = {
|
||||
{"leds", fields_leds, read_leds, write_leds, 0, 0},
|
||||
{"metrics", fields_metrics, read_metrics, NULL, 0, 0},
|
||||
{"settings", fields_settings, read_settings, write_settings, 0, 0},
|
||||
{"chart1", fields_chart1, read_chart1, NULL, 0, 0},
|
||||
{"files", fields_files, read_files, NULL, 0, 0},
|
||||
{0, 0, NULL, NULL, 0, 0},
|
||||
};
|
||||
static struct mg_dash s_dashboard = {field_sets, NULL, NULL};
|
||||
|
||||
void mg_dash_init(struct mg_mgr *mgr) {
|
||||
mg_dash_file_add(&s_dashboard, mg_str("device-config.json"), 1234);
|
||||
mg_dash_file_add(&s_dashboard, mg_str("device-log-2026-04-25.txt"), 1327854);
|
||||
mg_http_listen(mgr, MG_HTTP_ADDR, mg_dash_ev_handler, &s_dashboard);
|
||||
}
|
||||
|
||||
void mg_dash_poll(struct mg_mgr *mgr) {
|
||||
// Send metrics change periodically
|
||||
static uint64_t timer = 0;
|
||||
if (mg_timer_expired(&timer, 30000, mg_now())) {
|
||||
mg_dash_send_change(mgr, &s_dashboard, "metrics");
|
||||
}
|
||||
}
|
||||
+197
-3
@@ -18,8 +18,184 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="main.js" defer></script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const tabs = Array.from(document.querySelectorAll(".header-left .nav-link[href^='#']"));
|
||||
const panels = Array.from(document.querySelectorAll(".tab-panel"));
|
||||
|
||||
let chart = null;
|
||||
|
||||
const activateTab = targetId => {
|
||||
const targetPanel = document.getElementById(targetId);
|
||||
if (!targetPanel) return;
|
||||
|
||||
tabs.forEach(tab => {
|
||||
const isActive = tab.getAttribute("href") === `#${targetId}`;
|
||||
tab.classList.toggle("nav-link-active", isActive);
|
||||
tab.setAttribute("aria-selected", String(isActive));
|
||||
tab.setAttribute("tabindex", isActive ? "0" : "-1");
|
||||
});
|
||||
|
||||
panels.forEach(panel => {
|
||||
const isActive = panel.id === targetId;
|
||||
panel.classList.toggle("tab-panel-active", isActive);
|
||||
panel.hidden = !isActive;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) rect.height = rect.width = 10;
|
||||
|
||||
if (!chart) {
|
||||
options.width = rect.width;
|
||||
options.height = rect.height;
|
||||
chart = new uPlot(options, [], el);
|
||||
} else {
|
||||
chart.setSize({ width: rect.width, height: rect.height });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const targetId = tab.getAttribute("href").slice(1);
|
||||
activateTab(targetId);
|
||||
window.history.replaceState(null, "", `#${targetId}`);
|
||||
});
|
||||
});
|
||||
|
||||
const initialId = window.location.hash.slice(1) || "overview";
|
||||
activateTab(initialId);
|
||||
|
||||
|
||||
const el = document.getElementById("chart");
|
||||
|
||||
const options = {
|
||||
width: 100,
|
||||
height: 100,
|
||||
tzDate: (ts) => uPlot.tzDate(new Date(ts * 1e3), "Europe/London"),
|
||||
plugins: [],
|
||||
legend: { show: false },
|
||||
axes: [
|
||||
{ values: "{MMM} {DD}", size: 25 },
|
||||
{ size: 50, xvalues: (u, vals, space) => vals.map(v => (+v / 1000).toFixed(1) + 'k') },
|
||||
],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { range: [0, 100] },
|
||||
},
|
||||
series: [
|
||||
{},
|
||||
{stroke: 'rgba(5, 141, 199, 1)', fill: 'rgba(5, 141, 199, 0.1)'},
|
||||
{stroke: 'rgba(237, 126, 23, 1)', fill: 'rgba(237, 126, 23, 0.1)'},
|
||||
],
|
||||
};
|
||||
|
||||
function updateGraphPoints(data) {
|
||||
if (!data) return;
|
||||
// const parsed = JSON.parse(data);
|
||||
const parsed = data || [];
|
||||
const uplot_data = [
|
||||
parsed.map(x => x[0]),
|
||||
parsed.map(x => x[1]),
|
||||
parsed.map(x => x[2]),
|
||||
];
|
||||
chart.setData(uplot_data);
|
||||
};
|
||||
|
||||
// Intercept change notifications
|
||||
Dashboard.on('change', args => updateGraphPoints(args?.chart1?.data));
|
||||
Dashboard.call('get', 'chart1').then(resp => updateGraphPoints(resp?.data));
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
:root { --color-surface-sunken: #e9eff5; --color-border-default: #d8e0ea; --color-text-toolbar: rgb(71, 85, 105); --color-text-strong: #24364d; --color-accent-border: #bae6fd; --color-accent-text: #0369a1; --color-surface-elevated: #ffffff; --font-size-body: 14px; --font-size-toolbar: 1rem; --max-width: 80rem; --spacing-1: 0.25rem; --spacing-2: 0.5rem; --spacing-3: 0.75rem; --spacing-4: 1rem; --spacing-5: 1.25rem; --spacing-6: 1.5rem; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: var(--font-size-body); line-height: 1.5; min-height: 100vh; color: var(--color-text-strong); }
|
||||
a, a:visited, a:link { text-decoration: none; }
|
||||
.header { z-index: 50; }
|
||||
.header-container { max-width: var(--max-width); margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-4); padding: var(--spacing-3) var(--spacing-6); }
|
||||
.header-left { display: flex; align-items: center; gap: var(--spacing-4); }
|
||||
.header-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: var(--spacing-3); flex-wrap: wrap; }
|
||||
.logo { height: 2.8rem; margin-right: 1rem; width: auto; }
|
||||
.connection-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 4px 14px; border-radius: 14px; font-weight: 600; }
|
||||
.connection-badge-online { color: rgb(4, 120, 87); border: 1px solid rgb(167, 243, 208); background: rgb(236, 253, 245); }
|
||||
.connection-badge-offline { color: rgb(120, 44, 57); border: 1px solid rgb(243, 177, 208); background: rgb(253, 246, 245); }
|
||||
.connection-dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; }
|
||||
.connection-dot-online { background: rgb(16, 185, 129); }
|
||||
.connection-dot-offline { background: rgb(185, 96, 109); }
|
||||
.nav-tabs { display: inline-flex; align-items: center; gap: var(--spacing-2); background: rgb(248, 250, 252); border: 1px solid rgb(226, 232, 240); padding: var(--spacing-1) var(--spacing-2); border-radius: 1rem; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 2px 4px 0px inset; }
|
||||
.nav-link { display: inline-flex; align-items: center; border-radius: 0.75rem; border: 1px solid transparent; padding: var(--spacing-2) var(--spacing-4); font-weight: 600; letter-spacing: -0.01em; color: var(--color-text-toolbar); background-color: transparent; transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); gap: var(--spacing-2); text-decoration: none; white-space: nowrap; }
|
||||
.nav-link-active { color: rgb(3, 105, 161); background: #fff; border: 1px solid rgb(186, 230, 253); }
|
||||
.nav-icon { height: 1rem; width: 1rem; flex-shrink: 0; }
|
||||
.tab-stage { max-width: var(--max-width); margin: 0 auto; padding: 0.5rem var(--spacing-6) var(--spacing-4); }
|
||||
.tab-panel { min-height: calc(100vh - 6rem); }
|
||||
.overview-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1rem; }
|
||||
.metric-tile { padding: 0.75rem 1.5rem 0.75rem; border-radius: 1rem; background: #ffffff; border: 1px solid rgba(203, 213, 225, 0.75); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 1), 0 2px 6px rgba(148, 163, 184, 0.1), 0 8px 24px rgba(148, 163, 184, 0.08); display: flex; flex-direction: column; gap: 0.35rem; transition: box-shadow 0.15s ease, transform 0.15s ease; }
|
||||
.metric-tile:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 1), 0 4px 12px rgba(148, 163, 184, 0.18), 0 12px 32px rgba(148, 163, 184, 0.12); transform: translateY(-1px); }
|
||||
.metric-label { color: #94a3b8; font-size: 0.9rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.metric-value-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||
.metric-value { display: flex; align-items: baseline; gap: 0.2rem; color: #475569; }
|
||||
.metric-number { font-size: 2rem; font-weight: 700; letter-spacing: -0.04em; line-height: 1.1; }
|
||||
.metric-unit { font-size: 1rem; font-weight: 600; color: #cbd5e1; letter-spacing: -0.01em; }
|
||||
.metric-status { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; padding: 0.18rem 0.52rem; border-radius: 0.4rem; border: 1px solid transparent; white-space: nowrap; }
|
||||
.metric-status-good { color: #166534; background: #dcfce7; border-color: #86efac; }
|
||||
.metric-status-warn { color: #92400e; background: #fef3c7; border-color: #fcd34d; }
|
||||
.metric-status-alert { color: #991b1b; background: #fee2e2; border-color: #fca5a5; }
|
||||
.tab-panel[hidden] { display: none; }
|
||||
.panel-body { display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem 1.25rem; flex: 1; }
|
||||
.panel-footer { display: flex; align-items: center; justify-content: flex-end; gap: 0.75rem; padding: 0.75rem 1.25rem; border-top: 1px solid #e2e8f0; }
|
||||
.field { display: flex; flex-direction: row; justify-content: space-between; align-items: center; gap: 0.75rem; }
|
||||
.field-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: #94a3b8; flex-shrink: 0; white-space: nowrap; min-width: 6rem; }
|
||||
.field-input { flex: 1; min-width: 0; padding: 0.45rem 0.75rem; font-size: 0.88rem; font-family: inherit; color: #334155; border: 1px solid #e2e8f0; border-radius: 0.55rem; outline: none; transition: border-color 0.15s ease; }
|
||||
.field-input.edited { border-color: #00500040 !important; background: #00ff0020; }
|
||||
.field-input.error { border-color: #50000040; background: #ff000020; }
|
||||
.field-input::placeholder { color: #cbd5e1; }
|
||||
.field-input:hover { border-color: #cbd5e1; }
|
||||
.field-input:focus { border-color: #7dd3fc; }
|
||||
.field-input:disabled, .field-input.disabled { color: #cbd5e1; border-color: #e2e8f0; cursor: not-allowed; pointer-events: none; }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; height: 2rem; padding: 0.3rem 1rem; font-size: 0.85rem; font-family: inherit; font-weight: 600; border-radius: 0.5rem; border: 1px solid transparent; cursor: pointer; transition: all 0.15s ease; white-space: nowrap; }
|
||||
.btn-secondary { color: #64748b; background: #ffffff; border-color: #e2e8f0; box-shadow: 0 1px 2px rgba(148, 163, 184, 0.1); }
|
||||
.btn-secondary:hover { background: #f1f5f9; border-color: #cbd5e1; color: #475569; }
|
||||
.btn-primary { color: #ffffff; background: linear-gradient(160deg, #38bdf8, #0ea5e9); border-color: #0284c7; box-shadow: 0 1px 3px rgba(14, 165, 233, 0.3), 0 4px 10px rgba(14, 165, 233, 0.15); }
|
||||
.btn-primary:hover { background: linear-gradient(160deg, #0ea5e9, #0284c7); box-shadow: 0 1px 3px rgba(14, 165, 233, 0.4), 0 6px 14px rgba(14, 165, 233, 0.2); }
|
||||
.btn-primary:active, .btn-secondary:active { transform: translateY(0); }
|
||||
.btn:disabled, .btn.disabled { opacity: 0.45; cursor: not-allowed; pointer-events: none; box-shadow: none; transform: none; }
|
||||
.note { color: #075985; background: #e0f2fe; border: 1px solid #7dd3fc; border-radius: 0.5rem; padding: 0.75rem 0.9rem; font-size: 0.86rem; line-height: 1.55; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
#files .row { align-items: stretch; flex-wrap: nowrap; }
|
||||
#files .panel { flex: 0 0 40rem; width: 40rem; }
|
||||
#files .note { flex: 1 1 0; min-width: 0; }
|
||||
.detail-row { display: grid; grid-template-columns: 1fr 300px; gap: 1rem; margin-top: 1rem; align-items: stretch; }
|
||||
.panel { background: #ffffff; border: 1px solid rgba(203, 213, 225, 0.75); border-radius: 0.75rem; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 1), 0 2px 6px rgba(148, 163, 184, 0.1), 0 8px 24px rgba(148, 163, 184, 0.08); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.panel-title { display: flex; align-items: center; justify-content: space-between; font-size: 0.95rem; font-weight: 700; color: #334155; padding: 0 1rem; min-height: 3rem; border-bottom: 1px solid #e2e8f0; flex-shrink: 0; }
|
||||
.file-list { display: flex; flex-direction: column; }
|
||||
.file-list:empty::before { content: "No Files"; color: #888; }
|
||||
.file-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 1rem; padding: 0.82rem 0; border-top: 1px solid #edf2f7; }
|
||||
.file-row:first-child { border-top: none; }
|
||||
.file-info { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
||||
.file-icon { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; color: #0369a1; background: #e0f2fe; border: 1px solid #bae6fd; border-radius: 0.65rem; flex-shrink: 0; }
|
||||
.file-icon svg { width: 1.15rem; height: 1.15rem; }
|
||||
.file-name { min-width: 0; color: #334155; font-size: 0.9rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-size { color: #94a3b8; font-size: 0.8rem; font-weight: 700; text-align: right; white-space: nowrap; }
|
||||
.file-actions { display: flex; align-items: center; justify-content: flex-end; gap: 0.45rem; }
|
||||
#chart { width: 100%; height: 12rem; }
|
||||
.chart-legend { display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
|
||||
.legend-item { display: flex; align-items: center; gap: 0.35rem; font-size: 0.75rem; color: #5d7088; font-weight: 600; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.legend-dot-color2 { background: rgba(237, 126, 23, 1); }
|
||||
.legend-dot-color1 { background: rgba(5, 141, 199, 1); }
|
||||
.toggle { position: relative; width: 2.75rem; height: 1.6rem; margin: 0; vertical-align: top; border: 1px solid transparent; background: #d1d5db; border-radius: 0.75rem; outline: none; cursor: pointer; -webkit-appearance: none; -moz-appearance: none; appearance: none; }
|
||||
.toggle::after { content: ""; position: absolute; left: 1px; top: 1px; width: 1.35rem; height: 1.35rem; background: #fff; border-radius: 50%; transition: all 0.3s; }
|
||||
.toggle:checked { background: #d1d5db; }
|
||||
.toggle:checked::after { transform: translateX(calc(100% - 3px)); }
|
||||
.toggle:disabled, .toggle.disabled { background: #d1d5db; cursor: not-allowed; }
|
||||
.toggle.edited { border-color: #0fe; }
|
||||
@media (max-width: 900px) { .detail-row { 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) { .overview-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 480px) { .overview-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; } }
|
||||
</style>
|
||||
|
||||
<!-- uPlot graph library -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.min.css">
|
||||
@@ -33,7 +209,25 @@
|
||||
<div class="header-container">
|
||||
<div class="header-left">
|
||||
<nav class="nav-tabs" role="tablist" aria-label="Primary tabs">
|
||||
<img src="logo.svg" alt="Logo" class="logo" />
|
||||
<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" />
|
||||
File renamed without changes.
@@ -1,245 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Cesanta Software Limited
|
||||
// SPDX-License-Identifier: GPL-2.0-only or commercial
|
||||
|
||||
#include "mongoose.h"
|
||||
|
||||
#if MG_ARCH == MG_ARCH_UNIX || MG_ARCH == MG_ARCH_WIN32
|
||||
#define HTTP_ADDR "http://0.0.0.0:8000"
|
||||
#else
|
||||
#define HTTP_ADDR "http://0.0.0.0:80"
|
||||
#endif
|
||||
|
||||
// Control panel
|
||||
static bool s_led1, s_led2, s_led3;
|
||||
|
||||
static struct mg_field fields_leds[] = {
|
||||
{"led1", MG_VAL_BOOL, &s_led1, sizeof(s_led1)},
|
||||
{"led2", MG_VAL_BOOL, &s_led2, sizeof(s_led2)},
|
||||
{"led3", MG_VAL_BOOL, &s_led3, sizeof(s_led3)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
static void sync_leds(bool is_write) {
|
||||
if (is_write) {
|
||||
// gpio_write(LED1_PIN, s_led1);
|
||||
// gpio_write(LED2_PIN, s_led2);
|
||||
// gpio_write(LED3_PIN, s_led3);
|
||||
} else {
|
||||
// s_led1 = gpio_read(LED1_PIN);
|
||||
// s_led2 = gpio_read(LED2_PIN);
|
||||
// s_led3 = gpio_read(LED3_PIN);
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only device Metrics
|
||||
static int s_ram = 32, s_cpu = 7;
|
||||
static double s_temperature = 24.8;
|
||||
|
||||
static struct mg_field fields_metrics[] = {
|
||||
{"ram", MG_VAL_INT, &s_ram, sizeof(s_ram)},
|
||||
{"cpu", MG_VAL_INT, &s_cpu, sizeof(s_cpu)},
|
||||
{"temperature", MG_VAL_DBL, &s_temperature, sizeof(s_temperature)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
static void sync_metrics(bool is_write) {
|
||||
if (is_write) return;
|
||||
s_ram = 25 + (rand() % 16); // Sumulate metric change
|
||||
s_cpu = 7 + (rand() % 21);
|
||||
s_temperature = 14.8 + ((double) rand() / RAND_MAX) * 20.0;
|
||||
}
|
||||
|
||||
// Read-write device settings
|
||||
static double s_volume = 17.2;
|
||||
static int s_log_level = MG_LL_DEBUG;
|
||||
static char s_name[20] = "Dublin";
|
||||
|
||||
static struct mg_field fields_settings[] = {
|
||||
{"volume", MG_VAL_DBL, &s_volume, sizeof(s_volume)},
|
||||
{"name", MG_VAL_STR, &s_name, sizeof(s_name)},
|
||||
{"log_level", MG_VAL_INT, &s_log_level, sizeof(s_log_level)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
static void sync_settings(bool is_write) {
|
||||
if (is_write) {
|
||||
mg_log_level = s_log_level;
|
||||
} else {
|
||||
s_log_level = mg_log_level;
|
||||
}
|
||||
}
|
||||
|
||||
#define NUM_POINTS 7 // How many data points to send
|
||||
|
||||
struct point {
|
||||
uint16_t x; // X coordinate of the data point, usually this is time
|
||||
uint16_t y1; // We can plot multiple values per graph,
|
||||
uint16_t y2; // let's plot 2 values: y1 and y2.
|
||||
};
|
||||
|
||||
// Serialise chart data into a string: [[x,y1,y2],[x,y1,y2],...]
|
||||
// One uint16_t takes 5 character max, so one entry is 19 bytes max.
|
||||
// We could be more efficient by storing hex or base64-encoded data, but
|
||||
// let's keep it simple. However if we do, update data parsing logic in
|
||||
// the web_root/main.js
|
||||
#define POINT_SIZE 19
|
||||
static char s_chart1_data[NUM_POINTS * POINT_SIZE + 2 + 1];
|
||||
|
||||
static struct mg_field fields_chart1[] = {
|
||||
{"data", MG_VAL_RAW, s_chart1_data, sizeof(s_chart1_data)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
|
||||
// Randomly generate graph points and serialise them into a string
|
||||
void sync_chart1(bool is_write) {
|
||||
size_t i, len = 0;
|
||||
if (is_write) return;
|
||||
len += mg_snprintf(s_chart1_data + len, sizeof(s_chart1_data) - len, "[");
|
||||
for (i = 0; i < NUM_POINTS; i++) {
|
||||
struct point p;
|
||||
p.x = (uint16_t) i;
|
||||
p.y1 = 85 - (uint16_t) ((49 * i) / NUM_POINTS) + (rand() % 9);
|
||||
p.y2 = 20 + (uint16_t) ((69 * i) / NUM_POINTS) + (rand() % 5);
|
||||
len += mg_snprintf(s_chart1_data + len, sizeof(s_chart1_data) - len,
|
||||
"%s[%hu,%hu,%hu]", i > 0 ? "," : "", p.x, p.y1, p.y2);
|
||||
}
|
||||
len += mg_snprintf(s_chart1_data + len, sizeof(s_chart1_data) - len, "]");
|
||||
}
|
||||
|
||||
struct file {
|
||||
struct file *next;
|
||||
char *name;
|
||||
size_t size;
|
||||
} *s_file_list;
|
||||
|
||||
static void file_add(struct mg_str name, size_t size) {
|
||||
struct file *f = mg_calloc(1, sizeof(*f));
|
||||
f->name = mg_strdup(name).buf;
|
||||
f->size = size;
|
||||
f->next = s_file_list;
|
||||
s_file_list = f;
|
||||
}
|
||||
|
||||
static void file_del(struct mg_str name) {
|
||||
struct file **head, *f;
|
||||
for (head = &s_file_list, f = *head; f; head = &(*head)->next, f = *head) {
|
||||
if (mg_strcmp(mg_str(f->name), name) == 0) {
|
||||
MG_INFO(("Deleting %s", f->name));
|
||||
*head = f->next;
|
||||
mg_free(f->name);
|
||||
mg_free(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files array: [{"name": "foo.txt", "size": 1234}]
|
||||
static char s_files[1024];
|
||||
static struct mg_field fields_files[] = {
|
||||
{"data", MG_VAL_RAW, s_files, sizeof(s_files)},
|
||||
{NULL, 0, NULL, 0},
|
||||
};
|
||||
static void sync_files(bool is_write) {
|
||||
size_t len = 0;
|
||||
struct file *f;
|
||||
if (is_write) return;
|
||||
len += mg_snprintf(s_files + len, sizeof(s_files) - len, "[");
|
||||
for (f = s_file_list; f; f = f->next) {
|
||||
len += mg_snprintf(s_files + len, sizeof(s_files) - len, "%s{%m:%m,%m:%u}",
|
||||
len > 1 ? "," : "", MG_ESC("name"), MG_ESC(f->name),
|
||||
MG_ESC("size"), f->size);
|
||||
}
|
||||
len += mg_snprintf(s_files + len, sizeof(s_files) - len, "]");
|
||||
}
|
||||
|
||||
static struct mg_field_set field_sets[] = {
|
||||
{"leds", fields_leds, sync_leds, 0, 0},
|
||||
{"metrics", fields_metrics, sync_metrics, 0, 0},
|
||||
{"settings", fields_settings, sync_settings, 0, 0},
|
||||
{"chart1", fields_chart1, sync_chart1, 0, 0},
|
||||
{"files", fields_files, sync_files, 0, 0},
|
||||
{0, 0, 0, 0, 0},
|
||||
};
|
||||
static struct mg_dash s_dashboard = {field_sets, NULL};
|
||||
|
||||
struct upload_state {
|
||||
char marker; // Tells that we're a file upload connection
|
||||
size_t expected; // POST data length, bytes
|
||||
size_t received; // Already received bytes
|
||||
void *ctx; // OTA context
|
||||
};
|
||||
|
||||
static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
|
||||
struct upload_state *us = (struct upload_state *) c->data;
|
||||
if ((ev == MG_EV_HTTP_HDRS || ev == MG_EV_HTTP_MSG) && us->marker == 0) {
|
||||
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
|
||||
if (hm->uri.len > 7) hm->uri.len -= 7, hm->uri.buf += 7; // Strip /files/
|
||||
if (sizeof(*us) > sizeof(c->data)) {
|
||||
mg_error(c,
|
||||
"FAILURE: sizeof(c->data) == %lu, need %lu."
|
||||
" Set #define MG_DATA_SIZE XXX",
|
||||
sizeof(c->data), sizeof(*us));
|
||||
} else if (mg_strcmp(hm->query, mg_str("action=delete")) == 0) {
|
||||
// Query string ?action=delete - file deletion
|
||||
file_del(hm->uri);
|
||||
mg_dash_send_change(c->mgr, &s_dashboard, "files");
|
||||
mg_http_reply(c, 200, NULL, "true");
|
||||
} else if (mg_path_is_sane(hm->uri) == false) {
|
||||
// Bad file name, return error. Protect from traversal, etc
|
||||
mg_http_reply(c, 400, NULL, "Bad name");
|
||||
} else if (mg_match(hm->method, mg_str("POST"), NULL) ||
|
||||
mg_match(hm->method, mg_str("PUT"), NULL)) {
|
||||
// File upload
|
||||
char path[128];
|
||||
mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf);
|
||||
us->ctx = fopen(path, "wb+");
|
||||
if (us->ctx == NULL) {
|
||||
mg_http_reply(c, 500, NULL, "File upload error %d\n", errno);
|
||||
} else {
|
||||
file_add(hm->uri, hm->body.len);
|
||||
MG_DEBUG(("Starting upload, [%.*s] %lu", hm->uri.len, hm->uri.buf, hm->body.len));
|
||||
us->marker = 'U';
|
||||
us->expected = hm->body.len;
|
||||
us->received = 0;
|
||||
mg_iobuf_del(&c->recv, 0, hm->head.len);
|
||||
c->pfn = NULL;
|
||||
c->fn = file_ev_handler;
|
||||
mg_call(c, MG_EV_READ, &c->recv.len);
|
||||
}
|
||||
} else {
|
||||
// Serve file
|
||||
char path[128];
|
||||
mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf);
|
||||
mg_http_serve_file(c, ev_data, path, NULL);
|
||||
}
|
||||
} else if (ev == MG_EV_READ && us->marker == 'U') {
|
||||
// Write uploaded data
|
||||
MG_DEBUG(("Uploading.. recv.len=%u", c->recv.len));
|
||||
us->received += c->recv.len;
|
||||
fwrite(c->recv.buf, 1, c->recv.len, us->ctx);
|
||||
c->recv.len = 0;
|
||||
if (us->received >= us->expected) {
|
||||
fclose(us->ctx);
|
||||
MG_DEBUG(("Uploaded %lu", us->expected));
|
||||
mg_http_reply(c, 200, NULL, "%lu uploaded\n", us->expected);
|
||||
mg_dash_send_change(c->mgr, &s_dashboard, "files");
|
||||
memset(us, 0, sizeof(*us));
|
||||
c->is_draining = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void mg_dash_init(struct mg_mgr *mgr) {
|
||||
file_add(mg_str("device-config.json"), 1234);
|
||||
file_add(mg_str("device-log-2026-04-25.txt"), 1327854);
|
||||
MG_DASH_REGISTER_CUSTOM_HANDLER(&s_dashboard, "/files/#", file_ev_handler);
|
||||
mg_http_listen(mgr, HTTP_ADDR, mg_dash_ev_handler, &s_dashboard);
|
||||
}
|
||||
|
||||
void mg_dash_poll(struct mg_mgr *mgr) {
|
||||
// Send metrics change periodically
|
||||
static uint64_t timer = 0;
|
||||
if (mg_timer_expired(&timer, 30000, mg_now())) {
|
||||
mg_dash_send_change(mgr, &s_dashboard, "metrics");
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<svg 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>
|
||||
|
||||
<!-- icon: two rounded rectangles -->
|
||||
<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 -->
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 771 B |
@@ -1,95 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const tabs = Array.from(document.querySelectorAll(".header-left .nav-link[href^='#']"));
|
||||
const panels = Array.from(document.querySelectorAll(".tab-panel"));
|
||||
|
||||
let chart = null;
|
||||
|
||||
const activateTab = targetId => {
|
||||
const targetPanel = document.getElementById(targetId);
|
||||
if (!targetPanel) return;
|
||||
|
||||
tabs.forEach(tab => {
|
||||
const isActive = tab.getAttribute("href") === `#${targetId}`;
|
||||
tab.classList.toggle("nav-link-active", isActive);
|
||||
tab.setAttribute("aria-selected", String(isActive));
|
||||
tab.setAttribute("tabindex", isActive ? "0" : "-1");
|
||||
});
|
||||
|
||||
panels.forEach(panel => {
|
||||
const isActive = panel.id === targetId;
|
||||
panel.classList.toggle("tab-panel-active", isActive);
|
||||
panel.hidden = !isActive;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) rect.height = rect.width = 10;
|
||||
|
||||
if (!chart) {
|
||||
options.width = rect.width;
|
||||
options.height = rect.height;
|
||||
chart = new uPlot(options, [], el);
|
||||
} else {
|
||||
chart.setSize({ width: rect.width, height: rect.height });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const targetId = tab.getAttribute("href").slice(1);
|
||||
activateTab(targetId);
|
||||
window.history.replaceState(null, "", `#${targetId}`);
|
||||
});
|
||||
});
|
||||
|
||||
const initialId = window.location.hash.slice(1) || "overview";
|
||||
activateTab(initialId);
|
||||
|
||||
|
||||
const el = document.getElementById("chart");
|
||||
|
||||
const options = {
|
||||
width: 100,
|
||||
height: 100,
|
||||
tzDate: (ts) => uPlot.tzDate(new Date(ts * 1e3), "Europe/London"),
|
||||
plugins: [],
|
||||
legend: { show: false },
|
||||
axes: [
|
||||
{ values: "{MMM} {DD}", size: 25 },
|
||||
{ size: 50, xvalues: (u, vals, space) => vals.map(v => (+v / 1000).toFixed(1) + 'k') },
|
||||
],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { range: [0, 100] },
|
||||
},
|
||||
series: [
|
||||
{},
|
||||
{stroke: 'rgba(5, 141, 199, 1)', fill: 'rgba(5, 141, 199, 0.1)'},
|
||||
{stroke: 'rgba(237, 126, 23, 1)', fill: 'rgba(237, 126, 23, 0.1)'},
|
||||
],
|
||||
};
|
||||
|
||||
function updateGraphPoints(data) {
|
||||
if (!data) return;
|
||||
// const parsed = JSON.parse(data);
|
||||
const parsed = data || [];
|
||||
const uplot_data = [
|
||||
parsed.map(x => x[0]),
|
||||
parsed.map(x => x[1]),
|
||||
parsed.map(x => x[2]),
|
||||
];
|
||||
chart.setData(uplot_data);
|
||||
};
|
||||
|
||||
// Intercept change notifications
|
||||
Dashboard.on('change', args => updateGraphPoints(args?.chart1?.data));
|
||||
Dashboard.call('get', 'chart1').then(resp => updateGraphPoints(resp?.data));
|
||||
|
||||
// Show logs
|
||||
// fetch('logs/')
|
||||
// .then(resp => resp.text())
|
||||
// .then(text => document.getElementById("logs").textContent = text);
|
||||
|
||||
});
|
||||
@@ -1,653 +0,0 @@
|
||||
/* Custom CSS for Dashboard */
|
||||
|
||||
:root {
|
||||
--color-surface-sunken: #e9eff5;
|
||||
--color-border-default: #d8e0ea;
|
||||
--color-text-toolbar: rgb(71, 85, 105);
|
||||
--color-text-strong: #24364d;
|
||||
--color-accent-border: #bae6fd;
|
||||
--color-accent-text: #0369a1;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--font-size-body: 14px;
|
||||
--font-size-toolbar: 1rem;
|
||||
--max-width: 80rem;
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.25rem;
|
||||
--spacing-6: 1.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-size: var(--font-size-body);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
a, a:visited, a:link { text-decoration: none; }
|
||||
|
||||
.header {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-6);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 2.8rem;
|
||||
margin-right: 1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.connection-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 4px 14px;
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-badge-online {
|
||||
color: rgb(4, 120, 87);
|
||||
border: 1px solid rgb(167, 243, 208);
|
||||
background: rgb(236, 253, 245);
|
||||
}
|
||||
|
||||
.connection-badge-offline {
|
||||
color: rgb(120, 44, 57);
|
||||
border: 1px solid rgb(243, 177, 208);
|
||||
background: rgb(253, 246, 245);
|
||||
}
|
||||
|
||||
|
||||
.connection-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-dot-online {
|
||||
background: rgb(16, 185, 129);
|
||||
}
|
||||
|
||||
.connection-dot-offline {
|
||||
background: rgb(185, 96, 109);
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
background: rgb(248, 250, 252);
|
||||
border: 1px solid rgb(226, 232, 240);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: 1rem;
|
||||
box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 2px 4px 0px inset;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-toolbar);
|
||||
background-color: transparent;
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
gap: var(--spacing-2);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
color: rgb(3, 105, 161);
|
||||
background: #fff;
|
||||
border: 1px solid rgb(186, 230, 253);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-stage {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem var(--spacing-6) var(--spacing-4);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
min-height: calc(100vh - 6rem);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-tile {
|
||||
padding: 0.75rem 1.5rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(203, 213, 225, 0.75);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1),
|
||||
0 2px 6px rgba(148, 163, 184, 0.1),
|
||||
0 8px 24px rgba(148, 163, 184, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.metric-tile:hover {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1),
|
||||
0 4px 12px rgba(148, 163, 184, 0.18),
|
||||
0 12px 32px rgba(148, 163, 184, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.metric-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.metric-status {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.18rem 0.52rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-status-good {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.metric-status-warn {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
.metric-status-alert {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.tab-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Form fields ─────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
color: #334155;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.55rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.field-input.edited {
|
||||
border-color: #00500040 !important;
|
||||
background: #00ff0020;
|
||||
}
|
||||
|
||||
.field-input.error {
|
||||
border-color: #50000040;
|
||||
background: #ff000020;
|
||||
}
|
||||
.field-input::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.field-input:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: #7dd3fc;
|
||||
}
|
||||
|
||||
.field-input:disabled,
|
||||
.field-input.disabled {
|
||||
color: #cbd5e1;
|
||||
border-color: #e2e8f0;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 2rem;
|
||||
padding: 0.3rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: #64748b;
|
||||
background: #ffffff;
|
||||
border-color: #e2e8f0;
|
||||
box-shadow: 0 1px 2px rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(160deg, #38bdf8, #0ea5e9);
|
||||
border-color: #0284c7;
|
||||
box-shadow: 0 1px 3px rgba(14, 165, 233, 0.3), 0 4px 10px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(160deg, #0ea5e9, #0284c7);
|
||||
box-shadow: 0 1px 3px rgba(14, 165, 233, 0.4), 0 6px 14px rgba(14, 165, 233, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:active,
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btn.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: #075985;
|
||||
background: #e0f2fe;
|
||||
border: 1px solid #7dd3fc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#files .row {
|
||||
align-items: stretch;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
#files .panel {
|
||||
flex: 0 0 40rem;
|
||||
width: 40rem;
|
||||
}
|
||||
|
||||
#files .note {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(203, 213, 225, 0.75);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1),
|
||||
0 2px 6px rgba(148, 163, 184, 0.1),
|
||||
0 8px 24px rgba(148, 163, 184, 0.08);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
padding: 0 1rem;
|
||||
min-height: 3rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* File manager */
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.file-list:empty::before {
|
||||
content: "No Files";
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.82rem 0;
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.file-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
color: #0369a1;
|
||||
background: #e0f2fe;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-icon svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
min-width: 0;
|
||||
color: #334155;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
/* Chart */
|
||||
|
||||
#chart {
|
||||
width: 100%;
|
||||
height: 12rem;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: #5d7088;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-dot-color2 { background: rgba(237, 126, 23, 1); }
|
||||
.legend-dot-color1 { background: rgba(5, 141, 199, 1) }
|
||||
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 2.75rem;
|
||||
height: 1.6rem;
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
border: 1px solid transparent;
|
||||
background: #d1d5db;
|
||||
border-radius: 0.75rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.toggle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
top: 1px;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.toggle:checked {
|
||||
background: #d1d5db;
|
||||
}
|
||||
.toggle:checked::after {
|
||||
transform: translateX(calc(100% - 3px));
|
||||
}
|
||||
.toggle:disabled,
|
||||
.toggle.disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.toggle.edited {
|
||||
border-color: #0fe;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-row {
|
||||
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) {
|
||||
.overview-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.overview-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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user