Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+111 -6
View File
@@ -46,7 +46,7 @@ interface ConfigInstance {
values: Record<string, any>;
}
interface Meta { id: string; name: string; version: number; setId?: string; }
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; }
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
// logic bound to a set, run when instances of that set are saved.
@@ -55,6 +55,7 @@ interface ConfigRule {
name: string;
setId: string;
description?: string;
enabled?: boolean;
version: number;
tag?: string;
owner?: string;
@@ -65,6 +66,14 @@ interface ConfigRule {
interface RuleViolation { rule?: string; path?: string; message: string; }
interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record<string, any>; compileError?: string; }
// Snapshot / preview payloads mirror the backend /config/rules/preview response.
interface SnapshotEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; error?: string; }
interface SnapshotResult { setId: string; entries: SnapshotEntry[]; captured: number; failed: number; }
interface RulePreview { snapshot: SnapshotResult; result: RuleResult; }
// A rule with no explicit enabled flag (legacy) is treated as enabled.
const ruleEnabled = (r: { enabled?: boolean }) => r.enabled !== false;
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; }
@@ -795,6 +804,7 @@ function ParamEditor({ param, allOptions, onOpenSignals, signalMeta, onChange, o
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
const [instances, setInstances] = useState<Meta[]>([]);
const [sets, setSets] = useState<Meta[]>([]);
const [setFilter, setSetFilter] = useState<string>('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const hist = useUndo<ConfigInstance>();
const working = hist.present;
@@ -974,6 +984,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
}
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
const visibleInstances = instances.filter(s => !setFilter || s.setId === setFilter);
return (
<div class="cl-body">
@@ -983,9 +994,19 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}> Snapshot</button>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length > 1 && (
<div class="cl-list-filter">
<select class="prop-select" value={setFilter} title="Filter instances by config set"
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
<option value="">All sets</option>
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</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 => (
{sets.length > 0 && instances.length > 0 && visibleInstances.length === 0 && <div class="hint cl-list-empty">No instances in this set.</div>}
{visibleInstances.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>
@@ -1446,6 +1467,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [check, setCheck] = useState<RuleResult | null>(null);
const [setFilter, setSetFilter] = useState<string>('');
const [preview, setPreview] = useState<RulePreview | null>(null);
const [showNew, setShowNew] = useState(false);
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
const [verReload, setVerReload] = useState(0);
@@ -1469,9 +1492,20 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
return apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(setId)}`);
}
async function runPreview() {
if (!working) return;
setBusy(true); setError(null);
try {
const res = await apiJSON<RulePreview>('/api/v1/config/rules/preview',
jsonPost({ setId: working.setId, source: working.source }));
setPreview(res);
} catch (err) { setError(`Preview failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function select(id: string) {
if (dirty && !confirm('Discard unsaved changes?')) return;
setError(null); setCheck(null);
setError(null); setCheck(null); setPreview(null);
try {
const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(id)}`);
const set = await loadSet(rule!.setId);
@@ -1485,7 +1519,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
async function createRule(name: string, setId: string) {
setBusy(true); setError(null);
try {
const body = { name, setId, source: '// CUE rule: constrain or derive parameter values.\n' };
const body = { name, setId, enabled: true, source: '// CUE rule: constrain or derive parameter values.\n' };
const created = await apiJSON<ConfigRule>('/api/v1/config/rules', jsonPost(body));
const set = await loadSet(created!.setId);
await reload();
@@ -1573,6 +1607,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
const completions = boundSet
? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean)
: [];
const visibleRules = rules.filter(s => !setFilter || s.setId === setFilter);
return (
<div class="cl-body">
@@ -1581,11 +1616,22 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<span>Rules</span>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length > 1 && (
<div class="cl-list-filter">
<select class="prop-select" value={setFilter} title="Filter rules by config set"
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
<option value="">All sets</option>
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
{sets.length > 0 && rules.length === 0 && <div class="hint cl-list-empty">No rules yet.</div>}
{rules.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
{sets.length > 0 && rules.length > 0 && visibleRules.length === 0 && <div class="hint cl-list-empty">No rules in this set.</div>}
{visibleRules.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}${ruleEnabled(s) ? '' : ' cfg-rule-disabled'}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
{!ruleEnabled(s) && <span class="hint cfg-rule-off" title="Disabled — not run on save/apply">off</span>}
{s.setId && <span class="hint cfg-rule-set" title="Bound set">{setName(s.setId)}</span>}
<span class="cfg-badge">v{s.version}</span>
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button>
@@ -1605,6 +1651,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}></button>
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}></button>
<button class="panel-btn" disabled={busy} title="Run this rule against a live snapshot of the set's signals (nothing is stored)" onClick={runPreview}> Preview</button>
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
</div>
@@ -1621,6 +1668,14 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<input class="prop-input" value={working.description ?? ''} placeholder="(optional)"
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field cfg-field-enabled">
<label>Enabled</label>
<label class="cfg-rule-enable" title="When enabled, this rule runs every time an instance of the bound set is saved or applied">
<input type="checkbox" checked={ruleEnabled(working)}
onChange={(e) => patch({ enabled: (e.target as HTMLInputElement).checked })} />
<span>{ruleEnabled(working) ? 'Runs on save/apply' : 'Disabled (not run)'}</span>
</label>
</div>
</div>
<div class="cfg-section-head"><span>CUE source</span></div>
@@ -1635,6 +1690,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<RuleCheckView result={check} />
{preview && <RulePreviewView preview={preview} onClose={() => setPreview(null)} />}
<VersionPanel kind="rules" id={working.id}
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
onView={viewVersion}
@@ -1697,6 +1754,54 @@ function RuleCheckView({ result }: { result: RuleResult | null }) {
);
}
// RulePreviewView shows the outcome of running a rule against a live snapshot of
// the bound set's signals: the captured input values on the left and the
// processed output (input merged with rule-derived values) on the right, plus
// any compile error / violations. Nothing is persisted.
function RulePreviewView({ preview, onClose }: { preview: RulePreview; onClose: () => void }) {
const input: Record<string, any> = {};
for (const e of preview.snapshot.entries) if (e.ok) input[e.key] = e.value;
const output = { ...input, ...(preview.result.transformed ?? {}) };
const failed = preview.snapshot.entries.filter(e => !e.ok);
return (
<div class="cfg-rule-preview">
<div class="cfg-rule-preview-head">
<b>Live preview</b>
<span class="hint">{preview.snapshot.captured} captured · {preview.snapshot.failed} failed</span>
<button class="cl-mini-btn" title="Close preview" onClick={onClose}></button>
</div>
{preview.result.compileError && (
<div class="cfg-rule-check cfg-rule-check-err"><b>Compile error:</b> {preview.result.compileError}</div>
)}
{!preview.result.compileError && !preview.result.ok && (
<div class="cfg-rule-check cfg-rule-check-err">
<b>{(preview.result.violations ?? []).length} violation(s):</b>
<ul class="cfg-rule-violations">
{(preview.result.violations ?? []).map((v, i) => (
<li key={i}>{v.path ? <code>{v.path}</code> : null} {v.message}</li>
))}
</ul>
</div>
)}
{failed.length > 0 && (
<div class="hint cfg-rule-preview-failed">
Could not read: {failed.map(e => `${e.key} (${e.error})`).join(', ')}
</div>
)}
<div class="cfg-rule-preview-cols">
<div class="cfg-rule-preview-col">
<div class="hint">Snapshot input</div>
<pre class="cfg-json">{JSON.stringify(input, null, 2)}</pre>
</div>
<div class="cfg-rule-preview-col">
<div class="hint">Processed output{preview.result.ok ? '' : ' (would be rejected)'}</div>
<pre class="cfg-json">{JSON.stringify(output, null, 2)}</pre>
</div>
</div>
</div>
);
}
function NewRuleDialog({ sets, busy, onCancel, onCreate }: {
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
}) {