mirror of
https://github.com/cesanta/mongoose.git
synced 2026-09-30 13:32:29 +07:00
Add graph and control panel
This commit is contained in:
@@ -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;}
|
||||
Reference in New Issue
Block a user