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([]); const [customSignals, setCustomSignals] = useState>(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(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>((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 ( ); }