Implemented admin pane + user permission
This commit is contained in:
+111
-6
@@ -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;
|
||||
}) {
|
||||
|
||||
Reference in New Issue
Block a user