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%
7.8 KB · 122 lines typescript
Raw Blame History
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