336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
import { h } from 'preact';
|
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
|
import type { SignalRef } from './lib/types';
|
|
import SyntheticWizard from './SyntheticWizard';
|
|
|
|
interface SignalInfo {
|
|
name: string;
|
|
type?: string;
|
|
unit?: string;
|
|
description?: string;
|
|
writable?: boolean;
|
|
}
|
|
|
|
interface DsGroup {
|
|
ds: string;
|
|
signals: SignalInfo[];
|
|
expanded: boolean;
|
|
addingSignal: boolean; // show add-PV input
|
|
addValue: string;
|
|
}
|
|
|
|
// Custom signals persisted in localStorage
|
|
const LS_KEY = 'uopi:custom-signals';
|
|
|
|
function loadCustom(): Array<{ ds: string; name: string }> {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(LS_KEY) ?? '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveCustom(signals: Array<{ ds: string; name: string }>) {
|
|
localStorage.setItem(LS_KEY, JSON.stringify(signals));
|
|
}
|
|
|
|
interface Props {
|
|
onDragStart?: (sig: SignalRef) => void;
|
|
}
|
|
|
|
export default function SignalTree({ onDragStart }: Props) {
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [groups, setGroups] = useState<DsGroup[]>([]);
|
|
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
|
|
const [filter, setFilter] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [cfAvailable, setCfAvailable] = useState(false);
|
|
const [cfSearching, setCfSearching] = useState(false);
|
|
const [showWizard, setShowWizard] = useState(false);
|
|
const csvRef = useRef<HTMLInputElement>(null);
|
|
|
|
async function loadGroups() {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/v1/datasources');
|
|
if (!res.ok) return;
|
|
const dsList: { name: string }[] = await res.json();
|
|
const loaded: DsGroup[] = await Promise.all(
|
|
dsList.map(async ({ name }) => {
|
|
try {
|
|
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
|
const sigs: SignalInfo[] = r.ok ? await r.json() : [];
|
|
return { ds: name, signals: sigs, expanded: true, addingSignal: false, addValue: '' };
|
|
} catch {
|
|
return { ds: name, signals: [], expanded: true, addingSignal: false, addValue: '' };
|
|
}
|
|
})
|
|
);
|
|
setGroups(loaded);
|
|
|
|
// Check if Channel Finder is available
|
|
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null);
|
|
setCfAvailable(cf?.status === 200);
|
|
} catch (e) {
|
|
console.error('Failed to load signals:', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => { loadGroups(); }, []);
|
|
|
|
// Merge custom signals into groups
|
|
const allGroups = groups.map(g => {
|
|
const extra = customSignals.filter(c => c.ds === g.ds);
|
|
const existing = new Set(g.signals.map(s => s.name));
|
|
const merged = [
|
|
...g.signals,
|
|
...extra.filter(c => !existing.has(c.name)).map(c => ({ name: c.name, type: 'custom' } as SignalInfo)),
|
|
];
|
|
return { ...g, signals: merged };
|
|
});
|
|
|
|
// Custom signals with no matching DS group get their own group
|
|
const knownDs = new Set(groups.map(g => g.ds));
|
|
const orphanCustom = customSignals.filter(c => !knownDs.has(c.ds));
|
|
const orphanGroups: DsGroup[] = Object.entries(
|
|
orphanCustom.reduce<Record<string, string[]>>((acc, { ds, name }) => {
|
|
(acc[ds] ??= []).push(name);
|
|
return acc;
|
|
}, {})
|
|
).map(([ds, names]) => ({
|
|
ds, signals: names.map(name => ({ name, type: 'custom' } as SignalInfo)),
|
|
expanded: true, addingSignal: false, addValue: '',
|
|
}));
|
|
|
|
const visibleGroups = [...allGroups, ...orphanGroups];
|
|
|
|
function toggleGroup(ds: string) {
|
|
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
|
|
}
|
|
|
|
function setAdding(ds: string, val: boolean) {
|
|
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g));
|
|
}
|
|
|
|
function setAddValue(ds: string, val: string) {
|
|
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addValue: val } : g));
|
|
}
|
|
|
|
function commitAdd(ds: string, name: string) {
|
|
name = name.trim();
|
|
if (!name) { setAdding(ds, false); return; }
|
|
const next = [...customSignals, { ds, name }];
|
|
setCustomSignals(next);
|
|
saveCustom(next);
|
|
setAdding(ds, false);
|
|
}
|
|
|
|
function removeCustom(ds: string, name: string) {
|
|
const next = customSignals.filter(c => !(c.ds === ds && c.name === name));
|
|
setCustomSignals(next);
|
|
saveCustom(next);
|
|
}
|
|
|
|
// ── Channel Finder search ────────────────────────────────────────────────
|
|
|
|
async function handleCfSearch() {
|
|
if (!filter) return;
|
|
setCfSearching(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/channel-finder?q=${encodeURIComponent(filter)}`);
|
|
if (!res.ok) return;
|
|
const names: string[] = await res.json();
|
|
const next = [
|
|
...customSignals,
|
|
...names
|
|
.filter(n => !customSignals.some(c => c.ds === 'epics' && c.name === n))
|
|
.map(n => ({ ds: 'epics', name: n })),
|
|
];
|
|
setCustomSignals(next);
|
|
saveCustom(next);
|
|
} finally {
|
|
setCfSearching(false);
|
|
}
|
|
}
|
|
|
|
// ── CSV import ────────────────────────────────────────────────────────────
|
|
|
|
async function handleCsvImport(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
input.value = '';
|
|
if (!file) return;
|
|
const text = await file.text();
|
|
const added: Array<{ ds: string; name: string }> = [];
|
|
for (const raw of text.split('\n')) {
|
|
const line = raw.trim();
|
|
if (!line || line.startsWith('#')) continue;
|
|
const parts = line.split(',').map(p => p.trim());
|
|
if (parts.length === 1 && parts[0]) {
|
|
// bare PV name → default to epics
|
|
added.push({ ds: 'epics', name: parts[0] });
|
|
} else if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
added.push({ ds: parts[0], name: parts[1] });
|
|
}
|
|
}
|
|
const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`));
|
|
const fresh = added.filter(a => !existing.has(`${a.ds}\0${a.name}`));
|
|
const next = [...customSignals, ...fresh];
|
|
setCustomSignals(next);
|
|
saveCustom(next);
|
|
}
|
|
|
|
// ── Filter ────────────────────────────────────────────────────────────────
|
|
|
|
const lf = filter.toLowerCase();
|
|
const filtered = visibleGroups
|
|
.map(g => ({
|
|
...g,
|
|
signals: lf
|
|
? g.signals.filter(s =>
|
|
s.name.toLowerCase().includes(lf) ||
|
|
(s.description ?? '').toLowerCase().includes(lf)
|
|
)
|
|
: g.signals,
|
|
}))
|
|
.filter(g => g.signals.length > 0 || g.addingSignal || !lf);
|
|
|
|
function makeDraggable(ds: string, sig: SignalInfo) {
|
|
return {
|
|
draggable: true as const,
|
|
onDragStart: (e: DragEvent) => {
|
|
const ref: SignalRef = { ds, name: sig.name };
|
|
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
|
onDragStart?.(ref);
|
|
},
|
|
};
|
|
}
|
|
|
|
return (
|
|
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
|
|
<div class="panel-header">
|
|
{!collapsed && <span class="panel-title">Signals</span>}
|
|
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
|
|
{collapsed ? '▶' : '◀'}
|
|
</button>
|
|
</div>
|
|
|
|
{!collapsed && (
|
|
<div class="signal-tree-body">
|
|
{/* Search / filter bar */}
|
|
<div class="signal-tree-search">
|
|
<div class="signal-search-row">
|
|
<input
|
|
class="signal-filter"
|
|
type="search"
|
|
placeholder="Filter signals…"
|
|
value={filter}
|
|
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
|
/>
|
|
{cfAvailable && filter && (
|
|
<button class="icon-btn" title="Search Channel Finder" onClick={handleCfSearch} disabled={cfSearching}>
|
|
{cfSearching ? '…' : '🔍'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toolbar: New Synthetic | Import CSV | Refresh */}
|
|
<div class="signal-tree-toolbar">
|
|
<button class="panel-btn" title="Create synthetic signal" onClick={() => setShowWizard(true)}>+ Synthetic</button>
|
|
<button class="panel-btn" title="Import CSV (ds,name or bare PV names)" onClick={() => csvRef.current?.click()}>CSV</button>
|
|
<button class="panel-btn" title="Refresh signal list" onClick={loadGroups}>↺</button>
|
|
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
|
</div>
|
|
|
|
{/* Signal groups */}
|
|
<div class="panel-list signal-list">
|
|
{loading ? (
|
|
<p class="hint">Loading signals…</p>
|
|
) : filtered.length === 0 ? (
|
|
<p class="hint">No signals found.</p>
|
|
) : (
|
|
filtered.map(group => {
|
|
const groupInGroups = groups.find(g => g.ds === group.ds);
|
|
return (
|
|
<div key={group.ds} class="signal-group">
|
|
<div class="signal-group-header">
|
|
<span class="signal-group-arrow" onClick={() => toggleGroup(group.ds)}>
|
|
{group.expanded ? '▾' : '▸'}
|
|
</span>
|
|
<span class="signal-group-name" onClick={() => toggleGroup(group.ds)}>
|
|
{group.ds}
|
|
</span>
|
|
<span class="signal-group-count">{group.signals.length}</span>
|
|
<button
|
|
class="icon-btn signal-add-btn"
|
|
title={`Add custom signal to ${group.ds}`}
|
|
onClick={() => setAdding(group.ds, true)}
|
|
>+</button>
|
|
</div>
|
|
|
|
{group.expanded && (
|
|
<div>
|
|
{group.signals.map(sig => {
|
|
const isCustom = !groups.find(g => g.ds === group.ds)?.signals.some(s => s.name === sig.name);
|
|
return (
|
|
<div
|
|
key={sig.name}
|
|
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
|
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
|
{...makeDraggable(group.ds, sig)}
|
|
>
|
|
<span class="signal-name">{sig.name}</span>
|
|
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
|
{isCustom && (
|
|
<button
|
|
class="icon-btn signal-remove-btn"
|
|
title="Remove custom signal"
|
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
|
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(group.ds, sig.name); }}
|
|
>✕</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Add-signal input row */}
|
|
{(groupInGroups?.addingSignal) && (
|
|
<div class="signal-add-row">
|
|
<input
|
|
class="signal-add-input"
|
|
placeholder="Signal / PV name…"
|
|
autoFocus
|
|
value={groupInGroups?.addValue ?? ''}
|
|
onInput={(e) => setAddValue(group.ds, (e.target as HTMLInputElement).value)}
|
|
onKeyDown={(e: KeyboardEvent) => {
|
|
if (e.key === 'Enter') commitAdd(group.ds, (e.target as HTMLInputElement).value);
|
|
if (e.key === 'Escape') setAdding(group.ds, false);
|
|
}}
|
|
/>
|
|
<button class="icon-btn" onClick={() => commitAdd(group.ds, groupInGroups?.addValue ?? '')}>✓</button>
|
|
<button class="icon-btn" onClick={() => setAdding(group.ds, false)}>✕</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showWizard && (
|
|
<SyntheticWizard
|
|
onClose={() => setShowWizard(false)}
|
|
onCreated={loadGroups}
|
|
/>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|