Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+84
View File
@@ -0,0 +1,84 @@
import { h } from 'preact';
import type { Widget } from '../lib/types';
import { tabLabels } from '../lib/containers';
interface Props {
widget: Widget;
onContextMenu?: (e: MouseEvent) => void;
// View-mode collapse state: supplied (with a toggle) only by the live Canvas.
// In the editor these are undefined, so the pane always renders expanded.
collapsed?: boolean;
onToggleCollapse?: () => void;
// Tabbed-mode active tab + selector. Supplied by both Canvas (view) and
// EditCanvas (edit) so tabs can be switched in either mode.
activeTab?: number;
onSelectTab?: (i: number) => void;
}
const TITLE_H = 24;
// A purely decorative grouping frame placed behind other widgets on the
// free-form canvas (it does not re-parent them). Two variants:
// - 'pane': a titled / collapsible frame; collapsing in view mode hides the
// widgets geometrically inside it and shrinks the pane to its title bar.
// - 'tabs': a tab bar; only widgets assigned to the active tab are shown.
export default function Container({ widget, onContextMenu, collapsed, onToggleCollapse, activeTab, onSelectTab }: Props) {
const o = widget.options;
const variant = o['variant'] === 'tabs' ? 'tabs' : 'pane';
const showBg = o['bg'] !== 'false';
const accent = o['accent'] || '#3d4f6e';
if (variant === 'tabs') {
const tabs = tabLabels(widget);
const active = Math.min(Math.max(activeTab ?? 0, 0), tabs.length - 1);
return (
<div
class={`container-pane${showBg ? '' : ' container-pane-nobg'}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;border-color:${accent};`}
onContextMenu={onContextMenu}
>
<div class="container-tab-bar" style={`border-color:${accent};`}>
{tabs.map((t, i) => (
<button
key={i}
class={`container-tab${i === active ? ' container-tab-active' : ''}`}
style={i === active ? `border-bottom-color:${accent};` : ''}
onClick={(e) => { e.stopPropagation(); onSelectTab && onSelectTab(i); }}
onMouseDown={(e) => e.stopPropagation()}
>{t}</button>
))}
</div>
</div>
);
}
const title = o['title'] ?? '';
const collapsible = o['collapsible'] === 'true';
// Collapse only applies in view mode (onToggleCollapse present) and when enabled.
const canCollapse = collapsible && !!onToggleCollapse;
const isCollapsed = canCollapse && !!collapsed;
const height = isCollapsed ? TITLE_H : widget.h;
const hasTitleBar = title !== '' || canCollapse;
return (
<div
class={`container-pane${showBg && !isCollapsed ? '' : ' container-pane-nobg'}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${height}px;border-color:${accent};`}
onContextMenu={onContextMenu}
>
{hasTitleBar && (
<div class="container-pane-title" style={`border-color:${accent};`}>
{canCollapse && (
<button
class="container-collapse-btn"
title={isCollapsed ? 'Expand' : 'Collapse'}
onClick={(e) => { e.stopPropagation(); onToggleCollapse!(); }}
onMouseDown={(e) => e.stopPropagation()}
>{isCollapsed ? '▸' : '▾'}</button>
)}
<span class="container-pane-label">{title}</span>
</div>
)}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { formatValue } from '../lib/format';
import type { Widget, SignalRef, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
const COLS = ['name', 'value', 'unit', 'quality', 'time'] as const;
type Col = typeof COLS[number];
const COL_LABELS: Record<Col, string> = {
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
};
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
function isCol(c: string): c is Col {
return (COLS as readonly string[]).includes(c);
}
interface RowProps {
sig: SignalRef;
label: string;
cols: Col[];
fmt: string;
unitOverride: string;
}
// One subscribed row. Each row owns its own value/meta subscription so the
// table can carry an arbitrary number of unrelated signals.
function TableRow({ sig, label, cols, fmt, unitOverride }: RowProps) {
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
const uv = getSignalStore(sig).subscribe(setSv);
const um = getMetaStore(sig).subscribe(setMeta);
return () => { uv(); um(); };
}, [sig.ds, sig.name]);
function valStr(): string {
const v = sv.value;
if (v === null || v === undefined) return '---';
if (typeof v === 'number') return formatValue(v, fmt);
if (Array.isArray(v)) return `[${v.length}]`;
return String(v);
}
const unit = unitOverride === 'none' ? '' : (unitOverride || meta?.unit || '');
const time = sv.ts ? new Date(sv.ts).toLocaleTimeString() : '';
return (
<tr>
{cols.map(c => {
switch (c) {
case 'name':
return <td key={c} class="tw-name" title={`${sig.ds}:${sig.name}`}>{label}</td>;
case 'value':
return <td key={c} class="tw-value">{valStr()}</td>;
case 'unit':
return <td key={c} class="tw-unit">{unit}</td>;
case 'quality':
return (
<td key={c} class="tw-quality">
<span class="quality-dot" style={`background:${qualityColor(sv.quality)};`} title={`Quality: ${sv.quality}`} />
</td>
);
case 'time':
return <td key={c} class="tw-time">{time}</td>;
}
})}
</tr>
);
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// A tabular multi-signal readout: each bound signal is one row, with a
// configurable set of columns (name / value / unit / status / time).
export default function TableWidget({ widget, onContextMenu }: Props) {
const o = widget.options;
const parsed = (o['columns'] ?? 'name,value,unit')
.split(',').map(s => s.trim()).filter(isCol);
const cols: Col[] = parsed.length ? parsed : ['name', 'value'];
const showHeader = o['header'] !== 'false';
const title = o['title'] ?? '';
const fmt = o['format'] ?? '';
const unitOverride = o['unit'] ?? '';
const labels = (o['labels'] ?? '').split(',');
return (
<div
class="table-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{title && <div class="tw-title">{title}</div>}
<div class="tw-scroll">
<table class="tw-table">
{showHeader && (
<thead>
<tr>{cols.map(c => <th key={c} class={`tw-th-${c}`}>{COL_LABELS[c]}</th>)}</tr>
</thead>
)}
<tbody>
{widget.signals.length === 0 ? (
<tr><td class="tw-empty" colSpan={cols.length}>No signals drop signals here</td></tr>
) : (
widget.signals.map((s, i) => (
<TableRow
key={`${s.ds}:${s.name}`}
sig={s}
label={labels[i]?.trim() || s.name}
cols={cols}
fmt={fmt}
unitOverride={unitOverride}
/>
))
)}
</tbody>
</table>
</div>
</div>
);
}