Add graph and control panel

This commit is contained in:
cpq
2026-04-19 08:58:14 +01:00
parent b68da7f759
commit 8e3c8bf61f
6 changed files with 334 additions and 6 deletions
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-only or commercial
#include "mongoose.h"
#include "math.h"
#if MG_ARCH == MG_ARCH_UNIX || MG_ARCH == MG_ARCH_WIN32
#define HTTP_ADDR "http://0.0.0.0:8000"
@@ -9,6 +10,40 @@
#define HTTP_ADDR "http://0.0.0.0:80"
#endif
#define NUM_POINTS 256 // How many data points to send
// #define INTERVAL_MS 200 // How often, in milliseconds
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.
};
// This function prints a JSON array of plot points
size_t print_points(void (*fn)(char, void *), void *arg, va_list *ap) {
size_t i, n = 0;
const char *comma = "";
(void) ap;
n += mg_xprintf(fn, arg, "[");
for (i = 0; i < NUM_POINTS; i++) {
struct point p;
p.x = (uint16_t) i;
// rand() - 0.5 + sin(x * 0.00002) * 40 + sin(x * 0.001) * 5 + sin(x * 0.1)
// * 2;
p.y1 = 100 + 50 * sin(i * 0.1) + rand() % 21;
p.y2 = 70 + 30 * sin(i * 0.03) + rand() % 17;
n += mg_xprintf(fn, arg, "%s[%hu,%hu,%hu]", comma, p.x, p.y1, p.y2);
comma = ",";
}
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) {
@@ -22,6 +57,7 @@ static void set_led(const union mg_val *val) {
// Modify this. This represents device's state to the dashboard
static struct mg_field fields[] = {
{"led", MG_VAL_BOOL, get_led, set_led},
{"points", MG_VAL_FN, get_points, NULL},
{NULL, 0, 0, 0},
};
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="uPlot.min.css">
<!-- Mongoose device dashboard -->
<script src="https://cdn.jsdelivr.net/gh/cesanta/mongoose@master/resources/dashboard.js"></script>
<script>
@@ -20,7 +21,8 @@
});
</script>
<script src="main.js" defer></script>
<title>My Dashboard Toolbar</title>
<script src="uPlot.iife.min.js" defer></script>
<title>My Dashboard</title>
</head>
<body>
<div class="header">
@@ -119,6 +121,61 @@
<div class="metric-status metric-status-good">Healthy headroom</div>
</article>
</div>
<div class="detail-row">
<!-- Sensor History Chart -->
<div class="panel chart-panel">
<div class="panel-header">
<div>
<div class="panel-title">Sensor History</div>
</div>
<div class="chart-legend">
<div class="legend-item"><span class="legend-dot legend-dot-color1"></span>Temperature</div>
<div class="legend-item"><span class="legend-dot legend-dot-color2"></span>Humidity</div>
</div>
</div>
<div id="chart"></div>
</div>
<!-- Controls Panel -->
<div class="panel controls-panel">
<div class="panel-title">Controls</div>
<div class="controls-list">
<div class="control-row">
<div class="control-info">
<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>
</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>
</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>
</div>
</div>
</div>
</div>
</section>
<section id="settings" class="tab-panel" role="tabpanel" aria-labelledby="tab-settings" hidden></section>
<section id="firmware" class="tab-panel" role="tabpanel" aria-labelledby="tab-firmware" hidden></section>
@@ -31,4 +31,43 @@ document.addEventListener("DOMContentLoaded", () => {
const initialId = window.location.hash.slice(1) || "overview";
activateTab(initialId);
const el = document.getElementById("chart");
const rect = el.getBoundingClientRect();
const options = {
width: rect.width,
height: rect.height,
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') },
],
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)'},
],
};
// Create chart instance
const chart = new uPlot(options, [], el);
function updateGraphPoints(args) {
const data = [
args.points.map(x => x[0]),
args.points.map(x => x[1]),
args.points.map(x => x[2]),
];
//console.log(123, args.points);
chart.setData(data);
};
// Intercept change notifications
Dashboard.on('change', args => updateGraphPoints(args));
Dashboard.call('get', 'points').then(resp => updateGraphPoints(resp));
});
@@ -155,7 +155,7 @@ body {
}
.metric-tile {
padding: 1.25rem 1.5rem 1.2rem;
padding: 0.75rem 1.5rem 0.75rem;
border-radius: 1rem;
background: #ffffff;
border: 1px solid rgba(203, 213, 225, 0.75);
@@ -178,8 +178,8 @@ body {
}
.metric-label {
color: #7d94ac;
font-size: 0.82rem;
color: #94a3b8;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -189,7 +189,7 @@ body {
display: flex;
align-items: baseline;
gap: 0.2rem;
color: #18314f;
color: #475569;
}
.metric-number {
@@ -202,7 +202,7 @@ body {
.metric-unit {
font-size: 1rem;
font-weight: 600;
color: #4a6580;
color: #cbd5e1;
letter-spacing: -0.01em;
}
@@ -210,6 +210,7 @@ body {
font-size: 0.78rem;
font-weight: 600;
margin-top: 0.1rem;
display: none;
}
.metric-status-good {
@@ -228,6 +229,198 @@ body {
display: none;
}
/* ── Detail row (chart + controls) ─────────────────────────────── */
.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: 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);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.1rem;
}
.panel-title {
font-size: 1.05rem;
font-weight: 700;
color: #18314f;
margin-bottom: 0.2rem;
}
.panel-subtitle {
color: #7d94ac;
font-size: 0.8rem;
}
/* Chart */
.chart-panel {
/* column flex already set by .panel */
}
.chart-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: 180px;
}
#chart {
width: 100%;
height: 12rem;
}
.chart-x-labels {
display: flex;
justify-content: space-between;
margin-top: 0.6rem;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 500;
}
.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) }
/* Controls */
.controls-panel {
/* column flex already set by .panel */
}
.controls-list {
display: flex;
flex-direction: column;
flex: 1;
margin-top: 0.25rem;
}
.control-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid #e8eef5;
}
.control-row:last-child {
border-bottom: none;
}
.control-name {
font-size: 0.88rem;
font-weight: 700;
color: #18314f;
margin-bottom: 0.15rem;
}
.control-desc {
font-size: 0.75rem;
color: #7d94ac;
}
/* Toggle switch */
.toggle {
position: relative;
display: inline-block;
width: 3rem;
height: 1.65rem;
cursor: pointer;
flex-shrink: 0;
}
.toggle input {
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;
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;
}
.toggle input:checked + .toggle-track {
background: linear-gradient(135deg, #10b981, #06b6d4);
}
.toggle input:checked + .toggle-track::before {
left: calc(100% - 1.45rem);
}
.toggle input:focus-visible + .toggle-track {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
}
@media (max-width: 900px) {
.detail-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.overview-grid {
grid-template-columns: repeat(2, 1fr);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}