Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
+38
-11
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user