HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1import 'server-only';2import { redirect } from 'next/navigation';3import { API_URL, ApiError, type Query } from '@/lib/api';4import { getAdminToken } from './session';5import type { AdminConnectorsPayload, AdminDocument, AdminError, AdminJobsPayload, AdminLlmHealth, AdminLlmJobsPayload, AdminOverview, AdminPage, AdminQuality, AdminRun, AdminSnapshot, AnomaliesPayload, AuditPayload, DuplicatesPayload, ExtractionPayload, Infrastructure, QuarantinePayload, ResolutionDecision, ResolutionPayload, ReviewPayload, RollbackResult } from './types';67/**8 * Server-only admin client. Every call carries `x-aia-admin-token` from the httpOnly cookie (or an explicit token9 * during login) and is `no-store`. A 401/403 raises `AdminAuthError`; pages turn it into a redirect to the login form.10 */11const BASE = `${API_URL}/api/v1/admin`;1213export class AdminAuthError extends Error {14 constructor() {15 super('admin token missing or rejected');16 this.name = 'AdminAuthError';17 }18}1920function qs(query?: Query): string {21 if (!query) return '';22 const p = new URLSearchParams();23 for (const [k, v] of Object.entries(query)) {24 if (v === undefined || v === null || v === '') continue;25 p.set(k, String(v));26 }27 const s = p.toString();28 return s ? `?${s}` : '';29}3031export async function adminRequest<T>(path: string, opts: { method?: 'GET' | 'POST' | 'PATCH'; query?: Query; body?: unknown; token?: string } = {}): Promise<T> {32 const token = opts.token ?? (await getAdminToken());33 if (!token) throw new AdminAuthError();34 const url = `${BASE}${path}${qs(opts.query)}`;35 const init: RequestInit = { method: opts.method ?? 'GET', cache: 'no-store', headers: { accept: 'application/json', 'x-aia-admin-token': token } };36 if (opts.body !== undefined) {37 init.body = JSON.stringify(opts.body);38 (init.headers as Record<string, string>)['content-type'] = 'application/json';39 }40 let res: Response;41 try {42 res = await fetch(url, init);43 } catch (e) {44 throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);45 }46 if (res.status === 401 || res.status === 403) throw new AdminAuthError();47 if (!res.ok) {48 let detail: string | null = null;49 try {50 const body = (await res.json()) as { detail?: unknown };51 detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null;52 } catch {53 /* non-JSON error body */54 }55 throw new ApiError(res.status, path, detail);56 }57 return (await res.json()) as T;58}5960/** Ensure a session cookie exists; otherwise go to the login form. */61export async function requireAdmin(): Promise<string> {62 const token = await getAdminToken();63 if (!token) redirect('/admin');64 return token;65}6667export type Loaded<T> = { ok: true; data: T } | { ok: false; error: string };6869/** Resolve an admin fetch for a page panel: auth failure → login redirect; other failures → `{ ok: false, error }`. */70export async function load<T>(p: Promise<T>): Promise<Loaded<T>> {71 try {72 return { ok: true, data: await p };73 } catch (e) {74 if (e instanceof AdminAuthError) redirect('/admin?expired=1');75 const msg = e instanceof ApiError ? (e.detail ? `${e.status}: ${e.detail}` : e.message) : (e as Error).message;76 return { ok: false, error: msg };77 }78}7980export const adminApi = {81 overview: (token?: string) => adminRequest<AdminOverview>('/overview', { token }),82 connectors: () => adminRequest<AdminConnectorsPayload>('/connectors'),83 runConnector: (name: string, force = true) => adminRequest<{ queued: boolean }>(`/connectors/${encodeURIComponent(name)}/run`, { method: 'POST', body: { force } }),84 patchConnector: (name: string, patch: { enabled?: boolean; interval_seconds?: number; priority?: number }) => adminRequest<unknown>(`/connectors/${encodeURIComponent(name)}`, { method: 'PATCH', body: patch }),85 runs: (query: Query) => adminRequest<AdminPage<AdminRun>>('/runs', { query }),86 errors: (query: Query) => adminRequest<AdminPage<AdminError>>('/errors', { query }),87 documents: (query: Query) => adminRequest<AdminPage<AdminDocument>>('/documents', { query }),88 document: (id: string) => adminRequest<AdminDocument>(`/documents/${encodeURIComponent(id)}`),89 snapshot: (id: string) => adminRequest<AdminSnapshot>(`/snapshots/${encodeURIComponent(id)}`),90 jobs: (query: Query) => adminRequest<AdminJobsPayload>('/jobs', { query }),91 retryJob: (id: string) => adminRequest<unknown>(`/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST', body: {} }),92 requeueDead: () => adminRequest<{ requeued?: number } & Record<string, unknown>>('/jobs/requeue-dead', { method: 'POST', body: {} }),93 llmJobs: (query: Query) => adminRequest<AdminLlmJobsPayload>('/llm-jobs', { query }),94 llmHealth: () => adminRequest<AdminLlmHealth>('/llm/health'),95 review: (query: Query) => adminRequest<ReviewPayload>('/review', { query }),96 reviewAction: (id: string, action: 'approve' | 'reject' | 'edit', resolution?: Record<string, unknown>) => adminRequest<{ ok: boolean; status: string; effect?: unknown }>(`/review/${encodeURIComponent(id)}`, { method: 'POST', body: { action, resolution } }),97 duplicates: (query: Query) => adminRequest<DuplicatesPayload>('/entities/duplicates', { query }),98 merge: (source_id: string, target_id: string) => adminRequest<unknown>('/entities/merge', { method: 'POST', body: { source_id, target_id } }),99 infrastructure: () => adminRequest<Infrastructure>('/infrastructure'),100 flushCache: () => adminRequest<{ flushed: number }>('/cache/flush', { method: 'POST', body: {} }),101 recomputeStats: () => adminRequest<unknown>('/stats/recompute', { method: 'POST', body: {} }),102 recomputeQuality: () => adminRequest<unknown>('/quality/recompute', { method: 'POST', body: {} }),103 // ---- D3 workbenches (1.1)104 quality: () => adminRequest<AdminQuality>('/quality'),105 entityResolution: (query: Query) => adminRequest<ResolutionPayload>('/entity-resolution', { query }),106 resolve: (a: string, b: string, decision: ResolutionDecision, note?: string) => adminRequest<{ ok: boolean; a: string; b: string; decision: string; applied: boolean; effect?: unknown }>(`/entity-resolution/${encodeURIComponent(a)}/${encodeURIComponent(b)}`, { method: 'POST', body: { decision, note: note || undefined } }),107 anomalies: (query: Query) => adminRequest<AnomaliesPayload>('/anomalies', { query }),108 anomalyAction: (id: string, status: 'resolved' | 'ignored' | 'open', note?: string) => adminRequest<unknown>(`/anomalies/${encodeURIComponent(id)}`, { method: 'POST', body: { status, note: note || undefined } }),109 extraction: (snapshotId: string, textLimit = 20000) => adminRequest<ExtractionPayload>(`/extractions/${encodeURIComponent(snapshotId)}`, { query: { text_limit: textLimit } }),110 quarantine: (query: Query) => adminRequest<QuarantinePayload>('/quarantine', { query }),111 quarantineAction: (id: string, action: 'release' | 'discard', note?: string) => adminRequest<unknown>(`/quarantine/${encodeURIComponent(id)}`, { method: 'POST', body: { action, note: note || undefined } }),112 audit: (query: Query) => adminRequest<AuditPayload>('/audit', { query }),113 rollback: (runId: string) => adminRequest<RollbackResult>(`/runs/${encodeURIComponent(runId)}/rollback`, { method: 'POST', body: {} }),114};115116/** Public (non-admin) history lookup used to map a conflict payload to concrete claim ids. */117export async function claimHistory(slug: string, property: string): Promise<{ items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] }> {118 const res = await fetch(`${API_URL}/api/v1/entities/${encodeURIComponent(slug)}/history?property=${encodeURIComponent(property)}`, { cache: 'no-store', headers: { accept: 'application/json' } });119 if (!res.ok) throw new ApiError(res.status, '/history', null);120 return (await res.json()) as { items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] };121}122