diff --git a/mongoose.h b/mongoose.h index 6c77d810..8482bbe6 100644 --- a/mongoose.h +++ b/mongoose.h @@ -3140,6 +3140,9 @@ void mg_rpc_list(struct mg_rpc_req *r); + + + enum mg_val_type { MG_VAL_INT, MG_VAL_BOOL, MG_VAL_DBL, MG_VAL_STR, MG_VAL_FN }; union mg_val { @@ -3228,7 +3231,7 @@ static inline size_t mg_print_field(mg_pfn_t fn, void *arg, va_list *ap) { } else if (f->type == MG_VAL_STR) { n += mg_xprintf(fn, arg, "%m", mg_print_esc, val.s.len, val.s.buf); } else if (f->type == MG_VAL_FN) { - n += mg_xprintf(fn, arg, "%M", val.fn); + n += mg_xprintf(fn, arg, val.fn ? "%M" : "null", val.fn); } else { n += mg_xprintf(fn, arg, "null"); } diff --git a/resources/dashboard.js b/resources/dashboard.js index 4d2ad475..fc9a74a1 100644 --- a/resources/dashboard.js +++ b/resources/dashboard.js @@ -13,8 +13,9 @@ ? realsend(JSON.stringify(x)) : q.push(x); const connect = () => { + if (debug) console.log('WS connect'); ws = new WebSocket(url); - ws.onopen = () => { while (q.length) realsend(JSON.stringify(q.shift())); }; + ws.onopen = () => { if (debug) console.log('WS open'); while (q.length) realsend(JSON.stringify(q.shift())); }; ws.onclose = () => { fail(); setTimeout(connect, 1000); }; ws.onmessage = e => { if (debug) console.log('WS <-', e.data); @@ -62,34 +63,34 @@ const isMock = !!window.frameElement || (window !== window.top); const rpc = isMock ? null : Rpc('api/websocket'); const settings = {data: {}, edits: {}, debug: true}; - const bound = {}; // data_key => [ el1, el2, ...] + const substitutions = {}; const userhandlers = {}; // User event handlers + 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); + const set = (obj, path, value) => apply(obj, fromPath(path, value)); + const has = (obj, path) => path.split('.').every(k => (obj = obj?.[k]) !== undefined); + const del = (obj, path) => { + const keys = path.split('.'); + const last = keys.pop(); + const parent = keys.reduce((o, k) => o?.[k], obj); + return parent && delete parent[last]; + }; + function safeEval(expr, context) { const c = Object.freeze(context); const k = Object.keys(c).join(","); - return Function("x", `"use strict"; const {${k}} = x; return ${expr};`)(c); + let result; + try { + result = Function("x", `"use strict"; const {${k}} = x; return ${expr};`)(c); + } catch (err) { + } + return result; } - // function scanAttr(el, name, val, context) { - // let res; - // try { - // res = safeEval(val, context); - // } catch (err) { - // console.error('EVAL', attr.name, attr.value, context); - // //errors.push({type: 'eval', expr: attr.value}); - // return; - // } - // //console.log('xxxx', el); - // if (name == 'html') { - // el.innerHTML = res; - // } else { - // el[name] = res; - // } - // }; - function edit(el, key, val) { - if (typeof(settings.data[key]) === 'number') { + const dv = get(settings.data, key); + if (typeof(dv) === 'number') { const v = +val; if (isNaN(v)) { el.classList.toggle("error", true); @@ -97,94 +98,124 @@ el.classList.toggle("error", false); val = v; } - } else if (typeof(settings.data[key]) === 'boolean') { + } else if (typeof(v) === 'boolean') { val = !!val; } - settings.edits[key] = val; - if (settings.edits[key] == settings.data[key]) { - delete settings.edits[key]; + set(settings.edits, key, val); + if (val == dv) { + del(settings.edits, key); el.classList.toggle("edited", false); } else { el.classList.toggle("edited", true); } - if (el.dataset.autosave) save(new RegExp(key)); - + if (el.dataset.autosave) save([key]); rescan(); }; // Return if any of the keys matching pattern is edited - function edited(regex) { + function edited(keys) { for (const key of Object.keys(settings.edits)) { - if (regex.test(key)) return true; + if (keys.includes(key)) return true; } return false; }; function scan_bind(el, key, context) { - if (key in settings.data) { - if (el.tagName == 'INPUT') { - if (el.type == 'checkbox') el.checked = context[key]; - if (el.type == 'text') el.value = context[key]; - if (!(key in settings.edits)) { - el.classList.toggle('edited', false); - el.classList.toggle('error', false); - } - if (!el.bound) el.addEventListener("input", ev => edit(el, key, el.type == 'checkbox' ? ev.target.checked : ev.target.value)), el.bound = true; + if (!has(context, key)) { console.error('EVAL', key, context); return; } + const v = get(context, key) + // console.log(2, key, v); + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)) { + if (el.type == 'checkbox') el.checked = v; + if (el.type == 'text') el.value = v; + if (!has(settings.edits, key)) { + el.classList.toggle('edited', false); + el.classList.toggle('error', false); } + if (!el.bound) el.addEventListener("input", ev => edit(el, key, el.type == 'checkbox' ? ev.target.checked : ev.target.value)), el.bound = true; } else { - console.error(`data-bind: ${key} is not in data`); + el.innerHTML = v; } }; // Save the edits for keys that match pattern - function save(regex) { + function save(keys) { const changes = {}; - for (const key of Object.keys(settings.edits)) { - if (regex.test(key)) changes[key] = settings.edits[key]; + for (const key of keys) { + if (!has(settings.edits, key)) continue; + set(changes, key, get(settings.edits, key)); } rpc.call('set', changes).then(r => apply_and_rescan(settings.data, r)); }; // Cancel the edits for keys that match pattern - function cancel(regex) { - console.log('CANCELLING'); + function cancel(keys) { for (const key of Object.keys(settings.edits)) { - if (regex.test(key)) delete settings.edits[key]; + if (keys.includes(key)) delete settings.edits[key]; } rescan(); }; - function scan_save(el, pattern) { - try { - const re = new RegExp(pattern); - el.disabled = !edited(re); - if (!el.bound) el.addEventListener("click", ev => save(re)), el.bound = true; - } catch (err) { - console.error('Invalid regex', pattern, 'for data-save'); - } + function scan_save(el, keys) { + el.disabled = !edited(keys); + if (!el.bound) el.addEventListener("click", ev => save(keys)), el.bound = true; }; - function scan_cancel(el, pattern) { - try { - const re = new RegExp(pattern); - if (!el.bound) el.addEventListener("click", ev => cancel(re)), el.bound = true; - el.disabled = !edited(re); - } catch (err) { - console.error('Invalid regex', pattern, 'for data-save'); - } + function scan_cancel(el, keys) { + if (!el.bound) el.addEventListener("click", ev => cancel(keys)), el.bound = true; + el.disabled = !edited(keys); }; + function createSubstitutions() { + substitutions.nodes = [], substitutions.attributes = [], substitutions.bound = []; + document.querySelectorAll("*").forEach(function (el) { + if (el.tagName === "SCRIPT" || el.tagName === "STYLE") return; + for (const node of el.childNodes) { + if (node.nodeType === Node.TEXT_NODE && node.nodeValue.includes('${')) { + node.originalValue = node.nodeValue; + substitutions.nodes.push(node); + } + } + for (const attr of el.attributes) { + if (attr.value.includes('${')) { + attr.originalValue = attr.value; + substitutions.attributes.push(attr); + } + } + for (const [key, val] of Object.entries(el.dataset)) { + if (['bind', 'save', 'cancel'].includes(key)) substitutions.bound.push(el); + } + }); + // console.log(substitutions); + }; + + function substituteExpressions(text, context) { + const expected = { string: 1, number: 1, boolean: 1 }; + const f = (orig, expr) => { + const res = safeEval(expr, context); + return (typeof res) in expected ? res : orig; + }; + return typeof text == "string" ? text.replace(/\${(.+?)}/g, f) : text; + } + function realrescan() { - const context = {...settings.data, ...settings.edits}; - //console.log('rescanning..', context); - document.querySelectorAll("*").forEach((el) => { - if (el.tagName === "SCRIPT" || el.tagName === "STYLE") return; + const context = JSON.parse(JSON.stringify(settings.data)); + apply(context, settings.edits); + if (!substitutions.nodes) createSubstitutions(); + substitutions.attributes.forEach(attr => { + const v = substituteExpressions(attr.originalValue, context); + if (v !== attr.value) attr.value = v; + }); + substitutions.nodes.forEach(node => { + const v = substituteExpressions(node.originalValue, context); + if (v !== node.nodeValue) node.nodeValue = v; + }); + substitutions.bound.forEach(el => { for (const [key, val] of Object.entries(el.dataset)) { if (key == 'bind') scan_bind(el, val, context); - if (key == 'save') scan_save(el, val); - if (key == 'cancel') scan_cancel(el, val); + if (key == 'save') scan_save(el, val.split(/\s*,\s*/)); + if (key == 'cancel') scan_cancel(el, val.split(/\s*,\s*/)); } }); }; diff --git a/src/dash.h b/src/dash.h index cc0a60d9..40b112bf 100644 --- a/src/dash.h +++ b/src/dash.h @@ -1,5 +1,8 @@ #pragma once +#include "json.h" +#include "printf.h" +#include "str.h" #include "ws.h" enum mg_val_type { MG_VAL_INT, MG_VAL_BOOL, MG_VAL_DBL, MG_VAL_STR, MG_VAL_FN }; @@ -90,7 +93,7 @@ static inline size_t mg_print_field(mg_pfn_t fn, void *arg, va_list *ap) { } else if (f->type == MG_VAL_STR) { n += mg_xprintf(fn, arg, "%m", mg_print_esc, val.s.len, val.s.buf); } else if (f->type == MG_VAL_FN) { - n += mg_xprintf(fn, arg, "%M", val.fn); + n += mg_xprintf(fn, arg, val.fn ? "%M" : "null", val.fn); } else { n += mg_xprintf(fn, arg, "null"); } diff --git a/tutorials/device-dashboard/full-dashboard/server.c b/tutorials/device-dashboard/full-dashboard/server.c index 754e0abf..fb7ca96c 100644 --- a/tutorials/device-dashboard/full-dashboard/server.c +++ b/tutorials/device-dashboard/full-dashboard/server.c @@ -9,7 +9,7 @@ #define HTTP_ADDR "http://0.0.0.0:80" #endif -#define NUM_POINTS 258 // How many data points to send +#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 @@ -39,24 +39,69 @@ size_t print_points(void (*fn)(char, void *), void *arg, va_list *ap) { n += mg_xprintf(fn, arg, "]"); return n; } - static void get_points(union mg_val *val) { val->fn = (void (*)(void)) print_points; } -static bool s_led; - -static void get_led(union mg_val *val) { - val->b = s_led; +// This function prints a JSON object with read-only metrics +size_t print_metrics(void (*fn)(char, void *), void *arg, va_list *ap) { + size_t n = 0; + (void) ap; + n += mg_xprintf(fn, arg, "{"); + n += mg_xprintf(fn, arg, "%s%m:%g", " ", MG_ESC("temperature"), 24.8); + n += mg_xprintf(fn, arg, "%s%m:%d", ",", MG_ESC("humidity"), 59); + n += mg_xprintf(fn, arg, "%s%m:%d", ",", MG_ESC("cpu"), 37); + n += mg_xprintf(fn, arg, "%s%m:%d", ",", MG_ESC("ram"), 32); + n += mg_xprintf(fn, arg, "}"); + return n; +} +static void get_metrics(union mg_val *val) { + val->fn = (void (*)(void)) print_metrics; } -static void set_led(const union mg_val *val) { - s_led = val->b; +static bool s_motor = false; +static double s_volume = 17.2; +static char s_name[20] = "Dublin"; +// This function prints a JSON object with read-only metrics +size_t print_settings(void (*fn)(char, void *), void *arg, va_list *ap) { + size_t n = 0; + (void) ap; + n += mg_xprintf(fn, arg, "{"); + n += mg_xprintf(fn, arg, "%s%m:%g", " ", MG_ESC("volume"), s_volume); + n += mg_xprintf(fn, arg, "%s%m:%s", ",", MG_ESC("motor"), s_motor ? "true" : "false"); + n += mg_xprintf(fn, arg, "%s%m:%m", ",", MG_ESC("name"), MG_ESC(s_name)); + n += mg_xprintf(fn, arg, "}"); + return n; +} +static void get_settings(union mg_val *val) { + val->fn = (void (*)(void)) print_settings; +} + +static bool s_fan, s_relay1, s_relay2; +size_t print_controls(void (*fn)(char, void *), void *arg, va_list *ap) { + size_t n = 0; + (void) ap; + n += mg_xprintf(fn, arg, "{"); + n += mg_xprintf(fn, arg, "%s%m:%s", " ", MG_ESC("fan"), s_fan ? "true" : "false"); + n += mg_xprintf(fn, arg, "%s%m:%s", ",", MG_ESC("relay1"), s_relay1 ? "true" : "false"); + n += mg_xprintf(fn, arg, "%s%m:%s", ",", MG_ESC("relay2"), s_relay2 ? "true" : "false"); + n += mg_xprintf(fn, arg, "}"); + return n; +} +static void set_controls(const union mg_val *val) { + mg_json_get_bool(val->s, "$.fan", &s_fan); + mg_json_get_bool(val->s, "$.relay1", &s_relay1); + mg_json_get_bool(val->s, "$.relay2", &s_relay2); +} +static void get_controls(union mg_val *val) { + val->fn = (void (*)(void)) print_controls; } // Modify this. This represents device's state to the dashboard static struct mg_field fields[] = { - {"led", MG_VAL_BOOL, get_led, set_led}, + {"controls", MG_VAL_FN, get_controls, set_controls}, + {"settings", MG_VAL_FN, get_settings, NULL}, + {"metrics", MG_VAL_FN, get_metrics, NULL}, {"points", MG_VAL_FN, get_points, NULL}, {NULL, 0, 0, 0}, }; diff --git a/tutorials/device-dashboard/full-dashboard/web_root/index.html b/tutorials/device-dashboard/full-dashboard/web_root/index.html index 1c255a72..8ab591d3 100644 --- a/tutorials/device-dashboard/full-dashboard/web_root/index.html +++ b/tutorials/device-dashboard/full-dashboard/web_root/index.html @@ -11,12 +11,23 @@ Dashboard.init({ debug: true, data: { - temperature: 25, - humidity: 60, - voltage: 12.5, - current: 4.2, - power: 52.5, - speed: 100, + points: [], + controls: { + fan: true, + relay1: false, + relay2: false, + }, + settings: { + volume: 12, + motor: false, + name: 'Galway', + }, + metrics: { + temperature: 1.2, + humidity: 3, + cpu: 4, + ram: 5, + }, }, }); @@ -90,35 +101,54 @@
Temperature
-
- 24.8 - °C +
+
+ 24.8 + °C +
+ ok
-
Stable — within target range
Humidity
-
- 51 - % +
+
+ 51 + % +
+ ok
-
+2% in the last hour
-
+
CPU Load
-
- 37 - % +
+
+ 37 + % +
+ moderate +
+
+
+
Free Memory
+
+
+ ${metrics.ram} + KB +
+ low + low
-
Moderate load
-
Free Memory
-
- 182 - KB +
Volume
+
+
+ 18.7 + dB +
+ ok
-
Healthy headroom
@@ -147,37 +177,48 @@
Cooling Fan
PWM controlled output
- +
Relay Channel 1
Auxiliary load output
- +
Relay Channel 2
Safety-controlled line
- +
- + diff --git a/tutorials/device-dashboard/full-dashboard/web_root/main.js b/tutorials/device-dashboard/full-dashboard/web_root/main.js index b4b53613..47901587 100644 --- a/tutorials/device-dashboard/full-dashboard/web_root/main.js +++ b/tutorials/device-dashboard/full-dashboard/web_root/main.js @@ -57,6 +57,7 @@ document.addEventListener("DOMContentLoaded", () => { const chart = new uPlot(options, [], el); function updateGraphPoints(args) { + if (!args.points) return; const data = [ args.points.map(x => x[0]), args.points.map(x => x[1]), diff --git a/tutorials/device-dashboard/full-dashboard/web_root/style.css b/tutorials/device-dashboard/full-dashboard/web_root/style.css index cec3fa7d..eedf8a9a 100644 --- a/tutorials/device-dashboard/full-dashboard/web_root/style.css +++ b/tutorials/device-dashboard/full-dashboard/web_root/style.css @@ -139,10 +139,6 @@ body { .tab-panel { min-height: calc(100vh - 6rem); - /* border-radius: .5rem; */ - /* background-color: #eee; */ - /* border: 1px solid rgba(216, 224, 234, 0.78); */ - /* padding: var(--spacing-4); */ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 40px rgba(148, 163, 184, 0.1); @@ -150,7 +146,7 @@ body { .overview-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 1rem; } @@ -185,6 +181,13 @@ body { 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; @@ -207,28 +210,208 @@ body { } .metric-status { - font-size: 0.78rem; - font-weight: 600; - margin-top: 0.1rem; - display: none; + 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: #16a34a; + color: #166534; + background: #dcfce7; + border-color: #86efac; } .metric-status-warn { - color: #d97706; + color: #92400e; + background: #fef3c7; + border-color: #fcd34d; +} + +.metric-status-alert { + color: #991b1b; + background: #fee2e2; + border-color: #fca5a5; } .metric-status-info { - color: #0369a1; + color: #075985; + background: #e0f2fe; + border-color: #7dd3fc; } .tab-panel[hidden] { display: none; } +/* ── Settings panel ─────────────────────────────── */ + +.settings-layout { + padding-top: 1rem; + max-width: 22rem; +} + +.settings-panel { + background: #ffffff; + border: 1px solid rgba(203, 213, 225, 0.75); + border-radius: 1rem; + 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; +} + +.settings-panel-title { + font-size: 0.95rem; + font-weight: 700; + color: #334155; + padding: 1rem 1.25rem 0.9rem; + border-bottom: 1px solid #e2e8f0; +} + +.settings-fields { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem 1.25rem; +} + +.field { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; +} + +.field-label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #94a3b8; + width: 4.5rem; + flex-shrink: 0; +} + +.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, .select.edited, .slider.edited>* { + border-color: #50500040; + background: #ffff0020; +} + +.field-input.error, .select.error, .slider.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; +} + +.select:disabled, +.select.disabled { + color: #cbd5e1; + border-color: #e2e8f0; + cursor: not-allowed; + pointer-events: none; +} + +.settings-panel-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: #f8fafc; + border-top: 1px solid #e2e8f0; +} + +.btn { + display: inline-flex; + align-items: center; + padding: 0.42rem 1rem; + font-size: 0.82rem; + font-family: inherit; + font-weight: 600; + border-radius: 0.55rem; + 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); + /*transform: translateY(-1px);*/ +} + +.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; +} + /* ── Detail row (chart + controls) ─────────────────────────────── */ .detail-row { @@ -360,57 +543,45 @@ body { color: #7d94ac; } -/* Toggle switch */ - .toggle { position: relative; - display: inline-block; - width: 3rem; - height: 1.65rem; + 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; - flex-shrink: 0; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } - -.toggle input { +.toggle::after { + content: ""; position: absolute; - opacity: 0; - width: 0; - height: 0; -} - -.toggle-track { - position: absolute; - inset: 0; - border-radius: 999px; - background: #cbd5e1; - transition: background 0.22s ease; -} - -.toggle-track::before { - content: ''; - position: absolute; - width: 1.25rem; - height: 1.25rem; + left: 1px; + top: 1px; + width: 1.35rem; + height: 1.35rem; + background: #fff; border-radius: 50%; - background: #ffffff; - top: 50%; - left: 0.2rem; - transform: translateY(-50%); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); - transition: left 0.22s ease; + transition: all 0.3s; } - -.toggle input:checked + .toggle-track { - background: linear-gradient(135deg, #10b981, #06b6d4); +.toggle:checked { + background: #d1d5db; } - -.toggle input:checked + .toggle-track::before { - left: calc(100% - 1.45rem); +.toggle:checked::after { + transform: translateX(calc(100% - 3px)); } - -.toggle input:focus-visible + .toggle-track { - outline: 2px solid #0ea5e9; - outline-offset: 2px; +.toggle:disabled, +.toggle.disabled { + background: #d1d5db; + cursor: not-allowed; +} +.toggle.edited { + border-color: #ee0; } @media (max-width: 900px) {