import { h, Fragment } from 'preact'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import type { SignalRef, StateVar } from './lib/types'; import SyntheticGraphEditor from './SyntheticGraphEditor'; import GroupsTree from './GroupsTree'; import ChannelFinderModal from './ChannelFinderModal'; import SignalPicker from './SignalPicker'; type TreeTab = 'sources' | 'groups'; type GroupBy = 'datasource' | 'area' | 'system' | 'ioc'; interface SignalInfo { name: string; type?: string; unit?: string; description?: string; writable?: boolean; properties?: Record; tags?: string[]; } interface DisplayGroup { id: string; label: string; signals: Array; expanded: boolean; addingSignal?: boolean; 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; width?: number; // Current interface id — scopes panel-visibility synthetic signals. panelId?: string; // Panel-local state variables and a setter (edit mode only). statevars?: StateVar[]; onStateVarsChange?: (vars: StateVar[]) => void; } export default function SignalTree({ onDragStart, width, panelId, statevars, onStateVarsChange }: Props) { const [treeTab, setTreeTab] = useState('sources'); const [groupBy, setGroupBy] = useState('datasource'); const [collapsed, setCollapsed] = useState(false); const [rawSignals, setRawSignals] = useState>([]); const [customSignals, setCustomSignals] = useState>(loadCustom); const [expandedGroups, setExpandedGroups] = useState>({}); const [filter, setFilter] = useState(''); const [loading, setLoading] = useState(true); const [cfAvailable, setCfAvailable] = useState(false); const [showWizard, setShowWizard] = useState(false); const [showCfModal, setShowCfModal] = useState(false); const [showManualAdd, setShowManualAdd] = useState(false); const [manualDs, setManualDs] = useState('epics'); const [manualName, setManualName] = useState(''); const [addingTo, setAddingTo] = useState(null); const [addValue, setAddValue] = useState(''); const [editSynthetic, setEditSynthetic] = useState(null); const csvRef = useRef(null); async function loadAllSignals() { setLoading(true); try { const res = await fetch('/api/v1/datasources'); if (!res.ok) return; const dsList: { name: string }[] = await res.json(); const all: Array = []; await Promise.all( dsList.map(async ({ name }) => { try { // Synthetic signals are filtered server-side by panel scope. const url = name === 'synthetic' && panelId ? `/api/v1/signals?ds=synthetic&panel=${encodeURIComponent(panelId)}` : `/api/v1/signals?ds=${encodeURIComponent(name)}`; const r = await fetch(url); if (r.ok) { const data = await r.json(); const sigs = Array.isArray(data) ? data : (data && typeof data === 'object' && Array.isArray(data.signals) ? data.signals : []); all.push(...sigs.map(s => ({ ...s, ds: name }))); } } catch (e) { console.error(`Failed to load ${name}:`, e); } }) ); setRawSignals(all); // Check if Channel Finder is available const cf = await fetch('/api/v1/channel-finder?q=') .then(r => (r.ok ? r.json() : null)) .catch(() => null); setCfAvailable(!!cf?.available); } catch (e) { console.error('Failed to load signals:', e); } finally { setLoading(false); } } useEffect(() => { loadAllSignals(); }, [panelId]); // ── Local state variables (edit mode) ────────────────────────────────────── const [showAddLocal, setShowAddLocal] = useState(false); const [localOpen, setLocalOpen] = useState(true); const [lvName, setLvName] = useState(''); const [lvType, setLvType] = useState<'number' | 'bool' | 'string' | 'array'>('number'); const [lvInitial, setLvInitial] = useState('0'); // array-specific fields (mirror LogicEditor LocalVars) const [lvElem, setLvElem] = useState<'number' | 'bool' | 'array'>('number'); const [lvSizing, setLvSizing] = useState<'dynamic' | 'capped' | 'fixed'>('dynamic'); const [lvCapacity, setLvCapacity] = useState('100'); const [lvJsonErr, setLvJsonErr] = useState(''); function handleLvTypeChange(t: 'number' | 'bool' | 'string' | 'array') { setLvType(t); setLvInitial(t === 'array' ? '[]' : t === 'bool' ? 'false' : t === 'string' ? '' : '0'); setLvJsonErr(''); } function handleLvInitialChange(v: string) { setLvInitial(v); if (lvType === 'array') { try { JSON.parse(v); setLvJsonErr(''); } catch (e: any) { setLvJsonErr(e.message); } } } function addLocal() { const name = lvName.trim(); if (!name || !onStateVarsChange) return; if (lvType === 'array' && lvJsonErr) return; const base: StateVar = { name, type: lvType, initial: lvInitial }; if (lvType === 'array') { base.elem = lvElem; base.sizing = lvSizing; if (lvSizing !== 'dynamic') base.capacity = parseInt(lvCapacity, 10) || 100; } const next = (statevars ?? []).filter(v => v.name !== name); next.push(base); onStateVarsChange(next); setLvName(''); setLvInitial('0'); setLvType('number'); setLvElem('number'); setLvSizing('dynamic'); setLvCapacity('100'); setLvJsonErr(''); setShowAddLocal(false); } function removeLocal(name: string) { if (!onStateVarsChange) return; onStateVarsChange((statevars ?? []).filter(v => v.name !== name)); } function makeLocalDraggable(name: string) { return { draggable: true as const, onDragStart: (e: DragEvent) => { const ref: SignalRef = { ds: 'local', name }; e.dataTransfer?.setData('application/json', JSON.stringify(ref)); onDragStart?.(ref); }, }; } // Merge custom signals that might not be in ListSignals yet const allSignals = useMemo(() => { const existing = new Set(rawSignals.map(s => `${s.ds}\0${s.name}`)); const extra = customSignals .filter(c => !existing.has(`${c.ds}\0${c.name}`)) .map(c => ({ name: c.name, ds: c.ds, type: 'custom' } as SignalInfo & { ds: string })); return [...rawSignals, ...extra]; }, [rawSignals, customSignals]); // Group signals based on selected criteria const visibleGroups = useMemo(() => { const lf = filter.toLowerCase(); const filtered = allSignals.filter(s => s.name.toLowerCase().includes(lf) || (s.description ?? '').toLowerCase().includes(lf) ); const groups: Record> = {}; filtered.forEach(s => { let key = 'unknown'; if (groupBy === 'datasource') { key = s.ds; } else if (groupBy === 'area') { key = s.properties?.area || 'no-area'; } else if (groupBy === 'system') { key = s.properties?.system || 'no-system'; } else if (groupBy === 'ioc') { key = s.properties?.iocName || 'no-ioc'; } (groups[key] ??= []).push(s); }); return Object.entries(groups).map(([id, signals]) => ({ id, label: id, signals: signals.sort((a, b) => a.name.localeCompare(b.name)), expanded: expandedGroups[id] ?? true, })).sort((a, b) => a.label.localeCompare(b.label)); }, [allSignals, groupBy, expandedGroups, filter]); function toggleGroup(id: string) { setExpandedGroups(prev => ({ ...prev, [id]: !(prev[id] ?? true) })); } function startAdding(ds: string) { setAddingTo(ds); setAddValue(''); setExpandedGroups(prev => ({ ...prev, [ds]: true })); } function commitAddManually() { const name = addValue.trim(); if (!name || !addingTo) { setAddingTo(null); return; } const next = [...customSignals, { ds: addingTo, name }]; setCustomSignals(next); saveCustom(next); setAddingTo(null); } function removeCustom(ds: string, name: string) { const next = customSignals.filter(c => !(c.ds === ds && c.name === name)); setCustomSignals(next); saveCustom(next); } function handleAddCfSignals(refs: SignalRef[]) { const existing = new Set(customSignals.map(c => `${c.ds}\0${c.name}`)); const fresh = refs.filter(r => !existing.has(`${r.ds}\0${r.name}`)); if (fresh.length === 0) return; const next = [...customSignals, ...fresh]; setCustomSignals(next); saveCustom(next); loadAllSignals(); // Refresh to fetch metadata for new ones } function handleManualAdd() { if (!manualName.trim()) return; handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]); setManualName(''); setShowManualAdd(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]) { added.push({ ds: 'epics', name: parts[0] }); } else if (parts.length >= 2 && parts[0] && parts[1]) { added.push({ ds: parts[0], name: parts[1] }); } } handleAddCfSignals(added); } function makeDraggable(sig: SignalInfo & { ds: string }) { return { draggable: true as const, onDragStart: (e: DragEvent) => { const ref: SignalRef = { ds: sig.ds, name: sig.name }; e.dataTransfer?.setData('application/json', JSON.stringify(ref)); onDragStart?.(ref); }, }; } return ( ); }