817 lines
35 KiB
TypeScript
817 lines
35 KiB
TypeScript
import { h, Fragment } from 'preact';
|
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
|
|
|
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
|
|
|
|
type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum';
|
|
|
|
interface Parameter {
|
|
key: string;
|
|
label?: string;
|
|
group?: string;
|
|
subgroup?: string;
|
|
ds: string;
|
|
signal: string;
|
|
type: ParamType;
|
|
default?: any;
|
|
mandatory?: boolean;
|
|
min?: number;
|
|
max?: number;
|
|
enumValues?: string[];
|
|
unit?: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface ConfigSet {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
version: number;
|
|
tag?: string;
|
|
owner?: string;
|
|
parameters: Parameter[];
|
|
}
|
|
|
|
interface ConfigInstance {
|
|
id: string;
|
|
name: string;
|
|
setId: string;
|
|
setVersion?: number;
|
|
version: number;
|
|
tag?: string;
|
|
owner?: string;
|
|
values: Record<string, any>;
|
|
}
|
|
|
|
interface Meta { id: string; name: string; version: number; }
|
|
interface VersionMeta { version: number; name: string; tag?: string; current: boolean; savedAt: string; }
|
|
|
|
interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
|
|
interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; }
|
|
|
|
type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged';
|
|
interface SetDiffEntry { key: string; status: DiffStatus; left?: Parameter; right?: Parameter; }
|
|
interface InstanceDiffEntry { key: string; status: DiffStatus; left?: any; right?: any; }
|
|
|
|
const PARAM_TYPES: ParamType[] = ['float64', 'int64', 'bool', 'string', 'enum'];
|
|
|
|
// ── API helper ───────────────────────────────────────────────────────────────
|
|
|
|
async function apiJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
|
|
const res = await fetch(url, opts);
|
|
if (!res.ok && res.status !== 204) {
|
|
let msg = `HTTP ${res.status}`;
|
|
try { const e = await res.json(); if (e && e.error) msg = e.error; } catch { /* ignore */ }
|
|
throw new Error(msg);
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
// Lazily-loaded data-source + signal options powering the SignalPicker.
|
|
function useSignals() {
|
|
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: { name: string }[]) => setDataSources(ds.map(d => d.name)))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const loadSignals = useCallback((ds: string) => {
|
|
if (!ds) return;
|
|
setDsSignals(prev => {
|
|
if (prev[ds]) return prev;
|
|
fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`)
|
|
.then(r => (r.ok ? r.json() : []))
|
|
.then((sigs: { name: string }[]) => setDsSignals(p => ({ ...p, [ds]: sigs.map(s => s.name) })))
|
|
.catch(() => {});
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
function allOptions(): SignalOption[] {
|
|
const out: SignalOption[] = [];
|
|
for (const ds of dataSources) for (const name of dsSignals[ds] ?? []) out.push({ ds, name });
|
|
return out;
|
|
}
|
|
function openAll() { dataSources.forEach(loadSignals); }
|
|
|
|
return { allOptions, openAll };
|
|
}
|
|
|
|
function splitRef(ref: string): { ds: string; signal: string } {
|
|
const i = ref.indexOf(':');
|
|
if (i < 0) return { ds: '', signal: ref };
|
|
return { ds: ref.slice(0, i), signal: ref.slice(i + 1) };
|
|
}
|
|
|
|
function fmtTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
// ── Top-level modal ───────────────────────────────────────────────────────────
|
|
|
|
interface Props { onClose: () => void; }
|
|
|
|
export default function ConfigManager({ onClose }: Props) {
|
|
const [tab, setTab] = useState<'sets' | 'instances'>('sets');
|
|
|
|
return (
|
|
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
<div class="cl-modal">
|
|
<header class="cl-header">
|
|
<span class="cl-title">Configuration manager</span>
|
|
<span class="hint cl-subtitle">Versioned configuration sets (schemas) and instances (values) that apply to signals.</span>
|
|
<div class="cl-header-actions">
|
|
<button class="panel-btn" onClick={onClose}>Close</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="cfg-tabs">
|
|
<button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('sets')}>Sets</button>
|
|
<button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('instances')}>Instances</button>
|
|
</div>
|
|
|
|
{tab === 'sets' ? <SetsManager /> : <InstancesManager />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Sets manager ──────────────────────────────────────────────────────────────
|
|
|
|
function SetsManager() {
|
|
const [sets, setSets] = useState<Meta[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [working, setWorking] = useState<ConfigSet | null>(null);
|
|
const [dirty, setDirty] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null);
|
|
const { allOptions, openAll } = useSignals();
|
|
|
|
const reload = useCallback(async () => {
|
|
try { setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []); }
|
|
catch (err) { setError(`Failed to load sets: ${msg(err)}`); }
|
|
}, []);
|
|
useEffect(() => { reload(); }, [reload]);
|
|
|
|
async function select(id: string) {
|
|
if (dirty && !confirm('Discard unsaved changes?')) return;
|
|
setError(null);
|
|
try {
|
|
const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(id)}`);
|
|
setSelectedId(id);
|
|
setWorking(s);
|
|
setDirty(false);
|
|
} catch (err) { setError(msg(err)); }
|
|
}
|
|
|
|
async function createSet() {
|
|
setBusy(true); setError(null);
|
|
try {
|
|
const body = { name: 'New config set', description: '', parameters: [] };
|
|
const created = await apiJSON<ConfigSet>('/api/v1/config/sets', jsonPost(body));
|
|
await reload();
|
|
setSelectedId(created!.id);
|
|
setWorking(created);
|
|
setDirty(false);
|
|
} catch (err) { setError(`Create failed: ${msg(err)}`); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
async function save() {
|
|
if (!working) return;
|
|
setBusy(true); setError(null);
|
|
try {
|
|
const saved = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working));
|
|
setWorking(saved);
|
|
setDirty(false);
|
|
await reload();
|
|
} catch (err) { setError(`Save failed: ${msg(err)}`); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
async function remove(id: string) {
|
|
if (!confirm('Delete this config set? A server-side copy is kept in trash.')) return;
|
|
setBusy(true); setError(null);
|
|
try {
|
|
await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
if (selectedId === id) { setSelectedId(null); setWorking(null); setDirty(false); }
|
|
await reload();
|
|
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
function patch(p: Partial<ConfigSet>) {
|
|
if (!working) return;
|
|
setWorking({ ...working, ...p });
|
|
setDirty(true);
|
|
}
|
|
function patchParam(i: number, p: Partial<Parameter>) {
|
|
if (!working) return;
|
|
const params = working.parameters.map((x, idx) => (idx === i ? { ...x, ...p } : x));
|
|
patch({ parameters: params });
|
|
}
|
|
function addParam() {
|
|
if (!working) return;
|
|
const n = working.parameters.length + 1;
|
|
patch({ parameters: [...working.parameters, { key: `param${n}`, ds: '', signal: '', type: 'float64' }] });
|
|
}
|
|
function removeParam(i: number) {
|
|
if (!working) return;
|
|
patch({ parameters: working.parameters.filter((_, idx) => idx !== i) });
|
|
}
|
|
|
|
async function showDiff(av: number, bv: number) {
|
|
if (!working) return;
|
|
setError(null);
|
|
try {
|
|
const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) });
|
|
const entries = (await apiJSON<SetDiffEntry[]>(`/api/v1/config/sets/diff?${q}`)) ?? [];
|
|
setDiff({ title: `${working.name}: v${av} → v${bv}`, entries });
|
|
} catch (err) { setError(`Diff failed: ${msg(err)}`); }
|
|
}
|
|
|
|
return (
|
|
<div class="cl-body">
|
|
<div class="cl-list">
|
|
<div class="cl-list-head">
|
|
<span>Config sets</span>
|
|
<button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
|
|
</div>
|
|
{sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
|
|
{sets.map(s => (
|
|
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
|
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
|
<span class="cfg-badge" title="Current version">v{s.version}</span>
|
|
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div class="cl-editor-wrap">
|
|
{error && <div class="cl-error">{error}</div>}
|
|
{!working && <div class="cl-empty hint">Select a config set on the left, or create a new one.</div>}
|
|
{working && (
|
|
<div class="cfg-editor">
|
|
<div class="cfg-edit-bar">
|
|
<input class="prop-input cl-name-input" value={working.name} placeholder="Set name"
|
|
onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
|
|
<span class="cfg-badge">v{working.version}</span>
|
|
{dirty && <span class="cl-dirty">unsaved</span>}
|
|
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
|
|
</div>
|
|
<input class="prop-input" value={working.description ?? ''} placeholder="Description (optional)"
|
|
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
|
|
|
|
<div class="cfg-section-head">
|
|
<span>Parameters</span>
|
|
<button class="panel-btn" onClick={addParam}>+ Add parameter</button>
|
|
</div>
|
|
{working.parameters.length === 0 && <div class="hint">No parameters. Each parameter binds a target signal with a default and constraints.</div>}
|
|
<div class="cfg-params">
|
|
{working.parameters.map((p, i) => (
|
|
<ParamEditor key={i} param={p} allOptions={allOptions()} onOpenSignals={openAll}
|
|
onChange={(patch2) => patchParam(i, patch2)} onRemove={() => removeParam(i)} />
|
|
))}
|
|
</div>
|
|
|
|
<VersionPanel kind="sets" id={working.id} onReload={reload}
|
|
onForked={(id) => { reload(); select(id); }}
|
|
onPromoted={() => select(working.id)}
|
|
onCompare={showDiff} onError={setError} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
|
|
<SetDiffTable entries={diff.entries} />
|
|
</DiffModal>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
|
|
param: Parameter;
|
|
allOptions: SignalOption[];
|
|
onOpenSignals: () => void;
|
|
onChange: (p: Partial<Parameter>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : '';
|
|
const isNum = param.type === 'float64' || param.type === 'int64';
|
|
return (
|
|
<div class="cfg-param">
|
|
<div class="cfg-param-row">
|
|
<div class="cfg-field cfg-field-key">
|
|
<label>Key</label>
|
|
<input class="prop-input" value={param.key} onInput={(e) => onChange({ key: (e.target as HTMLInputElement).value })} />
|
|
</div>
|
|
<div class="cfg-field cfg-field-label">
|
|
<label>Label</label>
|
|
<input class="prop-input" value={param.label ?? ''} placeholder="(optional)"
|
|
onInput={(e) => onChange({ label: (e.target as HTMLInputElement).value })} />
|
|
</div>
|
|
<div class="cfg-field cfg-field-type">
|
|
<label>Type</label>
|
|
<select class="prop-select" value={param.type} onChange={(e) => onChange({ type: (e.target as HTMLSelectElement).value as ParamType })}>
|
|
{PARAM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
</div>
|
|
<button class="cl-mini-btn cfg-param-del" title="Remove parameter" onClick={onRemove}>✕</button>
|
|
</div>
|
|
|
|
<div class="cfg-param-row">
|
|
<div class="cfg-field cfg-field-target">
|
|
<label>Target signal</label>
|
|
<SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform
|
|
onChange={(r) => { const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} />
|
|
</div>
|
|
<div class="cfg-field cfg-field-default">
|
|
<label>Default</label>
|
|
{param.type === 'bool' ? (
|
|
<select class="prop-select" value={String(param.default ?? '')}
|
|
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange({ default: v === '' ? undefined : v === 'true' }); }}>
|
|
<option value="">(none)</option>
|
|
<option value="true">true</option>
|
|
<option value="false">false</option>
|
|
</select>
|
|
) : (
|
|
<input class="prop-input" type={isNum ? 'number' : 'text'} value={param.default ?? ''}
|
|
onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
|
|
)}
|
|
</div>
|
|
<label class="cfg-mandatory">
|
|
<input type="checkbox" checked={!!param.mandatory} onChange={(e) => onChange({ mandatory: (e.target as HTMLInputElement).checked })} />
|
|
Mandatory
|
|
</label>
|
|
</div>
|
|
|
|
<div class="cfg-param-row">
|
|
<div class="cfg-field">
|
|
<label>Group</label>
|
|
<input class="prop-input" value={param.group ?? ''} placeholder="(optional)"
|
|
onInput={(e) => onChange({ group: (e.target as HTMLInputElement).value })} />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>Unit</label>
|
|
<input class="prop-input" value={param.unit ?? ''} placeholder="(optional)"
|
|
onInput={(e) => onChange({ unit: (e.target as HTMLInputElement).value })} />
|
|
</div>
|
|
{isNum && (
|
|
<Fragment>
|
|
<div class="cfg-field cfg-field-num">
|
|
<label>Min</label>
|
|
<input class="prop-input" type="number" value={param.min ?? ''}
|
|
onInput={(e) => onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
|
|
</div>
|
|
<div class="cfg-field cfg-field-num">
|
|
<label>Max</label>
|
|
<input class="prop-input" type="number" value={param.max ?? ''}
|
|
onInput={(e) => onChange({ max: numOrUndef((e.target as HTMLInputElement).value) })} />
|
|
</div>
|
|
</Fragment>
|
|
)}
|
|
{param.type === 'enum' && (
|
|
<div class="cfg-field cfg-field-enum">
|
|
<label>Enum values (comma-separated)</label>
|
|
<input class="prop-input" value={(param.enumValues ?? []).join(', ')} placeholder="a, b, c"
|
|
onInput={(e) => onChange({ enumValues: (e.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean) })} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Instances manager ─────────────────────────────────────────────────────────
|
|
|
|
function InstancesManager() {
|
|
const [instances, setInstances] = useState<Meta[]>([]);
|
|
const [sets, setSets] = useState<Meta[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [working, setWorking] = useState<ConfigInstance | null>(null);
|
|
const [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
|
|
const [dirty, setDirty] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
|
|
const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
|
|
const [showNew, setShowNew] = useState(false);
|
|
|
|
const reload = useCallback(async () => {
|
|
try {
|
|
setInstances((await apiJSON<Meta[]>('/api/v1/config/instances')) ?? []);
|
|
setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []);
|
|
} catch (err) { setError(`Failed to load: ${msg(err)}`); }
|
|
}, []);
|
|
useEffect(() => { reload(); }, [reload]);
|
|
|
|
async function loadSet(setId: string, setVersion?: number): Promise<ConfigSet | null> {
|
|
const path = setVersion && setVersion > 0
|
|
? `/api/v1/config/sets/${encodeURIComponent(setId)}/versions/${setVersion}`
|
|
: `/api/v1/config/sets/${encodeURIComponent(setId)}`;
|
|
return apiJSON<ConfigSet>(path);
|
|
}
|
|
|
|
async function select(id: string) {
|
|
if (dirty && !confirm('Discard unsaved changes?')) return;
|
|
setError(null); setApplyResult(null);
|
|
try {
|
|
const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(id)}`);
|
|
const set = await loadSet(inst!.setId, inst!.setVersion);
|
|
setSelectedId(id);
|
|
setWorking(inst);
|
|
setBoundSet(set);
|
|
setDirty(false);
|
|
} catch (err) { setError(msg(err)); }
|
|
}
|
|
|
|
async function createInstance(name: string, setId: string) {
|
|
setBusy(true); setError(null);
|
|
try {
|
|
const body = { name, setId, values: {} };
|
|
const created = await apiJSON<ConfigInstance>('/api/v1/config/instances', jsonPost(body));
|
|
const set = await loadSet(created!.setId, created!.setVersion);
|
|
await reload();
|
|
setSelectedId(created!.id);
|
|
setWorking(created);
|
|
setBoundSet(set);
|
|
setDirty(false);
|
|
setShowNew(false);
|
|
} catch (err) { setError(`Create failed: ${msg(err)}`); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
async function save() {
|
|
if (!working) return;
|
|
setBusy(true); setError(null);
|
|
try {
|
|
const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working));
|
|
setWorking(saved);
|
|
setDirty(false);
|
|
await reload();
|
|
} catch (err) { setError(`Save failed: ${msg(err)}`); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
async function remove(id: string) {
|
|
if (!confirm('Delete this config instance? A server-side copy is kept in trash.')) return;
|
|
setBusy(true); setError(null);
|
|
try {
|
|
await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
if (selectedId === id) { setSelectedId(null); setWorking(null); setBoundSet(null); setDirty(false); }
|
|
await reload();
|
|
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
async function apply() {
|
|
if (!working) return;
|
|
if (!confirm('Apply this configuration? Each value is written to its target signal.')) return;
|
|
setBusy(true); setError(null); setApplyResult(null);
|
|
try {
|
|
const res = await apiJSON<ApplyResult>(`/api/v1/config/instances/${encodeURIComponent(working.id)}/apply`, { method: 'POST' });
|
|
setApplyResult(res);
|
|
} catch (err) { setError(`Apply failed: ${msg(err)}`); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
function setValue(key: string, value: any) {
|
|
if (!working) return;
|
|
const values = { ...working.values };
|
|
if (value === undefined) delete values[key];
|
|
else values[key] = value;
|
|
setWorking({ ...working, values });
|
|
setDirty(true);
|
|
}
|
|
|
|
async function showDiff(av: number, bv: number) {
|
|
if (!working) return;
|
|
setError(null);
|
|
try {
|
|
const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) });
|
|
const entries = (await apiJSON<InstanceDiffEntry[]>(`/api/v1/config/instances/diff?${q}`)) ?? [];
|
|
setDiff({ title: `${working.name}: v${av} → v${bv}`, entries });
|
|
} catch (err) { setError(`Diff failed: ${msg(err)}`); }
|
|
}
|
|
|
|
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
|
|
|
|
return (
|
|
<div class="cl-body">
|
|
<div class="cl-list">
|
|
<div class="cl-list-head">
|
|
<span>Instances</span>
|
|
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
|
</div>
|
|
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
|
|
{sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
|
|
{instances.map(s => (
|
|
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
|
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
|
<span class="cfg-badge">v{s.version}</span>
|
|
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div class="cl-editor-wrap">
|
|
{error && <div class="cl-error">{error}</div>}
|
|
{!working && <div class="cl-empty hint">Select an instance on the left, or create a new one.</div>}
|
|
{working && (
|
|
<div class="cfg-editor">
|
|
<div class="cfg-edit-bar">
|
|
<input class="prop-input cl-name-input" value={working.name} placeholder="Instance name"
|
|
onInput={(e) => { setWorking({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} />
|
|
<span class="cfg-badge">v{working.version}</span>
|
|
{dirty && <span class="cl-dirty">unsaved</span>}
|
|
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
|
|
<button class="panel-btn" disabled={busy || dirty} title={dirty ? 'Save before applying' : 'Write values to signals'} onClick={apply}>Apply</button>
|
|
</div>
|
|
<div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
|
|
|
|
{!boundSet && <div class="cl-error">Bound set could not be loaded.</div>}
|
|
{boundSet && (
|
|
<div class="cfg-values">
|
|
{boundSet.parameters.length === 0 && <div class="hint">The bound set has no parameters.</div>}
|
|
{boundSet.parameters.map(p => (
|
|
<ValueRow key={p.key} param={p} value={working.values[p.key]} onChange={(v) => setValue(p.key, v)} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{applyResult && <ApplyResultView result={applyResult} />}
|
|
|
|
<VersionPanel kind="instances" id={working.id} onReload={reload}
|
|
onForked={(id) => { reload(); select(id); }}
|
|
onPromoted={() => select(working.id)}
|
|
onCompare={showDiff} onError={setError} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showNew && <NewInstanceDialog sets={sets} busy={busy} onCancel={() => setShowNew(false)} onCreate={createInstance} />}
|
|
|
|
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
|
|
<InstanceDiffTable entries={diff.entries} />
|
|
</DiffModal>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ValueRow({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) {
|
|
const label = param.label || param.key;
|
|
const isNum = param.type === 'float64' || param.type === 'int64';
|
|
const defText = param.default !== undefined && param.default !== null ? String(param.default) : '';
|
|
return (
|
|
<div class="cfg-value-row">
|
|
<div class="cfg-value-label">
|
|
{param.mandatory && <span class="cfg-req" title="Mandatory">*</span>}
|
|
<span title={`${param.ds}:${param.signal}`}>{label}</span>
|
|
{param.unit && <span class="hint cfg-unit">{param.unit}</span>}
|
|
</div>
|
|
<div class="cfg-value-input">
|
|
{param.type === 'bool' ? (
|
|
<select class="prop-select" value={value === undefined ? '' : String(value)}
|
|
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v === 'true'); }}>
|
|
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
|
|
<option value="true">true</option>
|
|
<option value="false">false</option>
|
|
</select>
|
|
) : param.type === 'enum' ? (
|
|
<select class="prop-select" value={value === undefined ? '' : String(value)}
|
|
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v); }}>
|
|
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
|
|
{(param.enumValues ?? []).map(ev => <option key={ev} value={ev}>{ev}</option>)}
|
|
</select>
|
|
) : (
|
|
<input class="prop-input" type={isNum ? 'number' : 'text'}
|
|
value={value === undefined ? '' : value}
|
|
placeholder={defText ? `default: ${defText}` : '(unset)'}
|
|
onInput={(e) => { const raw = (e.target as HTMLInputElement).value; onChange(raw === '' ? undefined : coerceVal(raw, param.type)); }} />
|
|
)}
|
|
</div>
|
|
<span class="cfg-value-target hint" title="Target signal">{param.ds}:{param.signal}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NewInstanceDialog({ sets, busy, onCancel, onCreate }: {
|
|
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
|
|
}) {
|
|
const [name, setName] = useState('New instance');
|
|
const [setId, setSetId] = useState(sets[0]?.id ?? '');
|
|
return (
|
|
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
<div class="cl-confirm">
|
|
<div class="cl-confirm-title">New config instance</div>
|
|
<div class="cfg-field">
|
|
<label>Name</label>
|
|
<input class="prop-input" value={name} autoFocus onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>Config set</label>
|
|
<select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
|
|
{sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
|
|
</select>
|
|
</div>
|
|
<div class="cl-confirm-actions">
|
|
<button class="panel-btn" onClick={onCancel}>Cancel</button>
|
|
<button class="panel-btn panel-btn-primary" disabled={busy || !setId || !name.trim()}
|
|
onClick={() => onCreate(name.trim(), setId)}>Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ApplyResultView({ result }: { result: ApplyResult }) {
|
|
return (
|
|
<div class="cfg-apply-result">
|
|
<div class="cfg-apply-head">
|
|
Applied <b>{result.applied}</b>, failed <b class={result.failed ? 'cfg-fail' : ''}>{result.failed}</b>, skipped <b>{result.skipped}</b>
|
|
</div>
|
|
<table class="cfg-apply-table">
|
|
<thead><tr><th>Parameter</th><th>Target</th><th>Value</th><th>Result</th></tr></thead>
|
|
<tbody>
|
|
{result.entries.map(e => (
|
|
<tr key={e.key}>
|
|
<td>{e.key}</td>
|
|
<td class="hint">{e.ds}:{e.signal}</td>
|
|
<td>{e.value !== undefined ? String(e.value) : '—'}</td>
|
|
<td>
|
|
{e.skipped ? <span class="hint">skipped</span>
|
|
: e.ok ? <span class="cfg-ok">ok</span>
|
|
: <span class="cfg-fail" title={e.error}>failed</span>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Versions panel (shared) ───────────────────────────────────────────────────
|
|
|
|
function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: {
|
|
kind: 'sets' | 'instances';
|
|
id: string;
|
|
onReload: () => void;
|
|
onForked: (newId: string) => void;
|
|
onPromoted: () => void;
|
|
onCompare: (av: number, bv: number) => void;
|
|
onError: (m: string) => void;
|
|
}) {
|
|
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [a, setA] = useState<number | null>(null);
|
|
const [b, setB] = useState<number | null>(null);
|
|
|
|
const base = `/api/v1/config/${kind}`;
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const v = (await apiJSON<VersionMeta[]>(`${base}/${encodeURIComponent(id)}/versions`)) ?? [];
|
|
setVersions(v);
|
|
if (v.length >= 2) { setA(v[1].version); setB(v[0].version); }
|
|
else if (v.length === 1) { setA(v[0].version); setB(v[0].version); }
|
|
} catch (err) { onError(`Versions failed: ${msg(err)}`); }
|
|
}, [base, id, onError]);
|
|
|
|
useEffect(() => { if (open) load(); }, [open, load]);
|
|
// Reset when switching object.
|
|
useEffect(() => { setOpen(false); setVersions([]); }, [id]);
|
|
|
|
async function fork(version: number) {
|
|
try {
|
|
const obj = await apiJSON<{ id: string }>(`${base}/${encodeURIComponent(id)}/versions/${version}/fork`, { method: 'POST' });
|
|
onForked(obj!.id);
|
|
} catch (err) { onError(`Fork failed: ${msg(err)}`); }
|
|
}
|
|
async function promote(version: number) {
|
|
if (!confirm(`Promote v${version} to a new current version?`)) return;
|
|
try {
|
|
await apiJSON(`${base}/${encodeURIComponent(id)}/versions/${version}/promote`, { method: 'POST' });
|
|
onPromoted();
|
|
} catch (err) { onError(`Promote failed: ${msg(err)}`); }
|
|
}
|
|
|
|
return (
|
|
<div class="cfg-versions">
|
|
<button class="cfg-versions-toggle" onClick={() => setOpen(o => !o)}>
|
|
{open ? '▾' : '▸'} Version history
|
|
</button>
|
|
{open && (
|
|
<div class="cfg-versions-body">
|
|
{versions.length === 0 && <div class="hint">No versions.</div>}
|
|
{versions.map(v => (
|
|
<div key={v.version} class={`cfg-version-row${v.current ? ' cfg-version-current' : ''}`}>
|
|
<span class="cfg-version-num">v{v.version}{v.current && <span class="cfg-badge">current</span>}</span>
|
|
<span class="cfg-version-tag hint" title={v.tag}>{v.tag || ''}</span>
|
|
<span class="cfg-version-time hint">{fmtTime(v.savedAt)}</span>
|
|
<button class="cl-mini-btn" title="Fork into a new object" onClick={() => fork(v.version)}>fork</button>
|
|
{!v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
|
|
</div>
|
|
))}
|
|
{versions.length >= 2 && (
|
|
<div class="cfg-compare">
|
|
<span class="hint">Compare</span>
|
|
<select class="prop-select" value={String(a ?? '')} onChange={(e) => setA(Number((e.target as HTMLSelectElement).value))}>
|
|
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
|
</select>
|
|
<span>→</span>
|
|
<select class="prop-select" value={String(b ?? '')} onChange={(e) => setB(Number((e.target as HTMLSelectElement).value))}>
|
|
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
|
</select>
|
|
<button class="panel-btn" disabled={a === null || b === null} onClick={() => onCompare(a!, b!)}>Diff</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Diff views ────────────────────────────────────────────────────────────────
|
|
|
|
function DiffModal({ title, onClose, children }: { title: string; onClose: () => void; children: any }) {
|
|
return (
|
|
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
<div class="cl-confirm cfg-diff-modal">
|
|
<div class="cfg-diff-head">
|
|
<span class="cl-confirm-title">Diff — {title}</span>
|
|
<button class="panel-btn" onClick={onClose}>Close</button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function paramSummary(p?: Parameter): string {
|
|
if (!p) return '—';
|
|
const parts = [`${p.ds}:${p.signal}`, p.type];
|
|
if (p.default !== undefined) parts.push(`default=${p.default}`);
|
|
if (p.mandatory) parts.push('mandatory');
|
|
if (p.min !== undefined) parts.push(`min=${p.min}`);
|
|
if (p.max !== undefined) parts.push(`max=${p.max}`);
|
|
return parts.join(', ');
|
|
}
|
|
|
|
function SetDiffTable({ entries }: { entries: SetDiffEntry[] }) {
|
|
if (entries.length === 0) return <div class="hint">No parameters.</div>;
|
|
return (
|
|
<table class="cfg-diff-table">
|
|
<thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
|
|
<tbody>
|
|
{entries.map(e => (
|
|
<tr key={e.key} class={`cfg-diff-${e.status}`}>
|
|
<td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
|
|
<td>{paramSummary(e.left)}</td>
|
|
<td>{paramSummary(e.right)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) {
|
|
if (entries.length === 0) return <div class="hint">No values.</div>;
|
|
return (
|
|
<table class="cfg-diff-table">
|
|
<thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
|
|
<tbody>
|
|
{entries.map(e => (
|
|
<tr key={e.key} class={`cfg-diff-${e.status}`}>
|
|
<td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
|
|
<td>{e.left !== undefined ? String(e.left) : '—'}</td>
|
|
<td>{e.right !== undefined ? String(e.right) : '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
// ── small helpers ─────────────────────────────────────────────────────────────
|
|
|
|
function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); }
|
|
function jsonPost(body: any): RequestInit { return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }
|
|
function jsonPut(body: any): RequestInit { return { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }
|
|
function numOrUndef(v: string): number | undefined { if (v.trim() === '') return undefined; const n = Number(v); return isNaN(n) ? undefined : n; }
|
|
function coerceVal(v: string, type: ParamType): any {
|
|
if (type === 'float64' || type === 'int64') { const n = Number(v); return isNaN(n) ? v : n; }
|
|
return v;
|
|
}
|