mirror of
https://github.com/cesanta/mongoose.git
synced 2026-10-12 10:13:29 +00:00
${} evaluations
This commit is contained in:
+4
-1
@@ -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");
|
||||
}
|
||||
|
||||
+98
-67
@@ -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*/));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+4
-1
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -90,35 +101,54 @@
|
||||
<div class="overview-grid" aria-label="Device metrics overview">
|
||||
<article class="metric-tile">
|
||||
<div class="metric-label">Temperature</div>
|
||||
<div class="metric-value">
|
||||
<span class="metric-number">24.8</span>
|
||||
<span class="metric-unit">°C</span>
|
||||
<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>
|
||||
<div class="metric-status metric-status-good">Stable — within target range</div>
|
||||
</article>
|
||||
<article class="metric-tile">
|
||||
<div class="metric-label">Humidity</div>
|
||||
<div class="metric-value">
|
||||
<span class="metric-number">51</span>
|
||||
<span class="metric-unit">%</span>
|
||||
<div class="metric-value-row">
|
||||
<div class="metric-value">
|
||||
<span class="metric-number" data-bind="metrics.humidity">51</span>
|
||||
<span class="metric-unit">%</span>
|
||||
</div>
|
||||
<span class="metric-status metric-status-good">ok</span>
|
||||
</div>
|
||||
<div class="metric-status metric-status-good">+2% in the last hour</div>
|
||||
</article>
|
||||
<article class="metric-tile">
|
||||
<article class="metric-tile metric-tile-warning">
|
||||
<div class="metric-label">CPU Load</div>
|
||||
<div class="metric-value">
|
||||
<span class="metric-number">37</span>
|
||||
<span class="metric-unit">%</span>
|
||||
<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;'}">low</span>
|
||||
</div>
|
||||
<div class="metric-status metric-status-warn">Moderate load</div>
|
||||
</article>
|
||||
<article class="metric-tile">
|
||||
<div class="metric-label">Free Memory</div>
|
||||
<div class="metric-value">
|
||||
<span class="metric-number">182</span>
|
||||
<span class="metric-unit">KB</span>
|
||||
<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>
|
||||
<div class="metric-status metric-status-good">Healthy headroom</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -147,37 +177,48 @@
|
||||
<div class="control-name">Cooling Fan</div>
|
||||
<div class="control-desc">PWM controlled output</div>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
<input type="checkbox" data-bind="controls.fan" data-autosave="1" class="toggle" />
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-info">
|
||||
<div class="control-name">Relay Channel 1</div>
|
||||
<div class="control-desc">Auxiliary load output</div>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
<input type="checkbox" data-bind="controls.relay1" data-autosave="1" class="toggle" />
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-info">
|
||||
<div class="control-name">Relay Channel 2</div>
|
||||
<div class="control-desc">Safety-controlled line</div>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox">
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
<input type="checkbox" data-bind="controls.relay2" data-autosave="1" class="toggle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<section id="settings" class="tab-panel" role="tabpanel" aria-labelledby="tab-settings" hidden></section>
|
||||
<section id="settings" class="tab-panel" role="tabpanel" aria-labelledby="tab-settings" hidden>
|
||||
<div class="settings-layout">
|
||||
<div class="settings-panel">
|
||||
<div class="settings-panel-title">Device settings</div>
|
||||
<div class="settings-fields">
|
||||
<div class="field">
|
||||
<label class="field-label" for="setting-name">Name</label>
|
||||
<input class="field-input" id="setting-name" type="text" data-bind="settings.name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="setting-volume">Volume</label>
|
||||
<input class="field-input" id="setting-volume" type="text" data-bind="settings.volume" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panel-footer">
|
||||
<button class="btn btn-secondary" type="button" data-cancel="settings">Cancel</button>
|
||||
<button class="btn btn-primary" type="button" data-save="settings">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="firmware" class="tab-panel" role="tabpanel" aria-labelledby="tab-firmware" hidden></section>
|
||||
<section id="events" class="tab-panel" role="tabpanel" aria-labelledby="tab-events" hidden></section>
|
||||
</main>
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user