SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
18.3 KB · 528 lines typescript
Raw Blame History
1/**2 * Admin console plumbing.3 *4 * Server side (route handlers / server actions / layouts): cookie name + options, API base, token verification.5 * Client side: `adminFetch()` — every browser call goes to the same-origin proxy `/admin/api/<path>` which adds the6 * admin token from the httpOnly cookie. The token never reaches the browser bundle.7 *8 * Path conventions for the proxy: `connectors`, `documents/<id>/raw`… map to `/api/admin/<path>`;9 * a `v1/` prefix maps to the public API (`v1/events` → `/api/v1/events`).10 */11import type { ClaimDTO, ConnectorHealthDTO, DetectedChange, EventDTO, FacilityDetail, FacilitySummary, ProvenanceDTO, QualityFlagDTO, QualityOverview, SourceKind } from "@dci/core";1213export const ADMIN_COOKIE = "dci_admin";14export const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 12; // 12 h1516/** Base URL of the API as seen from the Next server (never exposed to the client). */17export function adminApiBase(): string {18  const base = process.env.API_URL_INTERNAL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://127.0.0.1:8311";19  return base.replace(/\/$/, "");20}2122/** Server-only: check a token against the API (GET /api/admin/ops). Never throws. */23export async function verifyAdminToken(token: string, timeoutMs = 8000): Promise<{ ok: boolean; status: number; error?: string }> {24  if (!token || token.length > 512) return { ok: false, status: 400, error: "empty token" };25  const ctrl = new AbortController();26  const timer = setTimeout(() => ctrl.abort(), timeoutMs);27  try {28    const res = await fetch(`${adminApiBase()}/api/admin/ops`, { headers: { "x-dci-admin-token": token, accept: "application/json" }, cache: "no-store", signal: ctrl.signal });29    if (res.ok) return { ok: true, status: res.status };30    if (res.status === 401) return { ok: false, status: 401, error: "Invalid token" };31    if (res.status === 503) return { ok: false, status: 503, error: "Admin API disabled (DCI_ADMIN_TOKEN not configured on the API)" };32    return { ok: false, status: res.status, error: `API responded HTTP ${res.status}` };33  } catch (e) {34    return { ok: false, status: 0, error: e instanceof Error && e.name === "AbortError" ? "API timeout" : "API unreachable" };35  } finally {36    clearTimeout(timer);37  }38}3940/* ------------------------------------------------------------------ client fetch */4142export type AdminResult<T> =43  | { ok: true; data: T; meta: Record<string, unknown> | undefined; status: number }44  | { ok: false; data: null; meta?: undefined; error: string; details?: unknown; status: number };4546export type AdminQuery = Record<string, string | number | boolean | undefined | null>;4748export function adminQuery(q?: AdminQuery): string {49  if (!q) return "";50  const sp = new URLSearchParams();51  for (const [k, v] of Object.entries(q)) {52    if (v === undefined || v === null || v === "" || v === false) continue;53    sp.set(k, v === true ? "1" : String(v));54  }55  const s = sp.toString();56  return s ? `?${s}` : "";57}5859export function adminProxyUrl(path: string, q?: AdminQuery): string {60  return `/admin/api/${path.replace(/^\/+/, "")}${adminQuery(q)}`;61}6263interface AdminFetchInit {64  method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";65  body?: unknown;66  query?: AdminQuery;67  signal?: AbortSignal;68  timeoutMs?: number;69  /** when true, a 401 redirects the browser to the login page (default true) */70  redirectOn401?: boolean;71}7273let redirecting = false;7475/** Browser-side JSON call through the admin proxy. NEVER throws. */76export async function adminFetch<T>(path: string, init: AdminFetchInit = {}): Promise<AdminResult<T>> {77  const url = adminProxyUrl(path, init.query);78  const ctrl = new AbortController();79  const timer = setTimeout(() => ctrl.abort(new Error("timeout")), init.timeoutMs ?? 60_000);80  if (init.signal) init.signal.addEventListener("abort", () => ctrl.abort(init.signal?.reason), { once: true });81  try {82    const res = await fetch(url, {83      method: init.method ?? "GET",84      headers: { accept: "application/json", ...(init.body !== undefined ? { "content-type": "application/json" } : {}) },85      body: init.body !== undefined ? JSON.stringify(init.body) : undefined,86      signal: ctrl.signal,87      credentials: "same-origin",88      cache: "no-store",89    });90    if (res.status === 401 && init.redirectOn401 !== false && typeof window !== "undefined" && !redirecting) {91      redirecting = true;92      const next = encodeURIComponent(window.location.pathname + window.location.search);93      // full navigation on purpose: the login page is server-rendered and client state must be dropped94      window.location.assign(new URL(`/admin/login?next=${next}&reason=expired`, window.location.origin).href);95    }96    const text = await res.text();97    let json: unknown = null;98    try {99      json = text ? JSON.parse(text) : null;100    } catch {101      json = null;102    }103    if (!res.ok) {104      const err = (json as { error?: string; message?: string; details?: unknown } | null) ?? {};105      return { ok: false, data: null, error: err.error ?? err.message ?? (text ? text.slice(0, 200) : `HTTP ${res.status}`), details: err.details, status: res.status };106    }107    if (json && typeof json === "object" && "data" in (json as object)) {108      const env = json as { data: T; meta?: Record<string, unknown> };109      return { ok: true, data: env.data, meta: env.meta, status: res.status };110    }111    return { ok: true, data: json as T, meta: undefined, status: res.status };112  } catch (e) {113    const aborted = e instanceof Error && (e.name === "AbortError" || e.message === "timeout");114    return { ok: false, data: null, error: aborted ? (init.signal?.aborted ? "aborted" : "timeout") : "network error", status: 0 };115  } finally {116    clearTimeout(timer);117  }118}119120/** Raw (non-JSON) proxy call — used for document bodies and YAML text. */121export async function adminFetchText(path: string, query?: AdminQuery, signal?: AbortSignal): Promise<{ ok: boolean; text: string; contentType: string | null; status: number; truncated: boolean; error?: string }> {122  try {123    const res = await fetch(adminProxyUrl(path, query), { credentials: "same-origin", cache: "no-store", signal });124    const text = await res.text();125    if (!res.ok) {126      let err = `HTTP ${res.status}`;127      try {128        err = (JSON.parse(text) as { error?: string }).error ?? err;129      } catch {130        /* keep */131      }132      return { ok: false, text: "", contentType: null, status: res.status, truncated: false, error: err };133    }134    return { ok: true, text, contentType: res.headers.get("content-type"), status: res.status, truncated: res.headers.get("x-truncated") === "1" };135  } catch (e) {136    return { ok: false, text: "", contentType: null, status: 0, truncated: false, error: e instanceof Error ? e.message : "network error" };137  }138}139140/* ------------------------------------------------------------------ admin DTOs (mirror apps/api/src/routes/admin) */141142export type { ConnectorHealthDTO };143144export interface AdminRun {145  id: string;146  connectorId: string;147  task: string;148  startedAt: string | null;149  finishedAt: string | null;150  status: string;151  stats: Record<string, unknown>;152  error: string | null;153  /** true when the connector was quarantined during the run (nothing published) */154  quarantined?: boolean;155  log?: Array<{ t?: string; level?: string; msg?: string; [k: string]: unknown }>;156}157158export interface ConnectorAdminDetail {159  health: ConnectorHealthDTO;160  config: Record<string, unknown>;161  paused: boolean;162  lastError: string | null;163  runs: Array<Omit<AdminRun, "connectorId">>;164  documentsByPageType: Array<{ pageType: string; count: number; changed: number; failed: number; quarantined: number }>;165  errorSamples: Array<{ id: string; url: string; error: string | null; statusCode: number | null; errorCount: number; lastChecked: string | null }>;166  createdAt: string | null;167  updatedAt: string | null;168}169170export interface AdminDocument {171  id: string;172  connectorId: string;173  sourceId: string;174  url: string;175  canonicalUrl: string;176  pageType: string;177  classifier: string | null;178  fetchLevel: number;179  priority: number;180  contentHash: string | null;181  etag: string | null;182  lastModified: string | null;183  statusCode: number | null;184  contentType: string | null;185  sizeBytes: number | null;186  title: string | null;187  storageKey: string | null;188  extractorVersion: string | null;189  extractOk: boolean | null;190  extractCount: number;191  error: string | null;192  errorCount: number;193  firstSeen: string | null;194  lastFetched: string | null;195  lastChanged: string | null;196  lastChecked: string | null;197  nextCheck: string | null;198  changeFrequencyScore: number | null;199  fetchCount: number;200  changeCount: number;201  discoveredFrom: string | null;202  quarantined: boolean;203  entityRefs: Array<{ type: string; id: string }>;204  updatedAt: string | null;205  versionCount?: number;206}207208export interface AdminDocVersion {209  id: string;210  documentId: string;211  contentHash: string;212  fetchedAt: string | null;213  fetchLevel: number;214  statusCode: number | null;215  sizeBytes: number | null;216  storageKey: string | null;217  significance: number;218  diffSummary: { added?: number; removed?: number; addedCount?: number; removedCount?: number; ratio?: number; [k: string]: unknown } | null;219  detectedChanges: DetectedChange[];220}221222export interface AdminDocumentDetail {223  document: AdminDocument;224  versions: AdminDocVersion[];225  provenance: Array<ProvenanceDTO & { entityType: string; entityId: string; isCurrent: boolean }>;226  entities: Array<{ type: string; id: string; slug: string | null; name: string | null }>;227}228229export interface AdminVersionDiff {230  version: AdminDocVersion;231  previous: { id: string; contentHash: string; fetchedAt: string | null } | null;232  diffSummary: AdminDocVersion["diffSummary"];233  detectedChanges: DetectedChange[];234}235236export interface MatchCandidate {237  key?: string;238  url?: string | null;239  name?: string | null;240  city?: string | null;241  address?: string | null;242  status?: string | null;243  countryIso2?: string | null;244  operatorName?: string | null;245  itCapacityMw?: number | null;246  totalPowerMw?: number | null;247  plannedPowerMw?: number | null;248  geo?: { lat?: number; lng?: number; precision?: string } | null;249  lat?: number | null;250  lng?: number | null;251  sourceId?: string | null;252  externalIds?: Record<string, string | number>;253  createdFacilityId?: string | null;254  [k: string]: unknown;255}256257/** One side of a duplicate pair (mirrors `Side` in apps/api/src/routes/admin/matches.ts). Empty id = candidate never persisted. */258export interface MatchSide {259  id: string;260  slug: string;261  name: string;262  operator: string | null;263  city: string | null;264  address: string | null;265  lat: number | null;266  lng: number | null;267  codes: string[];268  externalIds: Record<string, string | number>;269  sourceCount: number;270  status: string;271  recordScope: string;272  parentFacilityId: string | null;273}274275export interface AdminMatch {276  id: string;277  connectorId: string;278  candidateKey: string;279  candidate: MatchCandidate;280  matchedFacilityId: string | null;281  score: number | null;282  reasons: string[];283  status: string;284  decidedBy: string | null;285  decidedAt: string | null;286  createdAt: string | null;287  candidateFacilityId: string | null;288  matched: FacilitySummary | null;289  candidateFacility: FacilitySummary | null;290  pair?: { candidate: Partial<MatchSide> | null; matched: MatchSide | null; distanceKm: number | null; sameCodes: string[] };291}292293/* -------- quality / data gaps / runs / trace (2026-09-12) */294295export type { QualityFlagDTO, QualityOverview, ClaimDTO };296297/** Worker `dataGaps()` — counters keyed by gap name (facilities_without_operator, projects_without_coordinates…). */298export type DataGaps = Record<string, number>;299300export interface RunChanges {301  runId: string;302  provenance: Array<ProvenanceDTO & { entityType: string; entityId: string; isCurrent: boolean }>;303  claims: ClaimDTO[];304  events: EventDTO[];305  documentVersions: Array<{ id: string; documentId: string; url: string | null; fetchedAt: string | null; significance: number; changes: number }>;306  counts: { provenance: number; claims: number; events: number; documentVersions: number };307}308309export interface RollbackResult {310  runId: string;311  claimsRejected: number;312  provenanceRetired: number;313  winnersRestored: number;314  eventsRejected: number;315}316317/** Mirror of apps/worker/src/trace.ts TraceResult (proxied as GET /api/admin/documents/:id/trace). */318export interface TraceClaim {319  entityKey: string;320  field: string;321  value: number;322  unit: "MW" | "USD";323  scope: string;324  scopeReason: string;325  semantics: string | null;326  evidence: { text: string; start: number; end: number } | null;327}328329export interface TraceMatch {330  entityKey: string;331  how: string;332  matchedId: string | null;333  candidates: Array<{ id: string; name: string; operatorName: string | null; city: string | null; score: number; reasons: string[]; distanceKm: number | null }>;334}335336export interface TraceResult {337  document: Record<string, unknown> | null;338  fetch: { source: "archive" | "live"; status: number; contentType: string | null; bytes: number; storageKey: string | null; level: number; fetcher: string } | null;339  text: { title: string | null; length: number; excerpt: string; classification: { pageType: string; rule: string; eventType: string | null; mw: number[] } | null };340  /** news announcement classification (class, figures with offsets, location…) — shape is connector-defined */341  announcement: Record<string, unknown> | null;342  records: Array<{ kind: string; key: string; certainty: number | null; pageType: string | null; data: Record<string, unknown>; methods: Record<string, string> }>;343  entities: Array<Record<string, unknown>>;344  validation: { total: number; valid: number; rejected: number; issues: ValidationIssue[] };345  claims: TraceClaim[];346  matches: TraceMatch[];347  reconciliation: { created: number; updated: number; unchanged: number; merged: number; pendingMatches: number; rejected: number; events: number; provenanceRows: number; claims: number; qualityFlags: number; unscopedClaims: number; projectsVetoed: number; changes: unknown[]; refs: Array<{ type: string; id: string }> } | null;348  logs: string[];349  error: string | null;350}351352export interface QueueCounts {353  waiting?: number;354  active?: number;355  completed?: number;356  failed?: number;357  delayed?: number;358  paused?: number;359  prioritized?: number;360}361362export interface OpsOverview {363  worker: unknown;364  queues: Record<string, QueueCounts>;365  budgets: {366    source: string;367    day?: string;368    limits: { scrapfly: number; firecrawl: number };369    used?: Record<string, { credits: number; requests: number }>;370    remaining?: { scrapfly: number; firecrawl: number };371    [k: string]: unknown;372  };373  db: { sizeBytes: number | null; sizePretty: string | null; tables: Array<{ table: string; rows: number; bytes: number | null }> };374  clickhouse: { ok: boolean; url: string };375  minio: { ok: boolean; endpoint: string; bucket: string };376  redis: { ok: boolean };377  apiCache: { entries: number; max: number };378  alerts: Array<{ id: string; level: string; component: string; message: string; details: unknown; createdAt: string | null }>;379  lastRuns: AdminRun[];380}381382export interface SourceListItem {383  id: string;384  name: string;385  domain: string;386  kind: SourceKind;387  url: string | null;388  license: string | null;389  attribution: string | null;390  connectorId: string | null;391  priority: number | null;392  robotsAllowed: boolean | null;393  notes: string | null;394  documents: number;395  provenanceRows: number;396  facilities: number;397  lastDocumentAt: string | null;398  updatedAt: string | null;399}400401/* -------- dev tool */402403export interface DevtoolFetchResult {404  url: string;405  finalUrl: string;406  status: number;407  contentType: string | null;408  fetcher: string;409  level: number;410  credits: number;411  durationMs: number;412  bytes: number;413  error: { message?: string; code?: string; [k: string]: unknown } | null;414  attempts: unknown;415  title: string | null;416  description: string | null;417  published: string | null;418  classification: unknown;419  geo: unknown;420  address: unknown;421  jsonLd: unknown[];422  embeddedJson: Array<{ id: string; preview: string }>;423  links: Array<{ href: string; text: string }>;424  html?: string;425  htmlTruncated?: boolean;426  text?: string;427  markdown?: string | null;428}429430export interface ValidationIssue {431  entity?: string | number;432  key?: string;433  field?: string;434  path?: string;435  level?: string;436  severity?: string;437  message?: string;438  [k: string]: unknown;439}440441export interface ValidationReport {442  total: number;443  valid: number;444  rejected: number;445  issues: ValidationIssue[];446}447448export interface DevtoolPreviewResult {449  fetch: DevtoolFetchResult;450  records: Array<Record<string, unknown>>;451  entities: Array<Record<string, unknown>>;452  validation: ValidationReport;453  logs: Array<{ level: string; msg: string }>;454  pageMeta?: unknown;455}456457export interface ConfigError {458  path: string;459  message: string;460}461462export type ValidateConfigResult = { ok: false; errors: ConfigError[] } | { ok: true; config: Record<string, unknown>; warnings: string[] };463464export interface SaveConfigResult {465  id: string;466  path: string;467  backup: string | null;468  parserVersion: string;469  enabled: boolean;470}471472export interface RunSampleUrlResult {473  url: string;474  ok: boolean;475  fetch?: DevtoolFetchResult;476  error?: string;477  records?: Array<Record<string, unknown>>;478  entities?: Array<Record<string, unknown>>;479  validation?: ValidationReport;480  logs?: Array<{ level: string; msg: string }>;481  durationMs: number;482}483484export interface RunSampleResult {485  results: RunSampleUrlResult[];486  summary: { urls: number; ok: number; credits: number; fieldCoverage: Record<string, number> };487}488489export interface ConfigListItem {490  file: string;491  id: string;492  name: string | null;493  enabled: boolean;494  parserVersion: string;495  kind: string | null;496  domain: string | null;497  valid: boolean;498  errors: string[];499}500501export interface FacilityPatch {502  name?: string;503  status?: string;504  facility_type?: string;505  lat?: number | null;506  lng?: number | null;507  geo_precision?: string;508  it_capacity_mw?: number | null;509  total_power_mw?: number | null;510  planned_power_mw?: number | null;511  mw_is_estimate?: boolean;512  opened_on?: string | null;513  operator_id?: string | null;514  is_ai?: boolean;515  is_hyperscale?: boolean;516  description?: string | null;517  note?: string;518}519520export interface FacilityPatchResult {521  id: string;522  changed: Array<{ field: string; column: string; oldValue: unknown; newValue: unknown }>;523  facility: FacilityDetail | null;524  message?: string;525}526527export type { EventDTO, FacilityDetail, FacilitySummary };528