SPB Git

spb/ai-atlas

Public
6.2 KB · 111 lines typescript
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, AdminRun, AdminSnapshot, DuplicatesPayload, Infrastructure, ReviewPayload } 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};104105/** Public (non-admin) history lookup used to map a conflict payload to concrete claim ids. */106export async function claimHistory(slug: string, property: string): Promise<{ items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] }> {107  const res = await fetch(`${API_URL}/api/v1/entities/${encodeURIComponent(slug)}/history?property=${encodeURIComponent(property)}`, { cache: 'no-store', headers: { accept: 'application/json' } });108  if (!res.ok) throw new ApiError(res.status, '/history', null);109  return (await res.json()) as { items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] };110}111
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme