SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
10.0 KB · 206 lines typescript
Raw Blame History
1'use server';2import { revalidatePath } from 'next/cache';3import { redirect } from 'next/navigation';4import { ApiError } from '@/lib/api';5import { AdminAuthError, adminApi, claimHistory } from './admin-api';6import { clearAdminToken, setAdminToken } from './session';78/**9 * Server actions for the admin console. Each mutating action calls the API with the cookie token, revalidates the10 * section and redirects back with a `?notice=` message (no client state, works without JS).11 */1213function describe(e: unknown): string {14  if (e instanceof AdminAuthError) return 'Session expired — sign in again.';15  if (e instanceof ApiError) return e.detail ? `API ${e.status}: ${e.detail}` : e.message;16  return (e as Error)?.message ?? 'Unknown error';17}1819function back(path: string, notice: string, ok = true): never {20  const p = new URLSearchParams();21  p.set('notice', notice);22  if (!ok) p.set('level', 'error');23  redirect(`${path}${path.includes('?') ? '&' : '?'}${p.toString()}`);24}2526async function run(path: string, okMsg: (r: unknown) => string, fn: () => Promise<unknown>): Promise<never> {27  let msg: string;28  let ok = true;29  try {30    const r = await fn();31    msg = okMsg(r);32  } catch (e) {33    if (e instanceof AdminAuthError) redirect('/admin?expired=1');34    msg = describe(e);35    ok = false;36  }37  revalidatePath(path.split('?')[0] ?? path);38  back(path, msg, ok);39}4041// ------------------------------------------------------------------------------------------------------------ auth42export async function loginAction(_prev: { error: string | null } | undefined, formData: FormData): Promise<{ error: string | null }> {43  const token = String(formData.get('token') ?? '').trim();44  if (!token) return { error: 'Enter the admin token.' };45  try {46    await adminApi.overview(token);47  } catch (e) {48    if (e instanceof AdminAuthError) return { error: 'Token rejected by the API.' };49    return { error: `API unreachable: ${describe(e)}` };50  }51  await setAdminToken(token);52  redirect('/admin/overview');53}5455export async function logoutAction(): Promise<void> {56  await clearAdminToken();57  redirect('/admin?signed_out=1');58}5960// ------------------------------------------------------------------------------------------------------------ connectors61export async function runConnectorAction(formData: FormData): Promise<void> {62  const name = String(formData.get('name') ?? '');63  const ret = String(formData.get('return') ?? '/admin/connectors');64  await run(ret, () => `Run queued for ${name} — the scheduler picks it up on its next tick.`, () => adminApi.runConnector(name, true));65}6667export async function toggleConnectorAction(formData: FormData): Promise<void> {68  const name = String(formData.get('name') ?? '');69  const enabled = String(formData.get('enabled') ?? '') === 'true';70  const ret = String(formData.get('return') ?? '/admin/connectors');71  await run(ret, () => `${name} ${enabled ? 'enabled' : 'disabled'}.`, () => adminApi.patchConnector(name, { enabled }));72}7374// ------------------------------------------------------------------------------------------------------------ jobs75export async function retryJobAction(formData: FormData): Promise<void> {76  const id = String(formData.get('id') ?? '');77  const ret = String(formData.get('return') ?? '/admin/jobs');78  await run(ret, () => `Job ${id} re-queued.`, () => adminApi.retryJob(id));79}8081export async function requeueDeadAction(formData: FormData): Promise<void> {82  const ret = String(formData.get('return') ?? '/admin/jobs');83  await run(84    ret,85    (r) => {86      const n = (r as { requeued?: number; count?: number } | null)?.requeued ?? (r as { count?: number } | null)?.count;87      return n === undefined ? 'Dead jobs re-queued.' : `${n} dead job${n === 1 ? '' : 's'} re-queued.`;88    },89    () => adminApi.requeueDead(),90  );91}9293// ------------------------------------------------------------------------------------------------------------ review94export async function reviewAction(formData: FormData): Promise<void> {95  const id = String(formData.get('id') ?? '');96  const action = String(formData.get('action') ?? '') as 'approve' | 'reject';97  const ret = String(formData.get('return') ?? '/admin/review');98  if (action !== 'approve' && action !== 'reject') back(ret, 'Unknown action.', false);99  await run(ret, (r) => `Review ${id} ${(r as { status?: string })?.status ?? action}.`, () => adminApi.reviewAction(id, action));100}101102/**103 * Conflict resolution: the payload only carries the two values and their source URLs, not claim ids. We look the104 * claim up in the entity's claim history (same property, same value, same source URL — newest first) and approve105 * with `resolution.keep_claim_id` so the API promotes it and supersedes the other.106 */107export async function keepConflictSideAction(formData: FormData): Promise<void> {108  const id = String(formData.get('id') ?? '');109  const slug = String(formData.get('slug') ?? '');110  const property = String(formData.get('property') ?? '');111  const valueJson = String(formData.get('value') ?? 'null');112  const sourceUrl = String(formData.get('source_url') ?? '');113  const ret = String(formData.get('return') ?? '/admin/review');114  await run(115    ret,116    () => `Conflict ${id} resolved — kept ${property} = ${valueJson}.`,117    async () => {118      let value: unknown = null;119      try {120        value = JSON.parse(valueJson);121      } catch {122        value = valueJson;123      }124      const hist = await claimHistory(slug, property);125      const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);126      const match = hist.items.find((c) => same(c.value, value) && (!sourceUrl || c.source_url === sourceUrl)) ?? hist.items.find((c) => same(c.value, value));127      if (!match) throw new Error(`No claim with ${property} = ${valueJson} found in the history of ${slug}; approve or reject without promotion instead.`);128      return adminApi.reviewAction(id, 'approve', { keep_claim_id: match.id, kept_value: value, kept_source: sourceUrl || null });129    },130  );131}132133// ------------------------------------------------------------------------------------------------------------ duplicates134export async function mergeAction(formData: FormData): Promise<void> {135  const source = String(formData.get('source_id') ?? '');136  const target = String(formData.get('target_id') ?? '');137  const ret = String(formData.get('return') ?? '/admin/entities/duplicates');138  await run(ret, () => `Merged ${source} → ${target}.`, () => adminApi.merge(source, target));139}140141// ------------------------------------------------------------------------------------------------------------ maintenance142export async function flushCacheAction(): Promise<void> {143  await run('/admin/cache', (r) => `Cache flushed (${(r as { flushed?: number })?.flushed ?? '?'} keys).`, () => adminApi.flushCache());144}145export async function recomputeStatsAction(): Promise<void> {146  await run('/admin/cache', () => 'Stats recompute triggered.', () => adminApi.recomputeStats());147}148export async function recomputeQualityAction(): Promise<void> {149  await run('/admin/cache', () => 'Quality recompute triggered.', () => adminApi.recomputeQuality());150}151152// ------------------------------------------------------------------------------------------------------------ D3 workbenches153const DECISIONS = new Set(['merge', 'alias', 'variant_of', 'family_member', 'keep_separate', 'defer']);154/** Entity resolution: persists the decision for the pair (a, b) and applies it (merge / alias / variant_of / family_member). */155export async function resolutionAction(formData: FormData): Promise<void> {156  const a = String(formData.get('a') ?? '');157  const b = String(formData.get('b') ?? '');158  const decision = String(formData.get('decision') ?? '');159  const note = String(formData.get('note') ?? '').trim();160  const ret = String(formData.get('return') ?? '/admin/entity-resolution');161  if (!DECISIONS.has(decision)) back(ret, 'Unknown decision.', false);162  await run(163    ret,164    (r) => {165      const x = r as { applied?: boolean; effect?: unknown } | null;166      return `Decision “${decision.replace('_', ' ')}” recorded for ${a} / ${b}${x?.applied ? ' and applied' : ' (recorded, not applied)'}.`;167    },168    () => adminApi.resolve(a, b, decision as 'merge', note),169  );170}171172export async function anomalyAction(formData: FormData): Promise<void> {173  const id = String(formData.get('id') ?? '');174  const status = String(formData.get('status') ?? '') as 'resolved' | 'ignored' | 'open';175  const note = String(formData.get('note') ?? '').trim();176  const ret = String(formData.get('return') ?? '/admin/anomalies');177  if (!['resolved', 'ignored', 'open'].includes(status)) back(ret, 'Unknown status.', false);178  await run(ret, () => `Anomaly ${id} marked ${status}.`, () => adminApi.anomalyAction(id, status, note));179}180181export async function quarantineAction(formData: FormData): Promise<void> {182  const id = String(formData.get('id') ?? '');183  const action = String(formData.get('action') ?? '') as 'release' | 'discard';184  const note = String(formData.get('note') ?? '').trim();185  const ret = String(formData.get('return') ?? '/admin/quarantine');186  if (action !== 'release' && action !== 'discard') back(ret, 'Unknown action.', false);187  await run(ret, () => `Quarantined run ${id} ${action === 'release' ? 'released' : 'discarded'}.`, () => adminApi.quarantineAction(id, action, note));188}189190/** Rollback of one connector run: retracts its claims, closes its rows, flags its events — deletes nothing. */191export async function rollbackAction(formData: FormData): Promise<void> {192  const runId = String(formData.get('run_id') ?? '');193  const confirm = String(formData.get('confirm') ?? '');194  const ret = String(formData.get('return') ?? '/admin/runs');195  if (confirm !== runId) back(ret, 'Rollback not confirmed (the run id must be repeated).', false);196  await run(197    ret,198    (r) => {199      const x = r as { counts?: Record<string, unknown>; connector?: string | null } | null;200      const counts = x?.counts ? Object.entries(x.counts).map(([k, v]) => `${k} ${String(v)}`).join(' · ') : '';201      return `Run ${runId}${x?.connector ? ` (${x.connector})` : ''} rolled back${counts ? ` — ${counts}` : ''}.`;202    },203    () => adminApi.rollback(runId),204  );205}206