Implemented epics read/write

This commit is contained in:
Martino Ferrari
2026-04-27 12:42:10 +02:00
parent b76b7f0ba8
commit 1bda25454b
32 changed files with 1553 additions and 281 deletions
+9 -1
View File
@@ -3,10 +3,14 @@ import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import type { Interface } from './lib/types';
type AppMode = 'view' | 'edit';
// If URL has ?fs=<id>, render bare fullscreen canvas
const fsParam = new URLSearchParams(window.location.search).get('fs');
export default function App() {
const [mode, setMode] = useState<AppMode>('view');
const [wsStatus, setWsStatus] = useState('connecting');
@@ -20,9 +24,13 @@ export default function App() {
useEffect(() => {
const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr));
wsClient.connect('/ws');
if (!fsParam) wsClient.connect('/ws');
}, []);
if (fsParam) {
return <FullscreenMode interfaceId={fsParam} />;
}
function enterEdit(iface?: Interface) {
setEditTarget(iface ?? null);
setMode('edit');
+101 -15
View File
@@ -37,6 +37,11 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
link: [140, 50],
};
// Widget types that support multiple signals (signal can be appended)
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
// Widget types that have exactly one signal (signal can be replaced)
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
interface PickerState {
visible: boolean;
x: number;
@@ -46,6 +51,14 @@ interface PickerState {
signal: SignalRef | null;
}
interface SignalDropMenuState {
visible: boolean;
x: number;
y: number;
signal: SignalRef;
targetWidget: Widget;
}
interface DragState {
startMouseX: number;
startMouseY: number;
@@ -65,9 +78,9 @@ interface ResizeState {
interface RubberBand {
active: boolean;
startX: number; startY: number; // canvas-relative
startX: number; startY: number;
curX: number; curY: number;
clientStartX: number; clientStartY: number; // for scroll adjustment
clientStartX: number; clientStartY: number;
}
interface Props {
@@ -75,7 +88,7 @@ interface Props {
selectedIds: string[];
onSelect: (ids: string[]) => void;
onChange: (widgets: Widget[]) => void;
snapGrid: number; // 0 = off
snapGrid: number;
}
let _widgetSeq = Date.now();
@@ -95,7 +108,6 @@ function rectsIntersect(
export default function EditCanvas({ iface, selectedIds, onSelect, onChange, snapGrid }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
// Refs: always hold current values so event handlers don't go stale
const ifaceRef = useRef(iface);
ifaceRef.current = iface;
const onChangeRef = useRef(onChange);
@@ -111,14 +123,14 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
const resizeRef = useRef<ResizeState | null>(null);
const rubberRef = useRef<RubberBand>({ active: false, startX: 0, startY: 0, curX: 0, curY: 0, clientStartX: 0, clientStartY: 0 });
// Rubber-band visual state (drives re-renders during drag)
const [rubberVis, setRubberVis] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [picker, setPicker] = useState<PickerState>({
visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null,
});
// Set up document-level mouse handlers once
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
useEffect(() => {
function onMouseMove(e: MouseEvent) {
const grid = snapGridRef.current;
@@ -213,7 +225,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
function handleCanvasMouseDown(e: MouseEvent) {
if ((e.target as Element).closest('.widget-overlay')) return;
if ((e.target as Element).closest('.widget-picker-backdrop')) return;
// Start rubber-band on empty canvas
if ((e.target as Element).closest('.signal-drop-backdrop')) return;
const { cx, cy } = getCanvasPos(e);
rubberRef.current = { active: true, startX: cx, startY: cy, curX: cx, curY: cy, clientStartX: e.clientX, clientStartY: e.clientY };
if (!e.ctrlKey && !e.metaKey) {
@@ -232,10 +244,50 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
if (!json) return;
let sig: SignalRef;
try { sig = JSON.parse(json); } catch { return; }
const { cx, cy } = getCanvasPos(e);
// Check if drop lands on an existing widget
const hit = iface.widgets.find(w =>
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
);
if (hit && (MULTI_SIGNAL_TYPES.has(hit.type) || SINGLE_SIGNAL_TYPES.has(hit.type))) {
// Offer add/replace vs create-new
setSignalDropMenu({ visible: true, x: e.clientX, y: e.clientY, signal: sig, targetWidget: hit });
return;
}
// No existing widget hit — open type picker
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
}
function handleAddToWidget(widget: Widget, sig: SignalRef) {
setSignalDropMenu(null);
let updated: Widget;
if (MULTI_SIGNAL_TYPES.has(widget.type)) {
// Append signal (avoid duplicates)
const already = widget.signals.some(s => s.ds === sig.ds && s.name === sig.name);
if (already) return;
updated = { ...widget, signals: [...widget.signals, sig] };
} else {
// Replace first signal
updated = { ...widget, signals: [sig, ...widget.signals.slice(1)] };
}
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w));
onSelect([updated.id]);
}
function handleSignalDropCreateNew(sig: SignalRef, cx: number, cy: number) {
setSignalDropMenu(null);
// We don't have canvas coords in the menu state, approximate from screen coords
const el = containerRef.current;
const rect = el ? el.getBoundingClientRect() : { left: 0, top: 0 };
const canvasX = cx - rect.left + (el?.scrollLeft ?? 0);
const canvasY = cy - rect.top + (el?.scrollTop ?? 0);
setPicker({ visible: true, x: cx, y: cy, canvasX, canvasY, signal: sig });
}
function handlePick(type: string) {
if (!picker.signal) return;
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
@@ -255,10 +307,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
setPicker(p => ({ ...p, visible: false }));
}
// Called from EditMode when inserting a static widget (no signal)
// This is triggered via the parent placing a widget directly.
// (handled via onInsert prop in extended usage)
function handleOverlayMouseDown(e: MouseEvent, widget: Widget) {
e.stopPropagation();
const ctrl = e.ctrlKey || e.metaKey;
@@ -274,7 +322,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
}
onSelect(nextIds);
// Don't start drag on Ctrl+click deselect
if (ctrl && isSelected) return;
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
@@ -317,7 +364,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
>
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{/* Live widget previews */}
{iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type];
return Comp
@@ -330,7 +376,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
);
})}
{/* Interaction overlays */}
{iface.widgets.map(widget => {
const isSelected = selectedIds.includes(widget.id);
return (
@@ -361,7 +406,6 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
);
})}
{/* Rubber-band selection rectangle */}
{rubberVis && (
<div
class="rubber-band"
@@ -378,6 +422,48 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
/>
)}
{/* Signal drop menu: appears when dropping onto an existing widget */}
{signalDropMenu && (
<div class="signal-drop-backdrop" onClick={() => setSignalDropMenu(null)}>
<div
class="signal-drop-menu"
style={`left:${signalDropMenu.x}px;top:${signalDropMenu.y}px;`}
onClick={(e: MouseEvent) => e.stopPropagation()}
>
<div class="signal-drop-header">
Drop signal <strong>{signalDropMenu.signal.name}</strong>
</div>
{MULTI_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
<button
class="ctx-item"
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
>
+ Add signal to this {signalDropMenu.targetWidget.type}
</button>
)}
{SINGLE_SIGNAL_TYPES.has(signalDropMenu.targetWidget.type) && (
<button
class="ctx-item"
onClick={() => handleAddToWidget(signalDropMenu.targetWidget, signalDropMenu.signal)}
>
Replace signal in this {signalDropMenu.targetWidget.type}
</button>
)}
<button
class="ctx-item"
onClick={() => handleSignalDropCreateNew(
signalDropMenu.signal,
signalDropMenu.x,
signalDropMenu.y,
)}
>
+ Create new widget
</button>
<button class="ctx-item ctx-cancel" onClick={() => setSignalDropMenu(null)}>Cancel</button>
</div>
</div>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml';
import type { Interface } from './lib/types';
import Canvas from './Canvas';
interface Props {
interfaceId: string;
}
export default function FullscreenMode({ interfaceId }: Props) {
const [iface, setIface] = useState<Interface | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
wsClient.connect('/ws');
fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(xml => setIface(parseInterface(xml)))
.catch(err => setError(err instanceof Error ? err.message : String(err)));
}, [interfaceId]);
if (error) {
return (
<div class="fs-error">
<p>Failed to load interface: {error}</p>
</div>
);
}
return (
<div class="fs-view">
<Canvas iface={iface} onNavigate={async (id) => {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setIface(parseInterface(await res.text()));
} catch { /* ignore */ }
}} timeRange={null} />
</div>
);
}
+8 -2
View File
@@ -12,9 +12,10 @@ interface Props {
onLoad: (xml: string) => void;
onSelect?: (id: string) => void;
onEdit?: (iface?: Interface) => void;
onEditId?: (id: string) => void;
}
export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
const [loading, setLoading] = useState(true);
@@ -60,6 +61,10 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
if (res.ok) refresh();
}
function handleFullscreen(id: string) {
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
}
return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
<div class="panel-header">
@@ -100,7 +105,8 @@ export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
{iface.name || iface.id}
</span>
<div class="iface-item-actions">
<button class="icon-btn" title="Edit" onClick={() => onEdit?.()}></button>
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}></button>
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}></button>
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}></button>
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}></button>
</div>
+175 -53
View File
@@ -4,7 +4,7 @@ import type { Widget, Interface } from './lib/types';
interface Props {
selected: Widget | null;
multiCount: number; // >0 when multiple widgets are selected
multiCount: number;
iface: Interface;
onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void;
@@ -21,6 +21,10 @@ function Field({ label, children }: { label: string; children: any }) {
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [local, setLocal] = useState(value);
// Sync when external value changes (e.g. different widget selected)
if (local !== value && document.activeElement?.tagName !== 'INPUT') {
setLocal(value);
}
return (
<input
class="prop-input"
@@ -31,6 +35,13 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
);
}
// Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
// Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange }: Props) {
const [collapsed, setCollapsed] = useState(false);
@@ -44,6 +55,14 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
onChange({ ...selected, [key]: value } as Widget);
}
function removeSignal(idx: number) {
if (!selected) return;
const signals = selected.signals.filter((_, i) => i !== idx);
onChange({ ...selected, signals });
}
const w = selected;
return (
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
<div class="panel-header">
@@ -55,7 +74,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
{!collapsed && (
<div class="props-body">
{/* Interface-level properties (always shown) */}
{/* Canvas-level properties */}
<div class="props-section">
<div class="props-section-title">Canvas</div>
<Field label="Name">
@@ -84,117 +103,220 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field>
</div>
{selected && (
{w && (
<div class="props-section">
<div class="props-section-title">{selected.type}</div>
<div class="props-section-title">{w.type}</div>
{/* Position / size */}
<Field label="X">
<input class="prop-input prop-input-num" type="number" value={selected.x}
<input class="prop-input prop-input-num" type="number" value={w.x}
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field>
<Field label="Y">
<input class="prop-input prop-input-num" type="number" value={selected.y}
<input class="prop-input prop-input-num" type="number" value={w.y}
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field>
<Field label="Width">
<input class="prop-input prop-input-num" type="number" value={selected.w} min={10}
<input class="prop-input prop-input-num" type="number" value={w.w} min={10}
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field>
<Field label="Height">
<input class="prop-input prop-input-num" type="number" value={selected.h} min={10}
<input class="prop-input prop-input-num" type="number" value={w.h} min={10}
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field>
{/* Signal */}
{selected.signals.length > 0 && (
{/* Signals */}
{MULTI_SIGNAL_WIDGETS.has(w.type) ? (
<Field label="Signals">
<div class="prop-signals-list">
{w.signals.length === 0 && <span class="hint">No signals</span>}
{w.signals.map((s, i) => (
<div key={i} class="prop-signal-row">
<span class="prop-sig">{s.ds}:{s.name}</span>
<button
class="icon-btn prop-sig-remove"
title="Remove signal"
onClick={() => removeSignal(i)}
></button>
</div>
))}
</div>
</Field>
) : w.signals.length > 0 ? (
<Field label="Signal">
<span class="prop-sig">{selected.signals[0].ds}:{selected.signals[0].name}</span>
<span class="prop-sig">{w.signals[0].ds}:{w.signals[0].name}</span>
</Field>
) : null}
{/* ── Common label/unit overrides (issue #8) ──────────────────── */}
{/* Label: button and textlabel use it as display text;
signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
<TextInput
value={w.options['label'] ?? ''}
onCommit={(v) => setOpt('label', v)}
/>
{LABEL_WIDGETS.has(w.type) && (
<span class="prop-hint">Empty = signal name</span>
)}
</Field>
)}
{/* Common options */}
<Field label="Label">
<TextInput
value={selected.options['label'] ?? ''}
onCommit={(v) => setOpt('label', v)}
/>
</Field>
{/* Unit override for signal widgets */}
{UNIT_WIDGETS.has(w.type) && (
<Field label="Unit override">
<TextInput
value={w.options['unit'] ?? ''}
onCommit={(v) => setOpt('unit', v)}
/>
<span class="prop-hint">Empty = from metadata · "none" = hide</span>
</Field>
)}
{/* Type-specific options */}
{(selected.type === 'gauge' || selected.type === 'barh' || selected.type === 'barv') && (
{/* ── Type-specific options ──────────────────────────────────── */}
{(w.type === 'gauge' || w.type === 'barh' || w.type === 'barv') && (
<div>
<Field label="Min">
<TextInput value={selected.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
<TextInput value={w.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
</Field>
<Field label="Max">
<TextInput value={selected.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
<TextInput value={w.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
</Field>
</div>
)}
{selected.type === 'led' && (
{w.type === 'led' && (
<Field label="Condition">
<TextInput value={selected.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
<TextInput value={w.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
</Field>
)}
{selected.type === 'setvalue' && (
<Field label="Min">
<TextInput value={selected.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
</Field>
{w.type === 'setvalue' && (
<div>
<Field label="Min">
<TextInput value={w.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
</Field>
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
{selected.type === 'plot' && (
<Field label="Plot type">
<select
class="prop-select"
value={selected.options['plotType'] ?? 'timeseries'}
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
>
<option value="timeseries">Time-series</option>
<option value="histogram">Histogram</option>
<option value="bar">Bar chart</option>
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
</select>
</Field>
{/* Button options (issue #2) */}
{w.type === 'button' && (
<div>
<Field label="Send value">
<TextInput value={w.options['value'] ?? '1'} onCommit={(v) => setOpt('value', v)} />
</Field>
<Field label="Mode">
<select class="prop-select" value={w.options['mode'] ?? 'oneshot'}
onChange={(e) => setOpt('mode', (e.target as HTMLSelectElement).value)}>
<option value="oneshot">One-shot (set once)</option>
<option value="setReset">Set-reset (auto-revert)</option>
<option value="persistent">Persistent (toggle / monitor)</option>
</select>
</Field>
{(w.options['mode'] === 'setReset' || w.options['mode'] === 'persistent') && (
<Field label="Reset value">
<TextInput value={w.options['resetValue'] ?? '0'} onCommit={(v) => setOpt('resetValue', v)} />
</Field>
)}
{w.options['mode'] === 'setReset' && (
<Field label="Reset after (s)">
<TextInput value={w.options['resetTime'] ?? '1'} onCommit={(v) => setOpt('resetTime', v)} />
</Field>
)}
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
{selected.type === 'textlabel' && (
{/* Plot options */}
{w.type === 'plot' && (
<div>
<Field label="Plot type">
<select
class="prop-select"
value={w.options['plotType'] ?? 'timeseries'}
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
>
<option value="timeseries">Time-series</option>
<option value="histogram">Histogram</option>
<option value="bar">Bar chart</option>
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
</select>
</Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
<Field label="Time window (s)">
<TextInput value={w.options['timeWindow'] ?? '60'} onCommit={(v) => setOpt('timeWindow', v)} />
</Field>
)}
<Field label="Y min">
<TextInput value={w.options['yMin'] ?? 'auto'} onCommit={(v) => setOpt('yMin', v)} />
</Field>
<Field label="Y max">
<TextInput value={w.options['yMax'] ?? 'auto'} onCommit={(v) => setOpt('yMax', v)} />
</Field>
<Field label="Legend">
<select class="prop-select" value={w.options['legend'] ?? 'bottom'}
onChange={(e) => setOpt('legend', (e.target as HTMLSelectElement).value)}>
<option value="bottom">Bottom</option>
<option value="top">Top</option>
<option value="none">None</option>
</select>
</Field>
</div>
)}
{w.type === 'textlabel' && (
<div>
<Field label="Font size">
<TextInput value={selected.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
<TextInput value={w.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
</Field>
<Field label="Color">
<TextInput value={selected.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
<TextInput value={w.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
</Field>
</div>
)}
{selected.type === 'image' && (
{w.type === 'image' && (
<Field label="URL">
<TextInput value={selected.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
<TextInput value={w.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
</Field>
)}
{selected.type === 'link' && (
{w.type === 'link' && (
<div>
<Field label="Target">
<TextInput value={selected.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
<TextInput value={w.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
</Field>
<Field label="Label">
<TextInput value={selected.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
<TextInput value={w.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
</Field>
</div>
)}
</div>
)}
{!selected && multiCount > 1 && (
{!w && multiCount > 1 && (
<p class="hint" style="padding:0.75rem;">{multiCount} widgets selected.<br/>Use align/distribute tools in the toolbar.</p>
)}
{!selected && multiCount === 0 && (
{!w && multiCount === 0 && (
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
)}
</div>
+58 -36
View File
@@ -1,11 +1,10 @@
import { h } from 'preact';
import { useState, useMemo } from 'preact/hooks';
import { useState, useMemo, useEffect } from 'preact/hooks';
import InterfaceList from './InterfaceList';
import Canvas from './Canvas';
import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml';
import type { Interface } from './lib/types';
import { useEffect } from 'preact/hooks';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
@@ -25,6 +24,7 @@ export default function ViewMode({ onEdit }: Props) {
const [wsStatus, setWsStatus] = useState('connecting');
const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false);
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
@@ -52,13 +52,24 @@ export default function ViewMode({ onEdit }: Props) {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const xml = await res.text();
handleLoad(xml);
handleLoad(await res.text());
} catch (err) {
setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`);
}
}
/** Load interface by ID and pass parsed object to onEdit */
async function handleEditById(id: string) {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const iface = parseInterface(await res.text());
onEdit?.(iface);
} catch (err) {
setParseError(`Failed to load interface for editing: ${err instanceof Error ? err.message : err}`);
}
}
function handleLoadHistory() {
if (!startInput || !endInput) return;
const start = new Date(startInput).toISOString();
@@ -82,38 +93,6 @@ export default function ViewMode({ onEdit }: Props) {
)}
</div>
<div class="toolbar-center">
{/* Time range picker */}
<div class="time-nav">
<button
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
title="Switch to live data"
onClick={handleGoLive}
>
Live
</button>
<input
class="time-input"
type="datetime-local"
value={startInput}
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
title="History start"
/>
<span class="time-sep"></span>
<input
class="time-input"
type="datetime-local"
value={endInput}
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
title="History end"
/>
<button
class="toolbar-btn"
title="Load historical data for this time range"
onClick={handleLoadHistory}
>
Load
</button>
</div>
{parseError && (
<span class="parse-error" title={parseError}>Parse error check console</span>
)}
@@ -123,6 +102,13 @@ export default function ViewMode({ onEdit }: Props) {
<span class="status-dot"></span>
{wsStatus}
</div>
<button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
title="Toggle historical time navigation"
onClick={() => setShowTimeNav(v => !v)}
>
History
</button>
<ContextualHelp mode="view" onOpenManual={openHelp} />
<button
class="icon-btn help-manual-btn"
@@ -141,10 +127,46 @@ export default function ViewMode({ onEdit }: Props) {
</div>
</header>
{/* Historical time range bar — hidden by default */}
{showTimeNav && (
<div class="time-nav-bar">
<button
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
title="Switch to live data"
onClick={handleGoLive}
>
Live
</button>
<input
class="time-input"
type="datetime-local"
value={startInput}
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
title="History start"
/>
<span class="time-sep"></span>
<input
class="time-input"
type="datetime-local"
value={endInput}
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
title="History end"
/>
<button
class="toolbar-btn"
title="Load historical data for this time range"
onClick={handleLoadHistory}
>
Load
</button>
</div>
)}
<div class="content">
<InterfaceList
onLoad={handleLoad}
onEdit={onEdit}
onEditId={handleEditById}
onSelect={async (id) => {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
+1
View File
@@ -259,6 +259,7 @@ class WsClient {
}
write(ref: SignalRef, value: any): void {
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
}
}
+134 -27
View File
@@ -994,33 +994,7 @@ body {
border: 1px solid #3b82f6;
}
/* ── Time range / history navigation ──────────────────────────────────────── */
.time-nav {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.time-input {
font-size: 0.75rem;
background: #1a2236;
border: 1px solid #2d3748;
border-radius: 4px;
color: #cbd5e1;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
.time-input:focus { outline: none; border-color: #63b3ed; }
/* Colour-scheme hint for the browser's native date-time picker */
.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); }
.time-sep {
color: #475569;
font-size: 0.8rem;
}
/* time-nav styles are in the time-nav-bar section at the bottom of this file */
/* Overlay shown inside a plot widget while history is loading or unavailable */
.hist-overlay {
@@ -1966,3 +1940,136 @@ kbd {
}
.ctx-help-link:hover { text-decoration: underline; }
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
.time-nav-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 1rem;
background: #161b27;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
flex-wrap: wrap;
}
.time-input {
font-size: 0.75rem;
background: #1a2236;
border: 1px solid #2d3748;
color: #e2e8f0;
border-radius: 4px;
padding: 0.25rem 0.4rem;
cursor: pointer;
height: 28px;
box-sizing: border-box;
}
.time-input:focus { outline: none; border-color: #63b3ed; }
.time-input::-webkit-calendar-picker-indicator { filter: invert(0.8); }
.time-sep {
color: #475569;
font-size: 0.8rem;
}
/* ── Fullscreen mode ──────────────────────────────────────────────────────── */
.fs-view {
width: 100vw;
height: 100vh;
overflow: auto;
background: #0f1117;
display: flex;
}
.fs-error {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #f87171;
font-size: 1rem;
}
/* ── Signal drop menu (drop onto existing widget) ─────────────────────────── */
.signal-drop-backdrop {
position: absolute;
inset: 0;
z-index: 9500;
}
.signal-drop-menu {
position: fixed;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
min-width: 220px;
z-index: 9600;
overflow: hidden;
transform: translateY(4px);
}
.signal-drop-header {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
color: #64748b;
border-bottom: 1px solid #2d3748;
}
.signal-drop-header strong {
color: #94a3b8;
}
.ctx-cancel {
color: #6b7280;
border-top: 1px solid #2d3748;
}
/* ── Button widget: pressed / mode hint ──────────────────────────────────── */
.btn-pressed {
background: #1e40af !important;
border-color: #3b82f6 !important;
color: #93c5fd !important;
}
.btn-mode-hint {
font-size: 0.65rem;
opacity: 0.7;
margin-left: 0.35rem;
}
/* ── PropertiesPane: signals list + hint text ─────────────────────────────── */
.prop-signals-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 100%;
}
.prop-signal-row {
display: flex;
align-items: center;
gap: 0.25rem;
justify-content: space-between;
}
.prop-sig-remove {
font-size: 0.65rem;
opacity: 0.6;
flex-shrink: 0;
}
.prop-sig-remove:hover { opacity: 1; color: #f87171; }
.prop-hint {
display: block;
font-size: 0.65rem;
color: #475569;
margin-top: 0.15rem;
line-height: 1.2;
}
+3 -2
View File
@@ -28,8 +28,9 @@ export default function BarH({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const label = widget.options['label'] || sigRef?.name || '';
const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const quality = sv.quality;
+3 -2
View File
@@ -28,8 +28,9 @@ export default function BarV({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const label = widget.options['label'] || sigRef?.name || '';
const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const quality = sv.quality;
+50 -10
View File
@@ -1,24 +1,59 @@
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';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function Button({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const label = widget.options['label'] ?? 'Button';
const valueOpt = widget.options['value'] ?? '1';
const confirm = widget.options['confirm'] === 'true';
const label = widget.options['label'] ?? 'Button';
const sendValue = widget.options['value'] ?? '1';
const resetValue = widget.options['resetValue'] ?? '0';
// mode: oneshot | setReset | persistent
const mode = widget.options['mode'] ?? 'oneshot';
const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1');
const confirmOpt = widget.options['confirm'] === 'true';
const [signalVal, setSignalVal] = useState<any>(null);
// Subscribe to signal only for persistent mode (to track whether it is active)
useEffect(() => {
if (!sigRef || mode !== 'persistent') return;
return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value));
}, [sigRef?.ds, sigRef?.name, mode]);
const parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue);
const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue);
// Persistent mode: button appears pressed while signal value matches sendValue
const isPressed = mode === 'persistent'
&& signalVal !== null
&& String(signalVal) === String(parsedSend);
function doWrite(val: any) {
if (!sigRef) return;
wsClient.write(sigRef, val);
}
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);
}
}
function handleClick() {
if (!sigRef) return;
const parsed = parseFloat(valueOpt);
const val = isNaN(parsed) ? valueOpt : parsed;
const doWrite = () => wsClient.write(sigRef, val);
if (confirm) {
if (window.confirm(`Send ${label}?`)) doWrite();
if (confirmOpt) {
if (window.confirm(`Send ${label}?`)) execute();
} else {
doWrite();
execute();
}
}
@@ -28,7 +63,12 @@ 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" onClick={handleClick}>{label}</button>
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}>
{label}
{mode === 'setReset' && (
<span class="btn-mode-hint">{resetTimeSec}s</span>
)}
</button>
</div>
);
}
+3 -2
View File
@@ -51,8 +51,9 @@ export default function Gauge({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const label = widget.options['label'] || sigRef?.name || '';
const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
+4 -4
View File
@@ -64,15 +64,15 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const chartRef = useRef<HTMLDivElement>(null);
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
// Re-render chart when switching between live and historical mode.
// We stringify timeRange so the effect dependency is a stable primitive.
// Stable primitive deps so the effect re-runs when plotType or signals change.
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
const plotType = widget.options['plotType'] ?? 'timeseries';
const signalsKey = widget.signals.map(s => `${s.ds}:${s.name}${s.color ?? ''}`).join(',');
useEffect(() => {
if (!chartRef.current) return;
const signals = widget.signals;
const plotType = widget.options['plotType'] ?? 'timeseries';
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
const yMin = widget.options['yMin'];
const yMax = widget.options['yMax'];
@@ -329,7 +329,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}, [widget.id, timeRangeKey]);
}, [widget.id, timeRangeKey, plotType, signalsKey]);
return (
<div
+4 -3
View File
@@ -30,10 +30,11 @@ export default function SetValue({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const label = widget.options['label'] || sigRef?.name || '';
const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const confirm = widget.options['confirm'] === 'true';
const isNumeric = meta ? meta.type !== 'TypeString' : true;
const isNumeric = meta ? meta.type !== 'string' : true;
const quality = sv.quality;
function displayValue(): string {
+3 -2
View File
@@ -28,8 +28,9 @@ export default function TextView({ widget, onContextMenu }: Props) {
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const label = widget.options['label'] || sigRef?.name || '';
const rawUnit = widget.options['unit'];
const unit = rawUnit === 'none' ? '' : (rawUnit || meta?.unit || '');
const quality = sv.quality;
function displayValue(): string {