Major changes: logic add to panel, local variables, panel histor, users management...

This commit is contained in:
Martino Ferrari
2026-06-18 17:37:04 +02:00
parent 71430bc3b0
commit aba394b84d
54 changed files with 6104 additions and 1166 deletions
+38 -11
View File
@@ -1,8 +1,10 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from '../lib/ws';
import { getSignalStore } from '../lib/stores';
import type { Widget } from '../lib/types';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { logicEngine } from '../lib/logic';
import { useAuth, canWrite } from '../lib/auth';
import type { Widget, SignalMeta } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
@@ -15,8 +17,12 @@ export default function Button({ widget, onContextMenu }: Props) {
const mode = widget.options['mode'] ?? 'oneshot';
const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1');
const confirmOpt = widget.options['confirm'] === 'true';
// Optional panel-logic sequence fired on click (in addition to any signal write).
const action = widget.options['action'] ?? '';
const me = useAuth();
const [signalVal, setSignalVal] = useState<any>(null);
const [meta, setMeta] = useState<SignalMeta | null>(null);
// Subscribe to signal only for persistent mode (to track whether it is active)
useEffect(() => {
@@ -24,6 +30,20 @@ export default function Button({ widget, onContextMenu }: Props) {
return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value));
}, [sigRef?.ds, sigRef?.name, mode]);
// Track the target signal's writability so the button can be disabled when
// the user has no permission to write it (real signals only; local panel
// vars are written client-side and have no backend metadata).
useEffect(() => {
if (!sigRef || sigRef.ds === 'local') return;
return getMetaStore(sigRef).subscribe(setMeta);
}, [sigRef?.ds, sigRef?.name]);
// A button that drives a real signal is disabled when the user cannot write
// it (read-only access, or the signal reports it is not writable). Buttons
// that only run a logic action — or target a local var — stay enabled.
const drivesRealSignal = !!sigRef && sigRef.ds !== 'local';
const writeAllowed = !drivesRealSignal || (canWrite(me.level) && meta?.writable !== false);
const parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue);
const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue);
@@ -38,18 +58,23 @@ export default function Button({ widget, onContextMenu }: Props) {
}
function execute() {
if (mode === 'setReset') {
doWrite(parsedSend);
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
} else if (mode === 'persistent') {
doWrite(isPressed ? parsedReset : parsedSend);
} else {
doWrite(parsedSend);
if (sigRef) {
if (mode === 'setReset') {
doWrite(parsedSend);
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
} else if (mode === 'persistent') {
doWrite(isPressed ? parsedReset : parsedSend);
} else {
doWrite(parsedSend);
}
}
if (action) logicEngine.runAction(action);
}
function handleClick() {
if (!sigRef) return;
// A button may drive a signal, a logic sequence, or both.
if (!sigRef && !action) return;
if (!writeAllowed) return;
if (confirmOpt) {
if (window.confirm(`Send ${label}?`)) execute();
} else {
@@ -63,7 +88,9 @@ export default function Button({ widget, onContextMenu }: Props) {
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}>
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}
disabled={!writeAllowed}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>
{label}
{mode === 'setReset' && (
<span class="btn-mode-hint">{resetTimeSec}s</span>
+72 -29
View File
@@ -5,6 +5,7 @@ import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
import { formatValue } from '../lib/format';
import { fftMag, resampleUniform } from '../lib/fft';
import type { Widget, SignalRef } from '../lib/types';
interface Props {
@@ -81,7 +82,6 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
const histValues: number[][] = signals.map(() => []);
const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? '';
@@ -180,12 +180,14 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
switch (plotType) {
case 'histogram': {
const { labels, counts } = buildHistogram(histValues);
// Distribution over all buffered samples (ring-buffer bounded).
const { labels, counts } = buildHistogram(buffers.map(b => b.values));
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
xAxis: { type: 'category', name: 'Value', nameLocation: 'middle', nameGap: 24, data: labels, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', name: 'Count', axisLine, axisLabel, splitLine },
series: counts.map((d, i) => ({
name: signals[i]?.name ?? `S${i}`,
type: 'bar',
@@ -198,6 +200,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
series: [{
@@ -210,32 +213,62 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
};
}
case 'fft': {
// Single-sided magnitude spectrum per signal, computed from the
// windowed samples resampled onto a uniform grid.
const N = 256;
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel },
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
series: buffers.map((b, i) => ({
name: signals[i]?.name ?? `S${i}`,
type: 'line',
data: b.values.length ? b.values[b.values.length - 1] : [],
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
})),
grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
xAxis: { type: 'value', name: 'Hz', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', name: 'Mag', axisLine, axisLabel, splitLine },
series: buffers.map((b, i) => {
const { ts, vals } = windowedSlice(b, timeWindow);
let data: [number, number][] = [];
if (vals.length >= 4) {
const { sampled, dt } = resampleUniform(ts, vals, N);
const mag = fftMag(sampled);
const fs = dt > 0 ? 1 / dt : 1; // sample rate (Hz)
data = mag.map((m, k) => [(k * fs) / N, m]);
}
return {
name: signals[i]?.name ?? `S${i}`,
type: 'line',
data,
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
};
}),
};
}
case 'logic': {
// Logic-analyser style: each signal is a 0/1 step trace stacked on its
// own lane, with a relative-time x-axis (seconds before now).
const nowSec = Date.now() / 1000;
const lane = 1.4;
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel },
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine },
grid: { left: 90, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
xAxis: {
type: 'value', min: 'dataMin', max: 0, axisLine, splitLine,
axisLabel: { color: '#64748b', formatter: (v: number) => `${v.toFixed(0)}s` },
},
yAxis: {
type: 'value', min: 0, max: Math.max(lane, signals.length * lane),
interval: lane, axisLine, splitLine,
axisLabel: {
color: '#64748b',
formatter: (v: number) => signals[Math.round(v / lane)]?.name ?? '',
},
},
series: buffers.map((b, i) => {
const { ts, vals } = windowedSlice(b, timeWindow);
const base = i * lane;
return {
name: signals[i]?.name ?? `S${i}`,
type: 'line', step: 'end',
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
data: ts.map((t, j) => [t - nowSec, base + (vals[j] > 0 ? 1 : 0)]),
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
};
@@ -243,22 +276,33 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
};
}
case 'waterfall': {
const ROWS = 32;
// Spectrogram of the first signal: split the windowed samples into
// ROWS time frames and FFT each frame (freq on x, time on y).
const ROWS = 24, FRAME = 32;
const b = buffers[0];
const step = Math.max(1, Math.floor(b.values.length / ROWS));
const flatData: [number, number, number][] = [];
for (let ri = 0; ri < ROWS; ri++) {
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step;
if (idx < 0) continue;
const row = b.values[idx];
const arr = Array.isArray(row) ? row : [row];
arr.forEach((val, ci) => flatData.push([ci, ri, val]));
let maxMag = 0;
if (b && b.values.length >= FRAME) {
const { sampled } = resampleUniform(b.timestamps, b.values, ROWS * FRAME);
for (let r = 0; r < ROWS; r++) {
const mag = fftMag(sampled.slice(r * FRAME, (r + 1) * FRAME));
for (let k = 0; k < mag.length; k++) {
flatData.push([k, r, mag[k]]);
if (mag[k] > maxMag) maxMag = mag[k];
}
}
}
const bins = FRAME >> 1;
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
xAxis: { type: 'value', axisLabel },
yAxis: { type: 'value', axisLabel },
grid: { left: 56, right: 70, top: 12, bottom: 36 },
visualMap: {
min: 0, max: maxMag || 1, calculable: true, right: 8, top: 'center',
dimension: 2, textStyle: { color: '#94a3b8' },
inRange: { color: ['#0f1117', '#1e3a8a', '#4a9eff', '#22c55e', '#f59e0b', '#ef4444'] },
},
xAxis: { type: 'category', name: 'Freq bin', nameLocation: 'middle', nameGap: 22, data: Array.from({ length: bins }, (_, k) => String(k)), axisLine, axisLabel },
yAxis: { type: 'category', name: 'Time', data: Array.from({ length: ROWS }, (_, r) => String(r - ROWS + 1)), axisLine, axisLabel },
series: [{ type: 'heatmap', data: flatData }],
};
}
@@ -346,7 +390,6 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (!isNaN(v)) {
pushSample(buffers[i], ts, v);
if (plotType === 'histogram') histValues[i].push(v);
}
if (echart) echart.setOption(echartsOption(), { notMerge: true });
}
@@ -372,7 +415,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '']);
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
return (
<div
+18 -4
View File
@@ -2,6 +2,7 @@ import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
import { useAuth, canWrite } from '../lib/auth';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
@@ -19,6 +20,7 @@ interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function SetValue({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const me = useAuth();
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
const [inputValue, setInputValue] = useState('');
@@ -38,6 +40,14 @@ export default function SetValue({ widget, onContextMenu }: Props) {
const isEnum = !!(meta?.enumStrings && meta.enumStrings.length > 0);
const quality = sv.quality;
// The control is read-only when the current user cannot write this signal:
// either their global access is read-only, or the signal's metadata reports
// it is not writable. Panel-local vars are written client-side (no backend
// meta) so they stay editable. meta.writable === false only once known, so
// the control isn't prematurely disabled while metadata is still loading.
const isLocal = sigRef?.ds === 'local';
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
function displayValue(): string {
const v = sv.value;
if (v === null || v === undefined) return '---';
@@ -52,7 +62,7 @@ export default function SetValue({ widget, onContextMenu }: Props) {
}
function doWrite(raw: string) {
if (!sigRef || !raw.trim()) return;
if (!sigRef || !writeAllowed || !raw.trim()) return;
const val = isNumeric ? parseFloat(raw) : raw;
wsClient.write(sigRef, val);
setInputValue('');
@@ -84,7 +94,7 @@ export default function SetValue({ widget, onContextMenu }: Props) {
const [enumSelected, setEnumSelected] = useState(currentEnumIdx >= 0 ? String(currentEnumIdx) : '0');
function handleEnumSet() {
if (!sigRef) return;
if (!sigRef || !writeAllowed) return;
const doEnumWrite = () => { wsClient.write(sigRef, parseFloat(enumSelected)); };
if (confirm) {
const display = meta?.enumStrings?.[parseInt(enumSelected, 10)] ?? enumSelected;
@@ -107,13 +117,15 @@ export default function SetValue({ widget, onContextMenu }: Props) {
<select
class="sv-input sv-select"
value={enumSelected}
disabled={!writeAllowed}
onChange={(e: Event) => setEnumSelected((e.target as HTMLSelectElement).value)}
>
{meta!.enumStrings!.map((s, i) => (
<option key={i} value={String(i)}>{s}</option>
))}
</select>
<button class="sv-btn" onClick={handleEnumSet}>Set</button>
<button class="sv-btn" onClick={handleEnumSet} disabled={!writeAllowed}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
</>
) : (
<>
@@ -121,13 +133,15 @@ export default function SetValue({ widget, onContextMenu }: Props) {
class="sv-input"
type={isNumeric ? 'number' : 'text'}
value={inputValue}
disabled={!writeAllowed}
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
onKeyDown={handleKeydown}
placeholder="value"
/>
<span class="sv-current">{displayValue()}</span>
{unit && <span class="sv-unit">{unit}</span>}
<button class="sv-btn" onClick={handleSet}>Set</button>
<button class="sv-btn" onClick={handleSet} disabled={!writeAllowed}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
</>
)}
</div>