Wworking on improving the tool

This commit is contained in:
Martino Ferrari
2026-05-21 07:41:56 +02:00
parent 6ff8fb5c25
commit 71430bc3b0
30 changed files with 1820 additions and 331 deletions
+267
View File
@@ -0,0 +1,267 @@
import { h } from 'preact';
import { useState, useMemo } from 'preact/hooks';
import type { SignalRef } from './lib/types';
interface Props {
onClose: () => void;
onAddSignals: (signals: SignalRef[]) => void;
}
interface Rule {
type: 'name' | 'tag' | 'property';
key?: string;
value: string;
}
type SearchSource = 'cfs' | 'archiver';
export default function ChannelFinderModal({ onClose, onAddSignals }: Props) {
const [source, setSource] = useState<SearchSource>('cfs');
// CFS State
const [rules, setRules] = useState<Rule[]>([{ type: 'name', value: '' }]);
const [cfsResults, setCfsResults] = useState<Array<{ name: string, properties: any, tags: any }>>([]);
// Archiver State
const [archPattern, setArchPattern] = useState('');
const [archResults, setArchResults] = useState<string[]>([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
async function handleCfsSearch() {
if (!rules.some(r => r.value.trim() !== '')) return;
setSearching(true);
setError('');
try {
const params = new URLSearchParams();
for (const r of rules) {
if (!r.value.trim()) continue;
if (r.type === 'name') {
params.append('~name', r.value.includes('*') ? r.value : `*${r.value}*`);
} else if (r.type === 'tag') {
params.append('~tag', r.value);
} else if (r.type === 'property' && r.key) {
params.append(r.key, r.value);
}
}
const res = await fetch(`/api/v1/channel-finder?${params.toString()}`);
if (!res.ok) throw new Error(`CFS Search failed: ${res.statusText}`);
const data = await res.json();
setCfsResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSearching(false);
}
}
async function handleArchSearch() {
if (!archPattern.trim()) return;
setSearching(true);
setError('');
try {
const res = await fetch(`/api/v1/archiver/search?q=${encodeURIComponent(archPattern)}`);
if (!res.ok) {
if (res.status === 501) throw new Error("Archiver discovery not configured");
throw new Error(`Archiver Search failed: ${res.statusText}`);
}
const data = await res.json();
setArchResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSearching(false);
}
}
function addRule() {
setRules([...rules, { type: 'property', key: '', value: '' }]);
}
function updateRule(idx: number, patch: Partial<Rule>) {
setRules(rules.map((r, i) => i === idx ? { ...r, ...patch } : r));
}
function removeRule(idx: number) {
setRules(rules.filter((_, i) => i !== idx));
}
function handleAddAll() {
const refs = source === 'cfs'
? cfsResults.map(r => ({ ds: 'epics', name: r.name }))
: archResults.map(name => ({ ds: 'epics', name }));
onAddSignals(refs);
onClose();
}
const resultsCount = source === 'cfs' ? cfsResults.length : archResults.length;
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 750px; height: 85vh;">
<div class="wizard-header">
<span>PV Discovery</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
<div class="wizard-body" style="display: flex; flex-direction: column;">
{/* Source Tabs */}
<div class="signal-tree-tabs" style="margin-bottom: 1.5rem;">
<button
class={`stab${source === 'cfs' ? ' stab-active' : ''}`}
onClick={() => { setSource('cfs'); setError(''); }}
>Channel Finder</button>
<button
class={`stab${source === 'archiver' ? ' stab-active' : ''}`}
onClick={() => { setSource('archiver'); setError(''); }}
>Archive Appliance</button>
</div>
{source === 'cfs' ? (
<div style="flex: 0 0 auto;">
<div class="wizard-section-title">Channel Finder Rules</div>
<p class="hint" style="padding: 0 0 0.5rem 0;">Filter by name, tags, or properties.</p>
{rules.map((rule, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<select
class="prop-select"
style="flex: 0 0 100px;"
value={rule.type}
onChange={e => updateRule(idx, { type: (e.target as HTMLSelectElement).value as any })}
>
<option value="name">Name</option>
<option value="tag">Tag</option>
<option value="property">Property</option>
</select>
{rule.type === 'property' && (
<input
class="prop-input"
style="flex: 1;"
placeholder="Property name"
value={rule.key}
onInput={e => updateRule(idx, { key: (e.target as HTMLInputElement).value })}
/>
)}
<input
class="prop-input"
style="flex: 2;"
placeholder={rule.type === 'name' ? 'PV name pattern' : 'Value'}
value={rule.value}
onInput={e => updateRule(idx, { value: (e.target as HTMLInputElement).value })}
onKeyDown={e => e.key === 'Enter' && handleCfsSearch()}
/>
<button
class="icon-btn"
onClick={() => removeRule(idx)}
disabled={rules.length === 1}
></button>
</div>
))}
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button class="panel-btn" onClick={addRule}>+ Add Rule</button>
<button
class="toolbar-btn toolbar-btn-primary"
onClick={handleCfsSearch}
disabled={searching}
style="padding: 0 1.5rem;"
>
{searching ? 'Searching…' : 'Search CFS'}
</button>
</div>
</div>
) : (
<div style="flex: 0 0 auto;">
<div class="wizard-section-title">Archiver Search</div>
<p class="hint" style="padding: 0 0 0.5rem 0;">Search for archived PVs using glob patterns (e.g. <code>*TEMP*</code>).</p>
<div class="wizard-field wizard-field-row" style="gap: 0.5rem;">
<input
class="prop-input"
style="flex: 1;"
autoFocus
placeholder="PV pattern..."
value={archPattern}
onInput={e => setArchPattern((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && handleArchSearch()}
/>
<button
class="toolbar-btn toolbar-btn-primary"
onClick={handleArchSearch}
disabled={searching || !archPattern.trim()}
style="padding: 0 1.5rem;"
>
{searching ? 'Searching…' : 'Search Archiver'}
</button>
</div>
</div>
)}
<div class="wizard-section-title" style="margin-top: 1.5rem; display: flex; justify-content: space-between; align-items: center;">
<span>Results {resultsCount > 0 && `(${resultsCount})`}</span>
{resultsCount > 0 && (
<button class="panel-btn" style="font-size: 0.7rem; padding: 0.1rem 0.4rem;" onClick={handleAddAll}>Add All to Sidebar</button>
)}
</div>
{error && <p class="wizard-error">{error}</p>}
<div class="panel-list" style="flex: 1; border: 1px solid #2d3748; border-radius: 4px; background: #0f1117; overflow-y: auto;">
{resultsCount === 0 ? (
<p class="hint" style="text-align: center; padding: 2rem;">
{searching ? 'Searching…' : 'No results to show.'}
</p>
) : (
<ul class="iface-list">
{source === 'cfs' ? (
cfsResults.map(r => (
<li key={r.name} class="iface-item" style="cursor: default;">
<div style="display: flex; flex-direction: column; gap: 2px;">
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{r.name}</span>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
{r.tags?.map((t: any) => (
<span key={t.name} style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155;">
{t.name}
</span>
))}
</div>
</div>
<button
class="icon-btn"
title="Add to sidebar"
onClick={() => onAddSignals([{ ds: 'epics', name: r.name }])}
>+</button>
</li>
))
) : (
archResults.map(name => (
<li key={name} class="iface-item" style="cursor: default;">
<span style="font-family: ui-monospace, monospace; color: #e2e8f0;">{name}</span>
<button
class="icon-btn"
title="Add to sidebar"
onClick={() => onAddSignals([{ ds: 'epics', name }])}
>+</button>
</li>
))
)}
</ul>
)}
</div>
</div>
<div class="wizard-footer">
<button class="toolbar-btn" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}
+31
View File
@@ -210,6 +210,7 @@ export default function EditMode({ initial, onDone }: Props) {
setIface(f => ({ ...f, id: json.id }));
}
setDirty(false);
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -254,6 +255,9 @@ export default function EditMode({ initial, onDone }: Props) {
onDone(iface);
}
// Clipboard
const clipboard = useRef<Widget[]>([]);
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) {
@@ -268,6 +272,33 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(true);
return;
}
// Copy
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) {
e.preventDefault();
clipboard.current = iface.widgets
.filter(w => selectedIds.includes(w.id))
.map(w => ({ ...w }));
return;
}
// Paste
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) {
e.preventDefault();
pushUndo();
const offset = 20;
const pasted = clipboard.current.map(w => ({
...w,
id: genWidgetId(),
x: w.x + offset,
y: w.y + offset,
}));
setIface(f => ({ ...f, widgets: [...f.widgets, ...pasted] }));
setSelectedIds(pasted.map(w => w.id));
setDirty(true);
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
+2
View File
@@ -643,6 +643,8 @@ function SectionHistory() {
function SectionShortcuts() {
const shortcuts: Array<[string, string]> = [
['Ctrl+C', 'Copy selected widgets (Edit mode)'],
['Ctrl+V', 'Paste widgets from clipboard (Edit mode)'],
['Ctrl+Z', 'Undo last change (Edit mode)'],
['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'],
['Ctrl+A', 'Select all widgets (Edit mode)'],
+31
View File
@@ -89,6 +89,37 @@ export default function InfoPanel({ signal, onClose }: Props) {
</tbody>
</table>
{meta.tags && meta.tags.length > 0 && (
<div>
<div class="info-sep" />
<div class="info-section-hdr">Tags</div>
<div style="display: flex; gap: 4px; flex-wrap: wrap; padding: 4px 0.75rem;">
{meta.tags.map(t => (
<span key={t} style="font-size: 0.7rem; background: #1e293b; color: #94a3b8; padding: 1px 6px; border-radius: 4px; border: 1px solid #334155;">
{t}
</span>
))}
</div>
</div>
)}
{meta.properties && Object.keys(meta.properties).length > 0 && (
<div>
<div class="info-sep" />
<div class="info-section-hdr">Properties</div>
<table class="info-table">
<tbody>
{Object.entries(meta.properties).map(([k, v]) => (
<tr key={k}>
<td class="info-key">{k}</td>
<td class="info-val">{v}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{meta.enumStrings && meta.enumStrings.length > 0 && (
<div>
<div class="info-sep" />
+6 -1
View File
@@ -31,7 +31,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
.finally(() => setLoading(false));
}
useEffect(() => { refresh(); }, []);
useEffect(() => {
refresh();
const handleRefresh = () => refresh();
window.addEventListener('uopi:refresh-interfaces', handleRefresh);
return () => window.removeEventListener('uopi:refresh-interfaces', handleRefresh);
}, []);
function triggerImport() {
fileInputRef.current?.click();
+1 -1
View File
@@ -107,7 +107,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</div>
{w && (
<div class="props-section">
<div class="props-section" key={w.id}>
<div class="props-section-title">{w.type}</div>
{/* Position / size */}
+260 -209
View File
@@ -1,11 +1,13 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef } from './lib/types';
import SyntheticWizard from './SyntheticWizard';
import SyntheticEditor from './SyntheticEditor';
import GroupsTree from './GroupsTree';
import ChannelFinderModal from './ChannelFinderModal';
type TreeTab = 'sources' | 'groups';
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
interface SignalInfo {
name: string;
@@ -13,14 +15,17 @@ interface SignalInfo {
unit?: string;
description?: string;
writable?: boolean;
properties?: Record<string, string>;
tags?: string[];
}
interface DsGroup {
ds: string;
signals: SignalInfo[];
interface DisplayGroup {
id: string;
label: string;
signals: Array<SignalInfo & { ds: string }>;
expanded: boolean;
addingSignal: boolean; // show add-PV input
addValue: string;
addingSignal?: boolean;
addValue?: string;
}
// Custom signals persisted in localStorage
@@ -45,35 +50,44 @@ interface Props {
export default function SignalTree({ onDragStart, width }: Props) {
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
const [groupBy, setGroupBy] = useState<GroupBy>('datasource');
const [collapsed, setCollapsed] = useState(false);
const [groups, setGroups] = useState<DsGroup[]>([]);
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 [cfSearching, setCfSearching] = 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 loadGroups() {
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 loaded: DsGroup[] = await Promise.all(
const all: Array<SignalInfo & { ds: string }> = [];
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: '' };
}
if (r.ok) {
const sigs: SignalInfo[] = await r.json();
all.push(...sigs.map(s => ({ ...s, ds: name })));
}
} catch (e) { console.error(`Failed to load ${name}:`, e); }
})
);
setGroups(loaded);
setRawSignals(all);
// Check if Channel Finder is available
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null);
@@ -85,53 +99,66 @@ export default function SignalTree({ onDragStart, width }: Props) {
}
}
useEffect(() => { loadGroups(); }, []);
useEffect(() => { loadAllSignals(); }, []);
// 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 };
});
// 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]);
// 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: '',
}));
// 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 visibleGroups = [...allGroups, ...orphanGroups];
const groups: Record<string, Array<SignalInfo & { ds: string }>> = {};
function toggleGroup(ds: string) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
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 setAdding(ds: string, val: boolean) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, addingSignal: val, addValue: '' } : g));
function startAdding(ds: string) {
setAddingTo(ds);
setAddValue('');
setExpandedGroups(prev => ({ ...prev, [ds]: true }));
}
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 }];
function commitAddManually() {
const name = addValue.trim();
if (!name || !addingTo) { setAddingTo(null); return; }
const next = [...customSignals, { ds: addingTo, name }];
setCustomSignals(next);
saveCustom(next);
setAdding(ds, false);
setAddingTo(null);
}
function removeCustom(ds: string, name: string) {
@@ -140,26 +167,21 @@ export default function SignalTree({ onDragStart, width }: Props) {
saveCustom(next);
}
// ── Channel Finder search ────────────────────────────────────────────────
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
}
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);
}
function handleManualAdd() {
if (!manualName.trim()) return;
handleAddCfSignals([{ ds: manualDs, name: manualName.trim() }]);
setManualName('');
setShowManualAdd(false);
}
// ── CSV import ────────────────────────────────────────────────────────────
@@ -176,39 +198,19 @@ export default function SignalTree({ onDragStart, width }: Props) {
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);
handleAddCfSignals(added);
}
// ── 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) {
function makeDraggable(sig: SignalInfo & { ds: string }) {
return {
draggable: true as const,
onDragStart: (e: DragEvent) => {
const ref: SignalRef = { ds, name: sig.name };
const ref: SignalRef = { ds: sig.ds, name: sig.name };
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
onDragStart?.(ref);
},
@@ -226,7 +228,6 @@ export default function SignalTree({ onDragStart, width }: Props) {
{!collapsed && (
<div class="signal-tree-body">
{/* Tab bar: Sources | Groups */}
<div class="signal-tree-tabs">
<button
class={`stab${treeTab === 'sources' ? ' stab-active' : ''}`}
@@ -238,132 +239,137 @@ export default function SignalTree({ onDragStart, width }: Props) {
>Groups</button>
</div>
{/* Groups tab */}
{treeTab === 'groups' && (
<GroupsTree onDragStart={onDragStart} />
)}
{/* Sources tab contents */}
{treeTab === 'sources' && <>
{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>
{/* 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>
<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>
{/* 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>}
{group.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(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 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>
)}
</div>
);
})
)}
</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 && (
<SyntheticWizard
onClose={() => setShowWizard(false)}
onCreated={loadGroups}
onCreated={loadAllSignals}
/>
)}
@@ -371,9 +377,54 @@ export default function SignalTree({ onDragStart, width }: Props) {
<SyntheticEditor
name={editSynthetic}
onClose={() => setEditSynthetic(null)}
onSaved={loadGroups}
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>Data Source</label>
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
<option key={ds} value={ds}>{ds}</option>
))}
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
</select>
</div>
<div class="wizard-field">
<label>Signal / PV Name</label>
<input
class="prop-input"
autoFocus
placeholder="e.g. MY:PV:NAME"
value={manualName}
onInput={e => setManualName((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
/>
</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>
);
}
+164 -24
View File
@@ -1,6 +1,7 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { useState, useEffect, useMemo } from 'preact/hooks';
import LuaEditor from './LuaEditor';
import type { InputRef, SignalDef, PipelineNode } from './lib/types';
interface NodeParam {
label: string;
@@ -20,48 +21,126 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
interface PipelineNode {
type: string;
params: Record<string, any>;
}
interface SignalDef {
name: string;
ds: string;
signal: string;
pipeline: PipelineNode[];
meta: {
unit?: string;
description?: string;
displayLow?: number;
displayHigh?: number;
};
}
interface Props {
name: string;
onClose: () => void;
onSaved: () => void;
}
interface DataSource {
name: string;
}
interface SignalInfo {
name: string;
description?: string;
}
// ── Searchable Selector ──────────────────────────────────────────────────────
function SearchableSelect({
value,
options,
onSelect,
placeholder = 'Select…'
}: {
value: string;
options: string[];
onSelect: (val: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const f = filter.toLowerCase();
return options.filter(o => o.toLowerCase().includes(f));
}, [options, filter]);
return (
<div class="search-select">
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
{value || <span class="hint">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Search…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
/>
<div class="search-select-list">
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
{filtered.map(opt => (
<div
key={opt}
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
>
{opt}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
// ── Main Editor ──────────────────────────────────────────────────────────────
export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
const [def, setDef] = useState<SignalDef | null>(null);
const [inputs, setInputs] = useState<InputRef[]>([]);
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [addType, setAddType] = useState('gain');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Loaded data for selectors
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (!ds || dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
useEffect(() => {
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then((d: SignalDef) => {
setDef(d);
const initialInputs = d.inputs && d.inputs.length > 0
? d.inputs
: (d.ds && d.signal ? [{ ds: d.ds, signal: d.signal }] : []);
setInputs(initialInputs);
setPipeline(d.pipeline ?? []);
// Load signals for existing input data sources
initialInputs.forEach(inp => loadSignals(inp.ds));
})
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
@@ -107,12 +186,42 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
setPipeline(p => [...p, { type: addType, params }]);
}
function updateInput(idx: number, patch: Partial<InputRef>) {
setInputs(prev => prev.map((inp, i) => {
if (i !== idx) return inp;
const next = { ...inp, ...patch };
if (patch.ds) {
next.signal = '';
loadSignals(patch.ds);
}
return next;
}));
}
function addInput() {
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
}
function removeInput(idx: number) {
setInputs(prev => prev.filter((_, i) => i !== idx));
}
async function handleSave() {
if (!def) return;
if (inputs.length === 0) { setError('At least one input is required'); return; }
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
setSaving(true);
setError('');
try {
const body = { ...def, pipeline };
const body = {
...def,
inputs,
pipeline,
// Clear legacy fields if present
ds: undefined,
signal: undefined
};
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -135,7 +244,7 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
<div class="wizard-backdrop" onClick={onClose}>
<div class="wizard" onClick={(e) => e.stopPropagation()} style="max-width:560px;">
<div class="wizard-header">
<span>Edit Pipeline {name}</span>
<span>Edit Synthetic Signal {name}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
@@ -145,6 +254,37 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
<p class="hint">Loading</p>
) : (
<div>
<div class="wizard-section-title">Input signals</div>
{inputs.map((inp, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="wizard-field" style="flex:1;">
<label>Data source</label>
<SearchableSelect
value={inp.ds}
options={dataSources}
onSelect={(ds) => updateInput(idx, { ds })}
/>
</div>
<div class="wizard-field" style="flex:2;">
<label>Signal</label>
<SearchableSelect
value={inp.signal}
options={dsSignals[inp.ds] || []}
onSelect={(signal) => updateInput(idx, { signal })}
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
/>
</div>
<button
class="icon-btn"
style="margin-bottom: 0.25rem;"
title="Remove input"
onClick={() => removeInput(idx)}
disabled={inputs.length === 1}
></button>
</div>
))}
<button class="panel-btn" style="margin-top: 0.5rem; margin-bottom: 1.5rem;" onClick={addInput}>+ Add input</button>
<div class="wizard-section-title">Pipeline nodes</div>
{pipeline.length === 0 && (
<p class="hint" style="padding:0.5rem 0;">No processing nodes input passes through unchanged.</p>
@@ -197,7 +337,7 @@ export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
<div class="wizard-footer">
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Saving…' : 'Save Pipeline'}
{saving ? 'Saving…' : 'Save Signal'}
</button>
</div>
</div>
+146 -24
View File
@@ -1,6 +1,7 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { useState, useEffect, useMemo } from 'preact/hooks';
import LuaEditor from './LuaEditor';
import type { InputRef, SignalDef } from './lib/types';
interface Props {
onClose: () => void;
@@ -25,14 +26,80 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'lua', default: 'return a' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
interface DataSource {
name: string;
}
interface SignalInfo {
name: string;
description?: string;
}
// ── Searchable Selector ──────────────────────────────────────────────────────
function SearchableSelect({
value,
options,
onSelect,
placeholder = 'Select…'
}: {
value: string;
options: string[];
onSelect: (val: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const f = filter.toLowerCase();
return options.filter(o => o.toLowerCase().includes(f));
}, [options, filter]);
return (
<div class="search-select">
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
{value || <span class="hint">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Search…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
/>
<div class="search-select-list">
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
{filtered.map(opt => (
<div
key={opt}
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
>
{opt}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
// ── Main Wizard ──────────────────────────────────────────────────────────────
export default function SyntheticWizard({ onClose, onCreated }: Props) {
const [name, setName] = useState('');
const [inputDs, setInputDs] = useState('stub');
const [inputSig, setInputSig] = useState('sine_1hz');
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
const [nodeType, setNodeType] = useState('gain');
const [params, setParams] = useState<Record<string, string>>({});
const [unit, setUnit] = useState('');
@@ -42,6 +109,27 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Loaded data
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
function getParam(key: string, def: string): string {
@@ -52,9 +140,29 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
setParams(p => ({ ...p, [key]: val }));
}
function updateInput(idx: number, patch: Partial<InputRef>) {
setInputs(prev => prev.map((inp, i) => {
if (i !== idx) return inp;
const next = { ...inp, ...patch };
if (patch.ds) {
next.signal = '';
loadSignals(patch.ds);
}
return next;
}));
}
function addInput() {
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
}
function removeInput(idx: number) {
setInputs(prev => prev.filter((_, i) => i !== idx));
}
async function handleCreate() {
if (!name.trim()) { setError('Name is required'); return; }
if (!inputSig.trim()) { setError('Input signal is required'); return; }
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
const nodeParams: Record<string, any> = {};
for (const p of nodeDef.params) {
@@ -62,10 +170,9 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
}
const def = {
const def: SignalDef = {
name: name.trim(),
ds: inputDs,
signal: inputSig.trim(),
inputs,
pipeline: [{ type: nodeType, params: nodeParams }],
meta: {
unit: unit || undefined,
@@ -115,23 +222,38 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-section-title">Input signal</div>
<div class="wizard-field wizard-field-row">
<div class="wizard-field">
<label>Data source</label>
<input class="prop-input" value={inputDs}
placeholder="stub"
onInput={(e) => setInputDs((e.target as HTMLInputElement).value)} />
<div class="wizard-section-title">Input signals</div>
{inputs.map((inp, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="wizard-field" style="flex:1;">
<label>Data source</label>
<SearchableSelect
value={inp.ds}
options={dataSources}
onSelect={(ds) => updateInput(idx, { ds })}
/>
</div>
<div class="wizard-field" style="flex:2;">
<label>Signal</label>
<SearchableSelect
value={inp.signal}
options={dsSignals[inp.ds] || []}
onSelect={(signal) => updateInput(idx, { signal })}
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
/>
</div>
<button
class="icon-btn"
style="margin-bottom: 0.25rem;"
title="Remove input"
onClick={() => removeInput(idx)}
disabled={inputs.length === 1}
></button>
</div>
<div class="wizard-field" style="flex:2;">
<label>Signal name</label>
<input class="prop-input" value={inputSig}
placeholder="sine_1hz"
onInput={(e) => setInputSig((e.target as HTMLInputElement).value)} />
</div>
</div>
))}
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
<div class="wizard-section-title">Processing</div>
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
<div class="wizard-field">
<label>Node type</label>
<select class="prop-select" value={nodeType}
+29
View File
@@ -20,6 +20,8 @@ export interface SignalMeta {
writable: boolean;
enumStrings?: string[];
description?: string;
properties?: Record<string, string>;
tags?: string[];
}
// A single widget in an interface
@@ -55,3 +57,30 @@ export interface Interface {
h: number;
widgets: Widget[];
}
// ── Synthetic signals ────────────────────────────────────────────────────────
export interface InputRef {
ds: string;
signal: string;
}
export interface PipelineNode {
type: string;
params: Record<string, any>;
}
export interface SignalDef {
name: string;
ds?: string; // legacy single input
signal?: string; // legacy single input
inputs?: InputRef[];
pipeline: PipelineNode[];
meta: {
unit?: string;
description?: string;
displayLow?: number;
displayHigh?: number;
writable?: boolean;
};
}
+133
View File
@@ -2768,3 +2768,136 @@ kbd {
text-align: center;
font-variant-numeric: tabular-nums;
}
/* ── Searchable Select ────────────────────────────────────────────────────── */
.search-select {
position: relative;
width: 100%;
}
.search-select-trigger {
background: #0f1117;
border: 1px solid #2d3748;
border-radius: 3px;
padding: 0.15rem 0.35rem;
font-size: 0.8rem;
color: #e2e8f0;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 1.5rem;
}
.search-select-trigger:hover {
border-color: #4a5568;
}
.search-select-arrow {
font-size: 0.6rem;
color: #64748b;
margin-left: 0.25rem;
}
.search-select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #1a1f2e;
border: 1px solid #4a9eff;
border-radius: 4px;
margin-top: 2px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
padding: 0.25rem;
gap: 0.25rem;
}
.search-select-list {
max-height: 180px;
overflow-y: auto;
}
.search-select-item {
padding: 0.2rem 0.4rem;
font-size: 0.75rem;
color: #cbd5e1;
cursor: pointer;
border-radius: 2px;
}
.search-select-item:hover {
background: #2d3748;
color: #e2e8f0;
}
.search-select-item-selected {
background: rgba(74, 158, 255, 0.15);
color: #4a9eff;
font-weight: 600;
}
.search-select-backdrop {
position: fixed;
inset: 0;
z-index: 999;
}
/* ── Signal Tree Refinements ────────────────────────────────────────────── */
.signal-tree-toolbar select.prop-select {
border-color: #2d3748;
background-color: #0f1117;
color: #94a3b8;
transition: all 0.2s;
}
.signal-tree-toolbar select.prop-select:hover {
border-color: #4a5568;
color: #e2e8f0;
}
.signal-item {
display: flex;
align-items: center;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
color: #cbd5e1;
border-radius: 4px;
cursor: grab;
gap: 0.4rem;
}
.signal-item:hover {
background: #2d3748;
}
.signal-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signal-unit {
font-size: 0.7rem;
color: #64748b;
background: #1a1f2e;
padding: 0 4px;
border-radius: 3px;
border: 1px solid #2d3748;
}
.panel-btn.icon-only {
padding: 0;
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
}