4bnode 4.2.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,634 @@
1
+ import React, { useState, useEffect, useRef, useMemo } from "react";
2
+ import {
3
+ LuSend, LuPencil, LuTrash2, LuPlus, LuCopy, LuPause, LuPlay, LuDownload, LuEraser, LuSearch,
4
+ } from "react-icons/lu";
5
+ import { html, api, copyToClipboard, formatBytes, formatDuration, timeAgo } from "../lib.js";
6
+ import { useApp } from "../context.js";
7
+
8
+ // ── Live stream ────────────────────────────────────────────────────────────
9
+ // One SSE connection (fetch + reader so the passkey stays in a header) feeding
10
+ // a per-device line buffer. Components subscribe and re-render on new lines.
11
+ const MAX_LINES = 2000;
12
+ const buffers = new Map(); // device -> [{ dir, line, ts }]
13
+ const listeners = new Set();
14
+ let streamCtl = null;
15
+ let streamUsers = 0;
16
+
17
+ function pushLine(e) {
18
+ const list = buffers.get(e.device) || [];
19
+ list.push({ dir: e.dir, line: e.line, ts: e.ts });
20
+ if (list.length > MAX_LINES) list.splice(0, list.length - MAX_LINES);
21
+ buffers.set(e.device, list);
22
+ }
23
+
24
+ function startStream() {
25
+ if (streamCtl) return;
26
+ const ctl = new AbortController();
27
+ streamCtl = ctl;
28
+ (async function run() {
29
+ while (!ctl.signal.aborted) {
30
+ try {
31
+ const passkey = sessionStorage.getItem("_dev_passkey") || "";
32
+ const res = await fetch("/_dev/api/serial/stream", { headers: passkey ? { "x-passkey": passkey } : {}, signal: ctl.signal });
33
+ if (!res.ok || !res.body) throw new Error("stream " + res.status);
34
+ const reader = res.body.getReader();
35
+ const dec = new TextDecoder();
36
+ let buf = "";
37
+ for (;;) {
38
+ const { value, done } = await reader.read();
39
+ if (done) break;
40
+ buf += dec.decode(value, { stream: true });
41
+ let i;
42
+ while ((i = buf.indexOf("\n\n")) !== -1) {
43
+ const chunk = buf.slice(0, i);
44
+ buf = buf.slice(i + 2);
45
+ const data = chunk.split("\n").find((l) => l.startsWith("data: "));
46
+ if (!data) continue;
47
+ try {
48
+ const e = JSON.parse(data.slice(6));
49
+ if (e.type === "line") pushLine(e);
50
+ listeners.forEach((fn) => fn(e));
51
+ } catch {}
52
+ }
53
+ }
54
+ } catch {}
55
+ if (!ctl.signal.aborted) await new Promise((r) => setTimeout(r, 2000)); // reconnect
56
+ }
57
+ })();
58
+ }
59
+
60
+ // Subscribe a component to live serial events; returns a version counter that
61
+ // bumps (at most once per animation frame) when lines arrive.
62
+ export function useSerialStream(onEvent) {
63
+ const [version, setVersion] = useState(0);
64
+ const cb = useRef(onEvent);
65
+ cb.current = onEvent;
66
+ useEffect(() => {
67
+ let raf = 0;
68
+ const fn = (e) => {
69
+ if (cb.current) cb.current(e);
70
+ if (!raf) raf = requestAnimationFrame(() => { raf = 0; setVersion((v) => v + 1); });
71
+ };
72
+ listeners.add(fn);
73
+ streamUsers++;
74
+ startStream();
75
+ return () => {
76
+ listeners.delete(fn);
77
+ cancelAnimationFrame(raf);
78
+ if (--streamUsers === 0 && streamCtl) { streamCtl.abort(); streamCtl = null; }
79
+ };
80
+ }, []);
81
+ return version;
82
+ }
83
+
84
+ async function backfill(name) {
85
+ try {
86
+ const recent = await api("/serial/devices/" + encodeURIComponent(name) + "/recent");
87
+ const live = buffers.get(name) || [];
88
+ const firstLive = live.length ? live[0].ts : Infinity;
89
+ buffers.set(name, recent.filter((l) => l.ts < firstLive).concat(live).slice(-MAX_LINES));
90
+ } catch {}
91
+ }
92
+
93
+ // Save a device: the current config plus a patch (commands, rules, ...).
94
+ async function saveDevice(cfg, patch) {
95
+ return api("/serial/devices", { method: "POST", body: { ...cfg, ...patch, originalName: cfg.name } });
96
+ }
97
+
98
+ const fmtTime = (ts) => {
99
+ const d = new Date(ts);
100
+ return d.toLocaleTimeString([], { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
101
+ };
102
+ const toHex = (s) => Array.from(new TextEncoder().encode(s)).map((b) => b.toString(16).padStart(2, "0")).join(" ");
103
+
104
+ // ── 1. Monitor ─────────────────────────────────────────────────────────────
105
+ function Monitor({ cfg, live, onSent }) {
106
+ const { toast } = useApp();
107
+ const [query, setQuery] = useState("");
108
+ const [dir, setDir] = useState("all");
109
+ const [showTs, setShowTs] = useState(true);
110
+ const [hex, setHex] = useState(false);
111
+ const [paused, setPaused] = useState(false);
112
+ const [frozen, setFrozen] = useState(null); // snapshot while paused
113
+ const [clearedAt, setClearedAt] = useState(0);
114
+ const [command, setCommand] = useState("");
115
+ const [expectReply, setExpectReply] = useState(true);
116
+ const [sending, setSending] = useState(false);
117
+ const histKey = "_4bnode_serial_hist_" + cfg.name;
118
+ const [history, setHistory] = useState(() => { try { return JSON.parse(localStorage.getItem(histKey) || "[]"); } catch { return []; } });
119
+ const [histIdx, setHistIdx] = useState(-1);
120
+ const boxRef = useRef(null);
121
+ const stick = useRef(true);
122
+ const version = useSerialStream();
123
+
124
+ const [, refresh] = useState(0);
125
+ useEffect(() => { backfill(cfg.name).then(() => refresh((n) => n + 1)); }, [cfg.name]);
126
+
127
+ const all = buffers.get(cfg.name) || [];
128
+ const source = paused && frozen ? frozen : all;
129
+ const lines = useMemo(() => {
130
+ const q = query.trim().toLowerCase();
131
+ return source.filter((l) => l.ts > clearedAt && (dir === "all" || l.dir === dir) && (!q || l.line.toLowerCase().includes(q))).slice(-1000);
132
+ }, [source, source.length, version, query, dir, clearedAt, paused]);
133
+ const pendingNew = paused && frozen ? Math.max(0, all.length - frozen.length) : 0;
134
+
135
+ useEffect(() => {
136
+ const el = boxRef.current;
137
+ if (el && stick.current && !paused) el.scrollTop = el.scrollHeight;
138
+ }, [lines.length, version, paused]);
139
+
140
+ function togglePause() {
141
+ if (paused) { setPaused(false); setFrozen(null); }
142
+ else { setFrozen(all.slice()); setPaused(true); }
143
+ }
144
+
145
+ function exportLog() {
146
+ const text = lines.map((l) => fmtTime(l.ts) + " " + (l.dir === "tx" ? ">" : "<") + " " + l.line).join("\n");
147
+ const blob = new Blob([text + "\n"], { type: "text/plain" });
148
+ const a = document.createElement("a");
149
+ a.href = URL.createObjectURL(blob);
150
+ a.download = cfg.name + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".log";
151
+ a.click();
152
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
153
+ }
154
+
155
+ async function send(cmd, reply) {
156
+ const c = (cmd ?? command).trim();
157
+ if (!c) return;
158
+ setSending(true);
159
+ try {
160
+ const r = await api("/serial/devices/" + encodeURIComponent(cfg.name) + "/send", { method: "POST", body: { command: c, expectReply: reply ?? expectReply } });
161
+ if (reply ?? expectReply) toast("Reply in " + r.ms + " ms: " + r.reply, "success");
162
+ const next = [c, ...history.filter((h) => h !== c)].slice(0, 50);
163
+ setHistory(next);
164
+ try { localStorage.setItem(histKey, JSON.stringify(next)); } catch {}
165
+ setHistIdx(-1);
166
+ if (cmd === undefined) setCommand("");
167
+ onSent && onSent();
168
+ } catch (err) { toast(err.message, "error"); }
169
+ setSending(false);
170
+ }
171
+
172
+ function onKey(e) {
173
+ if (e.key === "Enter" && !sending) { e.preventDefault(); send(); }
174
+ else if (e.key === "ArrowUp" && history.length) {
175
+ e.preventDefault();
176
+ const i = Math.min(history.length - 1, histIdx + 1);
177
+ setHistIdx(i); setCommand(history[i]);
178
+ } else if (e.key === "ArrowDown") {
179
+ e.preventDefault();
180
+ const i = histIdx - 1;
181
+ setHistIdx(Math.max(-1, i)); setCommand(i >= 0 ? history[i] : "");
182
+ }
183
+ }
184
+
185
+ const q = query.trim();
186
+ const highlight = (text) => {
187
+ if (!q) return text;
188
+ const idx = text.toLowerCase().indexOf(q.toLowerCase());
189
+ if (idx === -1) return text;
190
+ return html`${text.slice(0, idx)}<mark>${text.slice(idx, idx + q.length)}</mark>${text.slice(idx + q.length)}`;
191
+ };
192
+ const connected = live && live.connected;
193
+
194
+ return html`<div>
195
+ <div className="serial-toolbar">
196
+ <div style=${{ position: "relative", flex: "1 1 200px" }}>
197
+ <input className="form-input" placeholder="Filter lines…" value=${query} onInput=${(e) => setQuery(e.target.value)} style=${{ paddingLeft: "30px" }} />
198
+ <span style=${{ position: "absolute", left: "10px", top: "8px", color: "var(--text-muted)" }}><${LuSearch} size=${14} /></span>
199
+ </div>
200
+ <select className="form-select" style=${{ width: "auto" }} value=${dir} onChange=${(e) => setDir(e.target.value)}>
201
+ <option value="all">All</option>
202
+ <option value="rx">Received</option>
203
+ <option value="tx">Sent</option>
204
+ </select>
205
+ <button className=${`chip-toggle${showTs ? " on" : ""}`} onClick=${() => setShowTs(!showTs)}>Time</button>
206
+ <button className=${`chip-toggle${hex ? " on" : ""}`} onClick=${() => setHex(!hex)}>Hex</button>
207
+ <button className=${`chip-toggle${paused ? " on" : ""}`} onClick=${togglePause} title=${paused ? "Resume" : "Pause"}>
208
+ ${paused ? html`<${LuPlay} size=${13} />` : html`<${LuPause} size=${13} />`} ${paused ? "Resume" + (pendingNew ? " (" + pendingNew + " new)" : "") : "Pause"}
209
+ </button>
210
+ <button className="chip-toggle" onClick=${() => setClearedAt(Date.now())} title="Clear the view"><${LuEraser} size=${13} /> Clear</button>
211
+ <button className="chip-toggle" onClick=${exportLog} disabled=${!lines.length} title="Download the visible lines"><${LuDownload} size=${13} /> Export</button>
212
+ </div>
213
+
214
+ <div className="serial-console" ref=${boxRef}
215
+ onScroll=${(e) => { const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }}>
216
+ ${lines.length === 0
217
+ ? html`<div className="sc-line text-muted">${connected ? (q ? "No lines match the filter." : "Waiting for data…") : "Not connected. Lines appear here as soon as the board connects."}</div>`
218
+ : lines.map((l, i) => html`<div key=${l.ts + "-" + i} className=${"sc-line sc-" + l.dir}>
219
+ ${showTs && html`<span className="sc-ts">${fmtTime(l.ts)}</span>`}
220
+ <span className="sc-dir">${l.dir === "tx" ? "→" : "←"}</span>
221
+ <span className=${hex ? "sc-text sc-hex" : "sc-text"}>${hex ? toHex(l.line) : highlight(l.line)}</span>
222
+ </div>`)}
223
+ </div>
224
+
225
+ <div className="flex gap-2" style=${{ marginTop: "10px", flexWrap: "wrap" }}>
226
+ <input className="form-input mono" style=${{ flex: "1 1 240px" }} value=${command} disabled=${!connected}
227
+ placeholder=${connected ? "Command (↑ ↓ history)" : "Not connected"}
228
+ onInput=${(e) => { setCommand(e.target.value); setHistIdx(-1); }} onKeyDown=${onKey} />
229
+ <button className="btn btn-primary" onClick=${() => send()} disabled=${sending || !connected || !command.trim()}>
230
+ ${sending ? html`<span className="spinner" />` : html`<${LuSend} size=${13} />`} Send
231
+ </button>
232
+ </div>
233
+ <div className="flex items-center justify-between" style=${{ marginTop: "8px", gap: "10px", flexWrap: "wrap" }}>
234
+ <label className="flex items-center gap-2 text-sm text-muted" style=${{ cursor: "pointer" }}>
235
+ <input type="checkbox" checked=${expectReply} onChange=${(e) => setExpectReply(e.target.checked)} /> Wait for reply (timeout ${cfg.timeout} ms)
236
+ </label>
237
+ ${(cfg.commands || []).length > 0 && html`<div className="cmd-grid">
238
+ ${cfg.commands.slice(0, 6).map((c) => html`<button key=${c.label} className="btn btn-secondary btn-sm" disabled=${!connected || sending}
239
+ onClick=${() => send(c.command, c.expectReply)} title=${c.command}>${c.label}</button>`)}
240
+ </div>`}
241
+ </div>
242
+ </div>`;
243
+ }
244
+
245
+ // ── 2. Saved commands ──────────────────────────────────────────────────────
246
+ function Commands({ cfg, live, onSaved }) {
247
+ const { toast } = useApp();
248
+ const [list, setList] = useState(cfg.commands || []);
249
+ const [label, setLabel] = useState("");
250
+ const [command, setCommand] = useState("");
251
+ const [expectReply, setExpectReply] = useState(true);
252
+ const [saving, setSaving] = useState(false);
253
+ const [busy, setBusy] = useState(null);
254
+ const history = (() => { try { return JSON.parse(localStorage.getItem("_4bnode_serial_hist_" + cfg.name) || "[]"); } catch { return []; } })();
255
+ useEffect(() => setList(cfg.commands || []), [cfg.name, JSON.stringify(cfg.commands || [])]);
256
+
257
+ async function persist(next) {
258
+ setSaving(true);
259
+ try {
260
+ await saveDevice(cfg, { commands: next });
261
+ setList(next);
262
+ onSaved();
263
+ toast("Commands saved", "success");
264
+ } catch (err) { toast(err.message, "error"); }
265
+ setSaving(false);
266
+ }
267
+
268
+ async function run(c) {
269
+ setBusy(c.label);
270
+ try {
271
+ const r = await api("/serial/devices/" + encodeURIComponent(cfg.name) + "/send", { method: "POST", body: { command: c.command, expectReply: c.expectReply } });
272
+ toast(c.expectReply ? c.label + " → " + r.reply : "Sent " + c.label, "success");
273
+ } catch (err) { toast(err.message, "error"); }
274
+ setBusy(null);
275
+ }
276
+
277
+ const connected = live && live.connected;
278
+ return html`<div>
279
+ <div className="card-subtitle mb-3">One-click commands for this device. Saved in <span className="mono">devices.json</span> and shared with everyone using this project.</div>
280
+ ${list.length === 0
281
+ ? html`<div className="ov-empty" style=${{ border: "1px dashed var(--border)", borderRadius: "8px", marginBottom: "14px" }}>No saved commands yet.</div>`
282
+ : html`<div style=${{ border: "1px solid var(--border)", borderRadius: "8px", marginBottom: "14px" }}>
283
+ ${list.map((c, i) => html`<div key=${i} className="ov-list-row" style=${{ height: "44px" }}>
284
+ <button className="btn btn-secondary btn-sm" disabled=${!connected || busy !== null} onClick=${() => run(c)} style=${{ minWidth: "90px" }}>
285
+ ${busy === c.label ? html`<span className="spinner" />` : html`<${LuSend} size=${12} />`} ${c.label}
286
+ </button>
287
+ <span className="ov-path">${c.command}</span>
288
+ <span className="text-sm text-muted">${c.expectReply ? "waits for reply" : "no reply"}</span>
289
+ <button className="btn btn-ghost btn-sm btn-icon" title="Remove" disabled=${saving} onClick=${() => persist(list.filter((_, j) => j !== i))}><${LuTrash2} size=${13} /></button>
290
+ </div>`)}
291
+ </div>`}
292
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
293
+ <input className="form-input" style=${{ flex: "0 1 160px" }} placeholder="Label (e.g. Home)" value=${label} onInput=${(e) => setLabel(e.target.value)} />
294
+ <input className="form-input mono" style=${{ flex: "1 1 200px" }} placeholder="Command (e.g. G28)" value=${command} onInput=${(e) => setCommand(e.target.value)} list=${"hist-" + cfg.name} />
295
+ <datalist id=${"hist-" + cfg.name}>${history.map((h) => html`<option key=${h} value=${h} />`)}</datalist>
296
+ <label className="flex items-center gap-2 text-sm text-muted"><input type="checkbox" checked=${expectReply} onChange=${(e) => setExpectReply(e.target.checked)} /> Wait for reply</label>
297
+ <button className="btn btn-primary" disabled=${saving || !command.trim()}
298
+ onClick=${() => { persist([...list, { label: label.trim() || command.trim(), command: command.trim(), expectReply }]); setLabel(""); setCommand(""); }}>
299
+ <${LuPlus} size=${13} /> Add
300
+ </button>
301
+ </div>
302
+ ${history.length > 0 && html`<div className="text-sm text-muted" style=${{ marginTop: "8px" }}>Recent commands from the monitor are suggested as you type.</div>`}
303
+ </div>`;
304
+ }
305
+
306
+ // ── 3. Live chart ──────────────────────────────────────────────────────────
307
+ // Validated categorical palette (dark steps), fixed order, max 4 series.
308
+ const SERIES_COLORS = ["#3987e5", "#d95926", "#199e70", "#c98500"];
309
+ const NUM_PAIR = /([A-Za-z_][\w.\-]*)\s*[:=]\s*(-?\d+(?:\.\d+)?)/g;
310
+ const BARE = /^\s*-?\d+(?:\.\d+)?(?:\s*[,;\s]\s*-?\d+(?:\.\d+)?)*\s*$/;
311
+
312
+ // Numbers in a line: "temp=24.5 hum:60" -> {temp, hum}; "1.2, 3.4" -> {v1, v2}.
313
+ export function parseNumbers(line) {
314
+ const out = {};
315
+ let m;
316
+ NUM_PAIR.lastIndex = 0;
317
+ while ((m = NUM_PAIR.exec(line))) out[m[1]] = parseFloat(m[2]);
318
+ if (Object.keys(out).length) return out;
319
+ if (BARE.test(line)) line.trim().split(/[\s,;]+/).forEach((n, i) => { out["v" + (i + 1)] = parseFloat(n); });
320
+ return out;
321
+ }
322
+
323
+ function niceTicks(min, max, count = 4) {
324
+ if (min === max) { min -= 1; max += 1; }
325
+ const step0 = (max - min) / count;
326
+ const mag = Math.pow(10, Math.floor(Math.log10(step0)));
327
+ const step = [1, 2, 2.5, 5, 10].map((k) => k * mag).find((s) => s >= step0) || step0;
328
+ const lo = Math.floor(min / step) * step;
329
+ const hi = Math.ceil(max / step) * step;
330
+ const ticks = [];
331
+ for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.round(v * 1e6) / 1e6);
332
+ return ticks;
333
+ }
334
+
335
+ function Chart({ cfg }) {
336
+ const version = useSerialStream();
337
+ const [picked, setPicked] = useState(null);
338
+ const [windowSec, setWindowSec] = useState(120);
339
+ const [hover, setHover] = useState(null);
340
+ const [W, setW] = useState(640);
341
+ const boxRef = useRef(null);
342
+ const [, refresh] = useState(0);
343
+ useEffect(() => { backfill(cfg.name).then(() => refresh((n) => n + 1)); }, [cfg.name]);
344
+ useEffect(() => {
345
+ const el = boxRef.current;
346
+ if (!el) return;
347
+ const ro = new ResizeObserver(() => setW(Math.max(280, el.clientWidth)));
348
+ ro.observe(el);
349
+ return () => ro.disconnect();
350
+ }, []);
351
+
352
+ const since = Date.now() - windowSec * 1000;
353
+ const points = useMemo(() => (buffers.get(cfg.name) || [])
354
+ .filter((l) => l.dir === "rx" && l.ts >= since)
355
+ .map((l) => ({ ts: l.ts, v: parseNumbers(l.line) }))
356
+ .filter((p) => Object.keys(p.v).length), [version, cfg.name, windowSec]);
357
+ const allSeries = useMemo(() => {
358
+ const seen = [];
359
+ points.forEach((p) => Object.keys(p.v).forEach((k) => { if (!seen.includes(k)) seen.push(k); }));
360
+ return seen;
361
+ }, [points]);
362
+ const series = (picked || allSeries).filter((s) => allSeries.includes(s)).slice(0, 4);
363
+
364
+ const H = 260, padL = 44, padR = 12, padT = 10, padB = 24;
365
+ const plotW = W - padL - padR, plotH = H - padT - padB;
366
+ const vals = points.flatMap((p) => series.map((s) => p.v[s]).filter((x) => typeof x === "number"));
367
+ const ticks = vals.length ? niceTicks(Math.min(...vals), Math.max(...vals)) : [0, 1];
368
+ const yMin = ticks[0], yMax = ticks[ticks.length - 1];
369
+ const t0 = since, t1 = Date.now();
370
+ const x = (ts) => padL + ((ts - t0) / (t1 - t0)) * plotW;
371
+ const y = (v) => padT + plotH - ((v - yMin) / (yMax - yMin || 1)) * plotH;
372
+
373
+ function onMove(e) {
374
+ const r = e.currentTarget.getBoundingClientRect();
375
+ const ts = t0 + ((e.clientX - r.left - padL) / plotW) * (t1 - t0);
376
+ let best = null;
377
+ for (const p of points) if (!best || Math.abs(p.ts - ts) < Math.abs(best.ts - ts)) best = p;
378
+ setHover(best);
379
+ }
380
+
381
+ return html`<div>
382
+ <div className="flex items-center justify-between mb-3" style=${{ gap: "10px", flexWrap: "wrap" }}>
383
+ <div className="card-subtitle" style=${{ margin: 0 }}>Numbers in received lines, e.g. <span className="mono">temp=24.5</span>, <span className="mono">T:210 B:60</span> or <span className="mono">1.2, 3.4</span>.</div>
384
+ <select className="form-select" style=${{ width: "auto", height: "30px" }} value=${windowSec} onChange=${(e) => setWindowSec(Number(e.target.value))}>
385
+ <option value="30">Last 30 s</option><option value="120">Last 2 min</option><option value="600">Last 10 min</option><option value="3600">Last hour</option>
386
+ </select>
387
+ </div>
388
+ ${allSeries.length > 0 && html`<div className="flex gap-2 mb-3" style=${{ flexWrap: "wrap" }}>
389
+ ${allSeries.map((s) => {
390
+ const on = series.includes(s);
391
+ const idx = series.indexOf(s);
392
+ return html`<button key=${s} className=${`series-pick${on ? " on" : ""}`}
393
+ onClick=${() => {
394
+ const cur = series.slice();
395
+ const next = on ? cur.filter((x) => x !== s) : cur.length < 4 ? [...cur, s] : cur;
396
+ setPicked(next);
397
+ }} title=${on ? "Hide" : series.length >= 4 ? "Up to 4 series at once" : "Show"}>
398
+ <i style=${{ background: on ? SERIES_COLORS[idx] : "var(--border-strong)" }}></i>${s}
399
+ </button>`;
400
+ })}
401
+ </div>`}
402
+ ${series.length >= 2 && html`<div className="chart-legend">${series.map((s, i) => html`<span key=${s}><i style=${{ background: SERIES_COLORS[i] }}></i>${s}</span>`)}</div>`}
403
+ <div ref=${boxRef} style=${{ position: "relative" }}>
404
+ ${points.length === 0
405
+ ? html`<div className="ov-empty" style=${{ height: H + "px", display: "grid", placeItems: "center", border: "1px dashed var(--border)", borderRadius: "8px" }}>
406
+ No numbers received in this window yet. Have the board print values like <span className="mono">temp=24.5</span>.
407
+ </div>`
408
+ : html`<svg width=${W} height=${H} style=${{ display: "block" }} onMouseMove=${onMove} onMouseLeave=${() => setHover(null)}
409
+ role="img" aria-label=${"Live values for " + series.join(", ")}>
410
+ ${ticks.map((t) => html`<g key=${t}>
411
+ <line x1=${padL} x2=${W - padR} y1=${y(t)} y2=${y(t)} stroke="var(--border)" />
412
+ <text x=${padL - 6} y=${y(t) + 4} textAnchor="end" fill="var(--text-muted)" fontSize="10.5">${t}</text>
413
+ </g>`)}
414
+ <text x=${padL} y=${H - 6} fill="var(--text-muted)" fontSize="10.5">${new Date(t0).toLocaleTimeString()}</text>
415
+ <text x=${W - padR} y=${H - 6} textAnchor="end" fill="var(--text-muted)" fontSize="10.5">now</text>
416
+ ${series.map((s, i) => {
417
+ const pts = points.filter((p) => typeof p.v[s] === "number");
418
+ if (!pts.length) return null;
419
+ const d = "M" + pts.map((p) => x(p.ts).toFixed(1) + " " + y(p.v[s]).toFixed(1)).join(" L");
420
+ const last = pts[pts.length - 1];
421
+ return html`<g key=${s}>
422
+ <path d=${d} fill="none" stroke=${SERIES_COLORS[i]} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
423
+ <circle cx=${x(last.ts)} cy=${y(last.v[s])} r="4" fill=${SERIES_COLORS[i]} stroke="var(--bg-card)" strokeWidth="2" />
424
+ </g>`;
425
+ })}
426
+ ${hover && html`<line x1=${x(hover.ts)} x2=${x(hover.ts)} y1=${padT} y2=${padT + plotH} stroke="var(--text-muted)" strokeDasharray="3 3" />`}
427
+ ${hover && series.map((s, i) => typeof hover.v[s] === "number" && html`<circle key=${s} cx=${x(hover.ts)} cy=${y(hover.v[s])} r="4" fill=${SERIES_COLORS[i]} stroke="var(--bg-card)" strokeWidth="2" />`)}
428
+ </svg>`}
429
+ ${hover && html`<div className="ov-tip" style=${{ left: Math.min(W - 80, Math.max(80, x(hover.ts))) + "px", top: "18px", transform: "translate(-50%, 0)" }}>
430
+ <div className="muted">${fmtTime(hover.ts)}</div>
431
+ ${series.map((s, i) => typeof hover.v[s] === "number" && html`<div key=${s}><i style=${{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", background: SERIES_COLORS[i], marginRight: "6px" }}></i>${s} <b>${hover.v[s]}</b></div>`)}
432
+ </div>`}
433
+ </div>
434
+ </div>`;
435
+ }
436
+
437
+ // ── 4. Health ──────────────────────────────────────────────────────────────
438
+ function Health({ cfg, live }) {
439
+ const s = (live && live.stats) || {};
440
+ const tile = (label, value, sub) => html`<div className="ov-kpi" key=${label}>
441
+ <div className="ov-kpi-label">${label}</div>
442
+ <div className="ov-kpi-value">${value}</div>
443
+ <div className="ov-kpi-sub">${sub || " "}</div>
444
+ </div>`;
445
+ return html`<div>
446
+ <div className="serial-stats mb-4">
447
+ ${tile("Status", live ? live.status : "—", live && live.path ? live.path : "")}
448
+ ${tile("Uptime", live && live.connected ? formatDuration(Math.round((s.uptimeMs || 0) / 1000)) : "—", live && live.connectedAt ? "since " + new Date(live.connectedAt).toLocaleTimeString() : "")}
449
+ ${tile("Reconnects", s.reconnects ?? 0, (s.connects ?? 0) + " connects total")}
450
+ ${tile("Last seen", s.lastRx ? timeAgo(s.lastRx) : "never", "last line received")}
451
+ ${tile("Received", formatBytes(s.bytesIn || 0), (s.linesIn || 0) + " lines")}
452
+ ${tile("Sent", formatBytes(s.bytesOut || 0), (s.linesOut || 0) + " lines")}
453
+ ${tile("Avg reply", s.avgReplyMs != null ? s.avgReplyMs + " ms" : "—", (s.replies || 0) + " replies")}
454
+ ${tile("Timeouts", s.timeouts || 0, "no reply within " + cfg.timeout + " ms")}
455
+ </div>
456
+ <div className="text-sm text-muted">Counters reset when the app restarts. ${live && live.error && live.status !== "connected" ? html`<span style=${{ color: "var(--warning)" }}>Last error: ${live.error}</span>` : null}</div>
457
+ </div>`;
458
+ }
459
+
460
+ // ── 7. Rules ───────────────────────────────────────────────────────────────
461
+ const EMPTY_RULE = { when: "contains", pattern: "", action: "webhook", url: "", target: "", command: "", cooldownMs: 2000, enabled: true };
462
+
463
+ function Rules({ cfg, live, devices, onSaved }) {
464
+ const { toast } = useApp();
465
+ const [rules, setRules] = useState(cfg.rules || []);
466
+ const [draft, setDraft] = useState(null); // { index | null, ...rule }
467
+ const [saving, setSaving] = useState(false);
468
+ useEffect(() => setRules(cfg.rules || []), [cfg.name, JSON.stringify(cfg.rules || [])]);
469
+ const stats = (live && live.rules) || {};
470
+
471
+ async function persist(next) {
472
+ setSaving(true);
473
+ try {
474
+ await saveDevice(cfg, { rules: next });
475
+ setRules(next);
476
+ setDraft(null);
477
+ onSaved();
478
+ toast("Rules saved", "success");
479
+ } catch (err) { toast(err.message, "error"); }
480
+ setSaving(false);
481
+ }
482
+ const setD = (patch) => setDraft((d) => ({ ...d, ...patch }));
483
+ const describe = (r) => (r.action === "webhook" ? "POST " + r.url : "send \"" + r.command + "\" to " + r.target);
484
+
485
+ return html`<div>
486
+ <div className="card-subtitle mb-3">When a line from <span className="mono">${cfg.name}</span> matches, call a webhook or send a command to a device. Runs inside your app, even with the dashboard closed.</div>
487
+ ${rules.length > 0 && html`<div style=${{ border: "1px solid var(--border)", borderRadius: "8px", marginBottom: "14px" }}>
488
+ ${rules.map((r, i) => {
489
+ const st = stats[r.id];
490
+ return html`<div key=${r.id} className="ov-list-row" style=${{ height: "auto", padding: "10px 16px", alignItems: "flex-start" }}>
491
+ <label className="form-toggle" style=${{ marginTop: "2px" }}>
492
+ <input type="checkbox" checked=${r.enabled !== false} disabled=${saving}
493
+ onChange=${(e) => persist(rules.map((x, j) => (j === i ? { ...x, enabled: e.target.checked } : x)))} />
494
+ <span className="slider"></span>
495
+ </label>
496
+ <div style=${{ flex: 1, minWidth: 0 }}>
497
+ <div className="text-sm">When line <b>${r.when}</b> <span className="mono">${r.pattern}</span> → ${describe(r)}</div>
498
+ <div className="text-sm text-muted" style=${{ marginTop: "2px" }}>
499
+ ${st ? "Fired " + st.fired + "× · last " + timeAgo(st.lastFired) : "Not fired yet"} · cooldown ${r.cooldownMs} ms
500
+ ${st && st.lastError ? html` · <span style=${{ color: "var(--error)" }}>${st.lastError}</span>` : null}
501
+ </div>
502
+ </div>
503
+ <button className="btn btn-ghost btn-sm btn-icon" title="Edit" onClick=${() => setDraft({ index: i, ...r })}><${LuPencil} size=${13} /></button>
504
+ <button className="btn btn-ghost btn-sm btn-icon" title="Delete" disabled=${saving} onClick=${() => persist(rules.filter((_, j) => j !== i))}><${LuTrash2} size=${13} /></button>
505
+ </div>`;
506
+ })}
507
+ </div>`}
508
+
509
+ ${draft
510
+ ? html`<div style=${{ border: "1px solid var(--border-strong)", borderRadius: "8px", padding: "14px" }}>
511
+ <div className="grid-2">
512
+ <div className="form-group">
513
+ <label className="form-label">When a received line</label>
514
+ <select className="form-select" value=${draft.when} onChange=${(e) => setD({ when: e.target.value })}>
515
+ <option value="contains">contains</option><option value="startsWith">starts with</option>
516
+ <option value="equals">equals</option><option value="regex">matches regex</option>
517
+ </select>
518
+ </div>
519
+ <div className="form-group">
520
+ <label className="form-label">Text</label>
521
+ <input className="form-input mono" value=${draft.pattern} placeholder="ALARM" onInput=${(e) => setD({ pattern: e.target.value })} />
522
+ </div>
523
+ <div className="form-group">
524
+ <label className="form-label">Then</label>
525
+ <select className="form-select" value=${draft.action} onChange=${(e) => setD({ action: e.target.value })}>
526
+ <option value="webhook">Call a webhook (POST JSON)</option>
527
+ <option value="send">Send a command to a device</option>
528
+ </select>
529
+ </div>
530
+ <div className="form-group">
531
+ <label className="form-label">Cooldown (ms)</label>
532
+ <input className="form-input" type="number" min="0" value=${draft.cooldownMs} onInput=${(e) => setD({ cooldownMs: Number(e.target.value) })} />
533
+ </div>
534
+ </div>
535
+ ${draft.action === "webhook"
536
+ ? html`<div className="form-group">
537
+ <label className="form-label">Webhook URL</label>
538
+ <input className="form-input mono" value=${draft.url} placeholder="https://example.com/hooks/serial" onInput=${(e) => setD({ url: e.target.value })} />
539
+ <div className="text-sm text-muted" style=${{ marginTop: "6px" }}>Body: <span className="mono">{ "device", "line", "rule", "ts" }</span></div>
540
+ </div>`
541
+ : html`<div className="grid-2">
542
+ <div className="form-group">
543
+ <label className="form-label">Device</label>
544
+ <select className="form-select" value=${draft.target} onChange=${(e) => setD({ target: e.target.value })}>
545
+ <option value="">Choose…</option>
546
+ ${devices.map((d) => html`<option key=${d} value=${d}>${d}</option>`)}
547
+ </select>
548
+ </div>
549
+ <div className="form-group">
550
+ <label className="form-label">Command</label>
551
+ <input className="form-input mono" value=${draft.command} placeholder="LED:ON" onInput=${(e) => setD({ command: e.target.value })} />
552
+ </div>
553
+ </div>`}
554
+ <div className="flex gap-2">
555
+ <button className="btn btn-primary" disabled=${saving} onClick=${() => {
556
+ const { index, ...rule } = draft;
557
+ persist(index == null ? [...rules, rule] : rules.map((x, j) => (j === index ? rule : x)));
558
+ }}>${saving ? html`<span className="spinner" />` : null} Save rule</button>
559
+ <button className="btn btn-ghost" onClick=${() => setDraft(null)}>Cancel</button>
560
+ </div>
561
+ </div>`
562
+ : html`<button className="btn btn-secondary" onClick=${() => setDraft({ index: null, ...EMPTY_RULE, target: cfg.name })}><${LuPlus} size=${13} /> Add rule</button>`}
563
+ </div>`;
564
+ }
565
+
566
+ // ── 6. Use it from your app ────────────────────────────────────────────────
567
+ function Use({ cfg }) {
568
+ const { toast } = useApp();
569
+ const o = window.location.origin;
570
+ const blocks = [
571
+ { title: "Send a command and get the reply", code: "curl -X POST " + o + "/api/serial/devices/" + cfg.name + "/send \\\n -H 'x-api-key: <your-api-key>' -H 'Content-Type: application/json' \\\n -d '{\"command\": \"" + (cfg.idCommand || "ID?") + "\"}'" },
572
+ { title: "Live lines in a browser or Node (Server-Sent Events)", code: "const res = await fetch('" + o + "/api/serial/devices/" + cfg.name + "/stream', {\n headers: { 'x-api-key': '<your-api-key>' },\n});\nconst reader = res.body.pipeThrough(new TextDecoderStream()).getReader();\nfor (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n for (const msg of value.split('\\n\\n')) {\n if (msg.startsWith('data: ')) console.log(JSON.parse(msg.slice(6))); // { device, dir, line, ts }\n }\n}" },
573
+ { title: "Inside your server code", code: "import { getDevice, onSerialLine } from './src/serial/devices.js';\n\nconst reply = await getDevice('" + cfg.name + "').send('" + (cfg.idCommand || "ID?") + "');\nonSerialLine((e) => {\n if (e.device === '" + cfg.name + "' && e.dir === 'rx') console.log(e.line);\n});" },
574
+ ];
575
+ return html`<div style=${{ display: "flex", flexDirection: "column", gap: "14px" }}>
576
+ ${blocks.map((b) => html`<div key=${b.title}>
577
+ <div className="flex items-center justify-between mb-2">
578
+ <span className="text-sm" style=${{ fontWeight: 600 }}>${b.title}</span>
579
+ <button className="btn btn-ghost btn-sm" onClick=${() => copyToClipboard(b.code).then(() => toast("Copied", "success"))}><${LuCopy} size=${13} /> Copy</button>
580
+ </div>
581
+ <pre className="code-block" style=${{ whiteSpace: "pre-wrap" }}>${b.code}</pre>
582
+ </div>`)}
583
+ <div className="text-sm text-muted">All routes need the <span className="mono">x-api-key</span> header (Security → API keys). The stream also exists for every device at <span className="mono">/api/serial/stream</span>.</div>
584
+ </div>`;
585
+ }
586
+
587
+ // ── Workspace ──────────────────────────────────────────────────────────────
588
+ const TABS = [
589
+ { id: "monitor", label: "Monitor" },
590
+ { id: "commands", label: "Commands" },
591
+ { id: "chart", label: "Chart" },
592
+ { id: "health", label: "Health" },
593
+ { id: "rules", label: "Rules" },
594
+ { id: "use", label: "Use in app" },
595
+ ];
596
+
597
+ export function SerialWorkspace({ cfg, live, devices, statusBadge, matchText, onEdit, onDelete, onSaved }) {
598
+ const [tab, setTab] = useState(() => { try { return localStorage.getItem("_4bnode_serial_tab") || "monitor"; } catch { return "monitor"; } });
599
+ useEffect(() => { try { localStorage.setItem("_4bnode_serial_tab", tab); } catch {} }, [tab]);
600
+
601
+ return html`<div className="card" style=${{ padding: 0 }}>
602
+ <div style=${{ padding: "16px 18px 0" }}>
603
+ <div className="flex items-center justify-between" style=${{ gap: "10px" }}>
604
+ <div style=${{ minWidth: 0 }}>
605
+ <div className="flex items-center gap-2">
606
+ <span className="mono" style=${{ fontWeight: 600, fontSize: "15px" }}>${cfg.name}</span>
607
+ <span className=${`badge ${statusBadge.cls}`}>${statusBadge.label}</span>
608
+ </div>
609
+ <div className="text-sm text-muted" style=${{ marginTop: "3px" }}>
610
+ ${live && live.path ? html`on <span className="mono">${live.path}</span> · ` : null}found by ${matchText} · ${cfg.baudRate} baud · ID <span className="mono">${cfg.idCommand}</span>
611
+ </div>
612
+ ${live && live.error && live.status !== "connected" && html`<div className="text-sm" style=${{ color: "var(--warning)", marginTop: "3px" }}>${live.error}</div>`}
613
+ </div>
614
+ <div className="flex gap-2">
615
+ <button className="btn btn-ghost btn-sm" onClick=${onEdit}><${LuPencil} size=${13} /> Edit</button>
616
+ <button className="btn btn-danger btn-sm btn-icon" onClick=${onDelete} title="Remove device"><${LuTrash2} size=${14} /></button>
617
+ </div>
618
+ </div>
619
+ <div className="api-tester-tabs" style=${{ marginTop: "12px", overflowX: "auto" }}>
620
+ ${TABS.map((t) => html`<button key=${t.id} className=${`api-tester-tab${tab === t.id ? " active" : ""}`} onClick=${() => setTab(t.id)}>
621
+ ${t.label}${t.id === "rules" && (cfg.rules || []).length ? " (" + cfg.rules.length + ")" : ""}${t.id === "commands" && (cfg.commands || []).length ? " (" + cfg.commands.length + ")" : ""}
622
+ </button>`)}
623
+ </div>
624
+ </div>
625
+ <div style=${{ padding: "16px 18px 18px" }}>
626
+ ${tab === "monitor" && html`<${Monitor} cfg=${cfg} live=${live} />`}
627
+ ${tab === "commands" && html`<${Commands} cfg=${cfg} live=${live} onSaved=${onSaved} />`}
628
+ ${tab === "chart" && html`<${Chart} cfg=${cfg} />`}
629
+ ${tab === "health" && html`<${Health} cfg=${cfg} live=${live} />`}
630
+ ${tab === "rules" && html`<${Rules} cfg=${cfg} live=${live} devices=${devices} onSaved=${onSaved} />`}
631
+ ${tab === "use" && html`<${Use} cfg=${cfg} />`}
632
+ </div>
633
+ </div>`;
634
+ }