spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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