cb4e81beb2
The signal-tree pane (edit mode) has its own inline "add local variable" form, separate from the Logic-tab LocalVars form that gained array support in the array-locals feature. This second entry point was missed, so the type dropdown there only offered number/bool/string. Mirror the LogicEditor form: add the array type option plus elem/sizing/capacity selects and a JSON-validated initial value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
618 lines
26 KiB
TypeScript
618 lines
26 KiB
TypeScript
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<string, string>;
|
|
tags?: string[];
|
|
}
|
|
|
|
interface DisplayGroup {
|
|
id: string;
|
|
label: string;
|
|
signals: Array<SignalInfo & { ds: string }>;
|
|
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<TreeTab>('sources');
|
|
const [groupBy, setGroupBy] = useState<GroupBy>('datasource');
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [rawSignals, setRawSignals] = useState<Array<SignalInfo & { ds: string }>>([]);
|
|
const [customSignals, setCustomSignals] = useState<Array<{ ds: string; name: string }>>(loadCustom);
|
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
|
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<string | null>(null);
|
|
const [addValue, setAddValue] = useState('');
|
|
const [editSynthetic, setEditSynthetic] = useState<string | null>(null);
|
|
const csvRef = useRef<HTMLInputElement>(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<SignalInfo & { ds: string }> = [];
|
|
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<string, Array<SignalInfo & { ds: string }>> = {};
|
|
|
|
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 (
|
|
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
|
<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">
|
|
<div class="signal-tree-tabs">
|
|
<button
|
|
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
|
|
onClick={() => setTreeTab('sources')}
|
|
>Sources</button>
|
|
<button
|
|
class={`stab${treeTab === 'groups' ? ' stab-active' : ''}`}
|
|
onClick={() => setTreeTab('groups')}
|
|
>Groups</button>
|
|
</div>
|
|
|
|
{treeTab === 'groups' && (
|
|
<GroupsTree onDragStart={onDragStart} />
|
|
)}
|
|
|
|
{treeTab === 'sources' && onStateVarsChange && (
|
|
<div class="signal-group local-state-group">
|
|
<div class="signal-group-header">
|
|
<span class="signal-group-arrow" onClick={() => setLocalOpen(o => !o)}>
|
|
{localOpen ? '▾' : '▸'}
|
|
</span>
|
|
<span class="signal-group-name" title="Panel-local state variables" onClick={() => setLocalOpen(o => !o)}>
|
|
Local State
|
|
</span>
|
|
<span class="signal-group-count">{(statevars ?? []).length}</span>
|
|
<button
|
|
class="icon-btn signal-add-btn"
|
|
title="Add local state variable"
|
|
onClick={(e) => { e.stopPropagation(); setShowAddLocal(s => !s); setLocalOpen(true); }}
|
|
>+</button>
|
|
</div>
|
|
{localOpen && (
|
|
<div>
|
|
{showAddLocal && (
|
|
<div class="signal-add-row" style="flex-wrap: wrap; gap: 4px;">
|
|
<input
|
|
class="signal-add-input"
|
|
placeholder="Variable name…"
|
|
autoFocus
|
|
value={lvName}
|
|
onInput={(e) => setLvName((e.target as HTMLInputElement).value)}
|
|
onKeyDown={(e: KeyboardEvent) => {
|
|
if (e.key === 'Enter') addLocal();
|
|
if (e.key === 'Escape') setShowAddLocal(false);
|
|
}}
|
|
/>
|
|
<select
|
|
class="prop-select"
|
|
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
|
|
value={lvType}
|
|
onChange={(e) => handleLvTypeChange((e.target as HTMLSelectElement).value as any)}
|
|
>
|
|
<option value="number">number</option>
|
|
<option value="bool">bool</option>
|
|
<option value="string">string</option>
|
|
<option value="array">array</option>
|
|
</select>
|
|
{lvType === 'array' && (
|
|
<Fragment>
|
|
<select
|
|
class="prop-select"
|
|
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
|
|
value={lvElem}
|
|
onChange={(e) => setLvElem((e.target as HTMLSelectElement).value as any)}
|
|
>
|
|
<option value="number">elem: number</option>
|
|
<option value="bool">elem: bool</option>
|
|
<option value="array">elem: array</option>
|
|
</select>
|
|
<select
|
|
class="prop-select"
|
|
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
|
|
value={lvSizing}
|
|
onChange={(e) => setLvSizing((e.target as HTMLSelectElement).value as any)}
|
|
>
|
|
<option value="dynamic">sizing: dynamic</option>
|
|
<option value="capped">sizing: capped</option>
|
|
<option value="fixed">sizing: fixed</option>
|
|
</select>
|
|
{lvSizing !== 'dynamic' && (
|
|
<input
|
|
class="signal-add-input"
|
|
type="number"
|
|
style="width: 5rem;"
|
|
placeholder="capacity"
|
|
value={lvCapacity}
|
|
onInput={(e) => setLvCapacity((e.target as HTMLInputElement).value)}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
)}
|
|
<input
|
|
class={`signal-add-input${lvJsonErr ? ' prop-input-error' : ''}`}
|
|
style="flex: 1;"
|
|
placeholder={lvType === 'array' ? 'initial (JSON)' : 'initial'}
|
|
value={lvInitial}
|
|
onInput={(e) => handleLvInitialChange((e.target as HTMLInputElement).value)}
|
|
onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') addLocal(); }}
|
|
/>
|
|
{lvJsonErr && <p class="wizard-error" style="flex-basis: 100%; margin: 0;">{lvJsonErr}</p>}
|
|
<button class="icon-btn" title="Add" onClick={addLocal}>✓</button>
|
|
<button class="icon-btn" title="Cancel" onClick={() => setShowAddLocal(false)}>✕</button>
|
|
</div>
|
|
)}
|
|
{(statevars ?? []).length === 0 && !showAddLocal && (
|
|
<p class="hint" style="padding: 0.25rem 0.5rem;">No local variables.</p>
|
|
)}
|
|
{(statevars ?? []).map(v => (
|
|
<div
|
|
key={v.name}
|
|
class="signal-item signal-custom"
|
|
title={`local:${v.name} (${v.type ?? 'number'}, initial ${v.initial})`}
|
|
{...makeLocalDraggable(v.name)}
|
|
>
|
|
<span class="signal-name">{v.name}</span>
|
|
<span class="signal-unit">{v.type ?? 'number'}</span>
|
|
<button
|
|
class="icon-btn signal-remove-btn"
|
|
title="Remove local variable"
|
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
|
onClick={(e: MouseEvent) => { e.stopPropagation(); removeLocal(v.name); }}
|
|
>✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{treeTab === 'sources' && (
|
|
<Fragment>
|
|
<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 && (
|
|
<button class="icon-btn" title="Advanced Search" onClick={() => setShowCfModal(true)}>
|
|
🔍
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="signal-tree-toolbar" style="gap: 4px;">
|
|
<select
|
|
class="prop-select"
|
|
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px; flex: 1;"
|
|
value={groupBy}
|
|
onChange={e => setGroupBy((e.target as HTMLSelectElement).value as any)}
|
|
>
|
|
<option value="datasource">By Source</option>
|
|
<option value="area">By Area</option>
|
|
<option value="system">By System</option>
|
|
<option value="ioc">By IOC</option>
|
|
</select>
|
|
<button class="panel-btn icon-only" title="Add Signal Manually" onClick={() => setShowManualAdd(true)}>+</button>
|
|
<button class="panel-btn icon-only" title="Create synthetic signal" onClick={() => setShowWizard(true)}>Σ</button>
|
|
<button class="panel-btn icon-only" title="Import CSV" onClick={() => csvRef.current?.click()}>CSV</button>
|
|
<button class="panel-btn icon-only" title="Refresh signal list" onClick={loadAllSignals}>↺</button>
|
|
<input ref={csvRef} type="file" accept=".csv,text/csv" style="display:none" onChange={handleCsvImport} />
|
|
</div>
|
|
|
|
<div class="panel-list signal-list">
|
|
{loading ? (
|
|
<p class="hint">Loading signals…</p>
|
|
) : visibleGroups.length === 0 ? (
|
|
<p class="hint">No signals found.</p>
|
|
) : (
|
|
visibleGroups.map(group => (
|
|
<div key={group.id} class="signal-group">
|
|
<div class="signal-group-header">
|
|
<span class="signal-group-arrow" onClick={() => toggleGroup(group.id)}>
|
|
{group.expanded ? '▾' : '▸'}
|
|
</span>
|
|
<span class="signal-group-name" title={group.label} onClick={() => toggleGroup(group.id)}>
|
|
{group.label}
|
|
</span>
|
|
<span class="signal-group-count">{group.signals.length}</span>
|
|
{groupBy === 'datasource' && group.id !== 'synthetic' && (
|
|
<button
|
|
class="icon-btn signal-add-btn"
|
|
title={`Add custom signal to ${group.id}`}
|
|
onClick={(e) => { e.stopPropagation(); startAdding(group.id); }}
|
|
>+</button>
|
|
)}
|
|
</div>
|
|
|
|
{group.expanded && (
|
|
<div>
|
|
{addingTo === group.id && (
|
|
<div class="signal-add-row">
|
|
<input
|
|
class="signal-add-input"
|
|
placeholder="Signal / PV name…"
|
|
autoFocus
|
|
value={addValue}
|
|
onInput={(e) => setAddValue((e.target as HTMLInputElement).value)}
|
|
onKeyDown={(e: KeyboardEvent) => {
|
|
if (e.key === 'Enter') commitAddManually();
|
|
if (e.key === 'Escape') setAddingTo(null);
|
|
}}
|
|
/>
|
|
<button class="icon-btn" onClick={commitAddManually}>✓</button>
|
|
<button class="icon-btn" onClick={() => setAddingTo(null)}>✕</button>
|
|
</div>
|
|
)}
|
|
{group.signals.map(sig => {
|
|
const isCustom = sig.type === 'custom' || !rawSignals.some(rs => rs.ds === sig.ds && rs.name === sig.name);
|
|
return (
|
|
<div
|
|
key={`${sig.ds}:${sig.name}`}
|
|
class={`signal-item${isCustom ? ' signal-custom' : ''}`}
|
|
title={`${sig.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? ` — ${sig.description}` : ''}`}
|
|
{...makeDraggable(sig)}
|
|
>
|
|
<span class="signal-name">{sig.name}</span>
|
|
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
|
|
{sig.ds === 'synthetic' && (
|
|
<button
|
|
class="icon-btn signal-edit-btn"
|
|
title="Edit pipeline"
|
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
|
onClick={(e: MouseEvent) => { e.stopPropagation(); setEditSynthetic(sig.name); }}
|
|
>✎</button>
|
|
)}
|
|
{isCustom && (
|
|
<button
|
|
class="icon-btn signal-remove-btn"
|
|
title="Remove custom signal"
|
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
|
onClick={(e: MouseEvent) => { e.stopPropagation(); removeCustom(sig.ds, sig.name); }}
|
|
>✕</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</Fragment>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showWizard && (
|
|
<SyntheticGraphEditor
|
|
create
|
|
panelId={panelId}
|
|
onClose={() => setShowWizard(false)}
|
|
onSaved={loadAllSignals}
|
|
/>
|
|
)}
|
|
|
|
{editSynthetic && (
|
|
<SyntheticGraphEditor
|
|
name={editSynthetic}
|
|
onClose={() => setEditSynthetic(null)}
|
|
onSaved={loadAllSignals}
|
|
/>
|
|
)}
|
|
|
|
{showCfModal && (
|
|
<ChannelFinderModal
|
|
cfURL="" // fetched by backend API
|
|
onClose={() => setShowCfModal(false)}
|
|
onAddSignals={handleAddCfSignals}
|
|
/>
|
|
)}
|
|
|
|
{showManualAdd && (
|
|
<div class="wizard-backdrop" onClick={() => setShowManualAdd(false)}>
|
|
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 400px;">
|
|
<div class="wizard-header">
|
|
<span>Add Signal Manually</span>
|
|
<button class="icon-btn" onClick={() => setShowManualAdd(false)}>✕</button>
|
|
</div>
|
|
<div class="wizard-body">
|
|
<div class="wizard-field">
|
|
<label>Signal</label>
|
|
<SignalPicker
|
|
value={manualName ? `${manualDs}:${manualName}` : ''}
|
|
options={allSignals}
|
|
allowFreeform
|
|
placeholder="Search or type ds:NAME…"
|
|
onChange={(ref) => {
|
|
const i = ref.indexOf(':');
|
|
if (i < 0) { setManualDs(ref); setManualName(''); }
|
|
else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
|
|
}} />
|
|
<p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
|
|
</div>
|
|
</div>
|
|
<div class="wizard-footer">
|
|
<button class="toolbar-btn" onClick={() => setShowManualAdd(false)}>Cancel</button>
|
|
<button class="toolbar-btn toolbar-btn-primary" onClick={handleManualAdd}>Add Signal</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|