SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
5.5 KB · 108 lines tsx
Raw Blame History
1'use client';23import { useState } from 'react';4import { Bar } from '@/components/ui/primitives';5import { adminFetch } from '@/lib/admin-fetch';6import { fmt } from '@/lib/format';7import { COMPONENT_LABEL } from '@/lib/pressure';8import type { AdminConfig, ComponentId } from '@/lib/types';9import { AdminPage, ErrorNote, Panel, Toast, btnCls, btnPrimary, inputCls, useAdmin } from './shared';1011export function Config() {12  const { data, err, reload } = useAdmin<AdminConfig>('/config');13  // Edits are kept separately and overlay the fetched config (no effect-driven copy).14  const [edits, setEdits] = useState<AdminConfig | null>(null);15  const cfg: AdminConfig | null = edits ?? data;16  const setCfg = (c: AdminConfig) => setEdits(c);17  const [msg, setMsg] = useState<string | null>(null);18  const [saveErr, setSaveErr] = useState<string | null>(null);19  const [busy, setBusy] = useState(false);2021  if (!cfg) return <AdminPage title="Scoring config">{err ? <ErrorNote err={err} /> : <p className="text-[12px] text-ink-3">Loading…</p>}</AdminPage>;2223  const sum = Object.values(cfg.pressure_weights).reduce((a, b) => a + Number(b || 0), 0);24  const valid = Math.abs(sum - 1) <= 0.001;25  const levelsOk = cfg.levels.every((l, i) => i === 0 || l.max > cfg.levels[i - 1]!.max) && cfg.levels.at(-1)?.max === 100;26  const dirty = JSON.stringify(cfg) !== JSON.stringify(data);2728  const save = async () => {29    setBusy(true);30    setSaveErr(null);31    try {32      await adminFetch('/config', { method: 'PUT', body: cfg });33      setMsg('Configuration stored — applied on the next engine cycle.');34      setEdits(null);35      reload();36    } catch (e) {37      setSaveErr(String(e));38    } finally {39      setBusy(false);40      setTimeout(() => setMsg(null), 4000);41    }42  };4344  const setEngine = (k: string, v: string) => setCfg({ ...cfg, engine: { ...cfg.engine, [k]: v === '' ? '' : Number.isNaN(Number(v)) ? v : Number(v) } });4546  return (47    <AdminPage48      title={`Scoring config · v${cfg.version}`}49      desc="Weights, levels and engine parameters (pressure.yaml). Stored in Postgres and hot-reloaded by the engine. Weights must sum to 1 ± 0.001."50      right={51        <div className="flex gap-2">52          <button type="button" className={btnCls} disabled={!dirty} onClick={() => setEdits(null)}>53            reset54          </button>55          <button type="button" className={btnPrimary} disabled={!valid || !levelsOk || !dirty || busy} onClick={() => void save()}>56            PUT config57          </button>58        </div>59      }60    >61      <ErrorNote err={err ?? saveErr} />62      <Toast msg={msg} />63      <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-3">64        <Panel title="Component weights" right={<span className="num" style={{ color: valid ? 'var(--ok)' : 'var(--bad)' }}>Σ {fmt(sum, 3)} {valid ? '✓' : '≠ 1.000'}</span>}>65          <ul className="space-y-2">66            {Object.entries(cfg.pressure_weights).map(([id, w]) => (67              <li key={id} className="grid grid-cols-[110px_72px_minmax(0,1fr)] items-center gap-3 text-[12.5px]">68                <span className="text-ink">{COMPONENT_LABEL[id as ComponentId] ?? id}</span>69                <input type="number" step="0.01" min={0} max={1} value={w} onChange={(e) => setCfg({ ...cfg, pressure_weights: { ...cfg.pressure_weights, [id]: Number(e.target.value) } })} className={`${inputCls} num w-full`} aria-label={`${id} weight`} />70                <Bar value={Number(w) * 100} max={40} color="var(--accent)" />71              </li>72            ))}73          </ul>74        </Panel>75        <Panel title="Levels" right={<span style={{ color: levelsOk ? 'var(--ok)' : 'var(--bad)' }}>{levelsOk ? 'monotonic, ends at 100' : 'must increase and end at 100'}</span>}>76          <ul className="space-y-2">77            {cfg.levels.map((l, i) => (78              <li key={l.id} className="grid grid-cols-[80px_72px_minmax(0,1fr)] items-center gap-3 text-[12.5px]">79                <span className="text-ink" style={{ color: `var(--p-${l.id})` }}>80                  {l.id}81                </span>82                <input type="number" min={0} max={100} value={l.max} onChange={(e) => setCfg({ ...cfg, levels: cfg.levels.map((x, j) => (j === i ? { ...x, max: Number(e.target.value) } : x)) })} className={`${inputCls} num w-full`} aria-label={`${l.id} max`} />83                <input value={l.label} onChange={(e) => setCfg({ ...cfg, levels: cfg.levels.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)) })} className={`${inputCls} w-full`} aria-label={`${l.id} label`} />84              </li>85            ))}86          </ul>87        </Panel>88        <Panel title="Engine parameters">89          <ul className="space-y-2">90            {Object.entries(cfg.engine).map(([k, v]) => (91              <li key={k} className="grid grid-cols-[minmax(0,1fr)_110px] items-center gap-3 text-[12px]">92                <span className="num truncate text-ink-2" title={k}>93                  {k}94                </span>95                <input value={String(v)} onChange={(e) => setEngine(k, e.target.value)} className={`${inputCls} num w-full`} aria-label={k} />96              </li>97            ))}98          </ul>99        </Panel>100      </div>101      <details className="mt-3">102        <summary className="cursor-pointer text-[12px] text-ink-2">Full JSON (events, fronts, scheduler, component signal recipes)</summary>103        <pre className="num mt-2 max-h-[420px] overflow-auto rounded-[4px] border border-line bg-panel p-3 text-[11px] text-ink-2">{JSON.stringify(cfg, null, 2)}</pre>104      </details>105    </AdminPage>106  );107}108