SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
2 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%

Safer config sync; production deployed on BHS128 + BHS64b

Simon-Pierre Boucher committed 21 days ago (Sep 11, 2026) parent 02c39d2

16 changed files +1,316 −0

added apps/web/qa/shoot-entities.mjs +96 −0
@@ -0,0 +1,96 @@
1 +// Entity-page QA screenshots → apps/web/qa/entities/<name>-<width>.png
2 +// node apps/web/qa/shoot-entities.mjs (all pages, 360/390/768/1280)
3 +// PAGES="facility,operator" WIDTHS=360,1280 THEME=dark node apps/web/qa/shoot-entities.mjs
4 +// EXTRA='name=/path,name2=/path2' adds ad-hoc pages. Reports horizontal overflow and HTTP status.
5 +import { createRequire } from "node:module";
6 +import { mkdirSync } from "node:fs";
7 +import { dirname, resolve } from "node:path";
8 +import { fileURLToPath } from "node:url";
9 +
10 +const require = createRequire("/Users/simon-pierreboucher/Desktop/uqo-eval/package.json");
11 +const { chromium } = require("playwright");
12 +const out = resolve(dirname(fileURLToPath(import.meta.url)), "entities");
13 +mkdirSync(out, { recursive: true });
14 +const BASE = process.env.BASE ?? "http://localhost:8310";
15 +const API = process.env.API ?? "http://127.0.0.1:8311/api/v1";
16 +
17 +async function firstSlug(path, pick = (d) => d[0]?.slug) {
18 + try {
19 + const r = await fetch(`${API}${path}`);
20 + const j = await r.json();
21 + return pick(Array.isArray(j.data) ? j.data : j.data?.items ?? []) ?? null;
22 + } catch {
23 + return null;
24 + }
25 +}
26 +
27 +const fac = process.env.FACILITY ?? (await firstSlug("/datacenters?per_page=1&sort=completeness&order=desc"));
28 +const op = process.env.OPERATOR ?? (await firstSlug("/operators?per_page=1&sort=facilities&order=desc"));
29 +const country = process.env.COUNTRY ?? "united-states";
30 +const metro = process.env.METRO ?? (await firstSlug("/metros", (d) => [...d].sort((a, b) => b.facilityCount - a.facilityCount)[0]?.slug));
31 +const project = process.env.PROJECT ?? (await firstSlug("/projects?per_page=1"));
32 +const ranking = process.env.RANKING ?? (await firstSlug("/rankings", (d) => (d.find((r) => r.total > 0) ?? d[0])?.key));
33 +const cloud = process.env.CLOUD_REGION ?? (await firstSlug("/cloud-regions?per_page=1"));
34 +const ixp = process.env.IXP ?? (await firstSlug("/ixps?per_page=1"));
35 +const source = process.env.SOURCE ?? (await firstSlug("/sources", (d) => d[0]?.id));
36 +
37 +const PAGES = [
38 + ["facility", fac && `/datacenters/${fac}`],
39 + ["operators", "/operators"],
40 + ["operator", op && `/operators/${op}`],
41 + ["countries", "/countries"],
42 + ["country", `/countries/${country}`],
43 + ["markets", "/datacenters/markets"],
44 + ["market", metro && `/datacenters/markets/${metro}`],
45 + ["projects", "/projects"],
46 + ["project", project && `/projects/${project}`],
47 + ["rankings", "/rankings"],
48 + ["ranking", ranking && `/rankings/${ranking}`],
49 + ["cloud-regions", "/cloud-regions"],
50 + ["cloud-region", cloud && `/cloud-regions/${cloud}`],
51 + ["ixps", "/ixps"],
52 + ["ixp", ixp && `/ixps/${ixp}`],
53 + ["sources", "/sources"],
54 + ["source", source && `/sources/${source}`],
55 + ["methodology", "/methodology"],
56 + ["bot", "/bot"],
57 + ["notfound", "/datacenters/this-slug-does-not-exist"],
58 + ...(process.env.EXTRA ? process.env.EXTRA.split(",").map((s) => s.split("=")) : []),
59 +].filter(([, p]) => p);
60 +
61 +const WIDTHS = (process.env.WIDTHS ?? "360,390,768,1280").split(",").map(Number);
62 +const THEME = process.env.THEME ?? "light";
63 +const ONLY = process.env.PAGES ? new Set(process.env.PAGES.split(",")) : null;
64 +const suffix = THEME === "dark" ? "-dark" : "";
65 +const browser = await chromium.launch();
66 +const report = [];
67 +for (const w of WIDTHS) {
68 + const ctx = await browser.newContext({ viewport: { width: w, height: w < 700 ? 800 : 900 }, deviceScaleFactor: 1, isMobile: w < 700, hasTouch: w < 700 });
69 + await ctx.addInitScript((theme) => {
70 + try {
71 + window.localStorage.setItem("theme", theme);
72 + } catch {}
73 + }, THEME);
74 + const page = await ctx.newPage();
75 + for (const [name, path] of PAGES) {
76 + if (ONLY && !ONLY.has(name)) continue;
77 + let status = 0;
78 + const res = await page.goto(`${BASE}${path}`, { waitUntil: "load", timeout: 60000 }).catch((e) => console.log("goto failed", name, w, String(e).slice(0, 80)));
79 + if (res) status = res.status();
80 + await page.waitForTimeout(1500);
81 + const overflow = await page.evaluate(() => {
82 + const doc = document.documentElement;
83 + const wide = [];
84 + for (const el of document.querySelectorAll("body *")) {
85 + const r = el.getBoundingClientRect();
86 + if (r.right > doc.clientWidth + 1 && r.width > 0 && getComputedStyle(el).position !== "fixed") wide.push(`${el.tagName.toLowerCase()}.${(el.className && typeof el.className === "string" ? el.className : "").split(" ").slice(0, 2).join(".")}:${Math.round(r.right - doc.clientWidth)}`);
87 + }
88 + return { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth, wide: wide.slice(0, 5) };
89 + });
90 + report.push({ w, name, status, overflow: overflow.scrollWidth > overflow.clientWidth ? overflow : "ok" });
91 + await page.screenshot({ path: resolve(out, `${name}-${w}${suffix}.png`), fullPage: true });
92 + }
93 + await ctx.close();
94 +}
95 +await browser.close();
96 +for (const r of report) console.log(r.w, r.name, r.status, typeof r.overflow === "string" ? r.overflow : JSON.stringify(r.overflow));
added apps/web/src/app/admin/api/[...path]/route.ts +97 −0
@@ -0,0 +1,97 @@
1 +/**
2 + * Same-origin proxy for the admin console: /admin/api/<path> → API /api/admin/<path> (or /api/v1/<path> when the
3 + * first segment is `v1`). Adds `x-dci-admin-token` from the httpOnly cookie server-side, forwards method / query /
4 + * body, streams the upstream body back (raw document bodies included). Mutations require a same-origin
5 + * `Sec-Fetch-Site` (or a matching `Origin`) — CSRF guard on top of the SameSite cookie.
6 + */
7 +import { NextResponse, type NextRequest } from "next/server";
8 +import { ADMIN_COOKIE, adminApiBase } from "@/lib/admin-api";
9 +
10 +export const dynamic = "force-dynamic";
11 +export const maxDuration = 300;
12 +
13 +const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;
14 +const UPSTREAM_TIMEOUT_MS = 290_000;
15 +const PASS_RESPONSE_HEADERS = ["content-type", "content-disposition", "content-length", "x-storage-key", "x-truncated", "x-cache", "etag", "x-ratelimit-limit", "x-ratelimit-remaining"];
16 +
17 +function json(status: number, body: Record<string, unknown>): NextResponse {
18 + const res = NextResponse.json(body, { status });
19 + res.headers.set("cache-control", "no-store");
20 + res.headers.set("x-robots-tag", "noindex, nofollow");
21 + return res;
22 +}
23 +
24 +function sameOrigin(req: NextRequest): boolean {
25 + const sfs = req.headers.get("sec-fetch-site");
26 + if (sfs) return sfs === "same-origin";
27 + const origin = req.headers.get("origin");
28 + if (!origin) return false;
29 + try {
30 + const o = new URL(origin);
31 + const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? req.nextUrl.host;
32 + return o.host === host;
33 + } catch {
34 + return false;
35 + }
36 +}
37 +
38 +async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }): Promise<Response> {
39 + const token = req.cookies.get(ADMIN_COOKIE)?.value;
40 + if (!token) return json(401, { error: "unauthorized", statusCode: 401 });
41 +
42 + const { path } = await ctx.params;
43 + if (!Array.isArray(path) || path.length === 0) return json(404, { error: "not found", statusCode: 404 });
44 + for (const seg of path) if (!SEGMENT.test(seg)) return json(400, { error: "invalid path", statusCode: 400 });
45 +
46 + const method = req.method.toUpperCase();
47 + const mutation = method !== "GET" && method !== "HEAD";
48 + if (mutation && !sameOrigin(req)) return json(403, { error: "cross-site request blocked", statusCode: 403 });
49 +
50 + const [first, ...rest] = path;
51 + const upstreamPath = first === "v1" ? `/api/v1/${rest.map(encodeURIComponent).join("/")}` : `/api/admin/${path.map(encodeURIComponent).join("/")}`;
52 + const url = `${adminApiBase()}${upstreamPath}${req.nextUrl.search}`;
53 +
54 + const headers = new Headers();
55 + headers.set("x-dci-admin-token", token);
56 + headers.set("accept", req.headers.get("accept") ?? "application/json");
57 + const ct = req.headers.get("content-type");
58 + if (ct) headers.set("content-type", ct);
59 + const inm = req.headers.get("if-none-match");
60 + if (inm) headers.set("if-none-match", inm);
61 + headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") ?? "127.0.0.1");
62 + headers.set("user-agent", "dci-web-admin-proxy/1.0");
63 +
64 + const init: RequestInit & { duplex?: "half" } = {
65 + method,
66 + headers,
67 + redirect: "manual",
68 + cache: "no-store",
69 + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
70 + };
71 + if (mutation && req.body) {
72 + init.body = req.body;
73 + init.duplex = "half";
74 + }
75 +
76 + let upstream: Response;
77 + try {
78 + upstream = await fetch(url, init);
79 + } catch (e) {
80 + const timeout = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");
81 + return json(timeout ? 504 : 502, { error: timeout ? "upstream timeout" : `upstream unreachable: ${e instanceof Error ? e.message : "error"}`, statusCode: timeout ? 504 : 502 });
82 + }
83 +
84 + const out = new Headers();
85 + for (const h of PASS_RESPONSE_HEADERS) {
86 + const v = upstream.headers.get(h);
87 + if (v) out.set(h, v);
88 + }
89 + out.set("cache-control", "no-store");
90 + out.set("x-robots-tag", "noindex, nofollow");
91 + out.set("x-content-type-options", "nosniff");
92 + // never let an HTML body render in the top frame from this origin
93 + if ((out.get("content-type") ?? "").includes("text/html")) out.set("content-security-policy", "sandbox; default-src 'none'");
94 + return new Response(method === "HEAD" ? null : upstream.body, { status: upstream.status, headers: out });
95 +}
96 +
97 +export { proxy as GET, proxy as POST, proxy as PATCH, proxy as PUT, proxy as DELETE, proxy as HEAD };
added apps/web/src/app/admin/layout.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import type { Metadata } from "next";
2 +import type { ReactNode } from "react";
3 +
4 +/**
5 + * /admin root: never indexed, no public chrome. The public Header / MobileNav come from the root layout, so they are
6 + * hidden here with a scoped rule (they are direct children of <body>); the admin shell renders its own navigation.
7 + */
8 +export const metadata: Metadata = {
9 + title: { default: "Admin", template: "%s · Admin · DataCenterIndex" },
10 + robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
11 +};
12 +
13 +export default function AdminRootLayout({ children }: { children: ReactNode }) {
14 + return (
15 + <>
16 + <style>{`body > header, body > nav[aria-label="Mobile"] { display: none !important; }`}</style>
17 + {children}
18 + </>
19 + );
20 +}
added apps/web/src/app/admin/login/actions.ts +33 −0
@@ -0,0 +1,33 @@
1 +"use server";
2 +
3 +import { cookies } from "next/headers";
4 +import { redirect } from "next/navigation";
5 +import { ADMIN_COOKIE, ADMIN_COOKIE_MAX_AGE, verifyAdminToken } from "@/lib/admin-api";
6 +
7 +export interface LoginState {
8 + error?: string;
9 +}
10 +
11 +function safeNext(v: unknown): string {
12 + if (typeof v !== "string") return "/admin";
13 + // only same-origin admin paths, no protocol-relative tricks
14 + if (!v.startsWith("/admin") || v.startsWith("//") || v.includes("\\")) return "/admin";
15 + if (v.startsWith("/admin/login") || v.startsWith("/admin/logout") || v.startsWith("/admin/api")) return "/admin";
16 + return v;
17 +}
18 +
19 +export async function loginAction(_prev: LoginState, formData: FormData): Promise<LoginState> {
20 + const token = String(formData.get("token") ?? "").trim();
21 + if (!token) return { error: "Enter the admin token." };
22 + const res = await verifyAdminToken(token);
23 + if (!res.ok) return { error: res.error ?? "Invalid token" };
24 + const jar = await cookies();
25 + jar.set(ADMIN_COOKIE, token, {
26 + httpOnly: true,
27 + sameSite: "lax",
28 + secure: process.env.NODE_ENV === "production",
29 + path: "/admin",
30 + maxAge: ADMIN_COOKIE_MAX_AGE,
31 + });
32 + redirect(safeNext(formData.get("next")));
33 +}
added apps/web/src/app/admin/login/page.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import type { Metadata } from "next";
2 +import { cookies } from "next/headers";
3 +import { redirect } from "next/navigation";
4 +import { ADMIN_COOKIE } from "@/lib/admin-api";
5 +import { LoginForm } from "@/components/admin/login-form";
6 +
7 +export const metadata: Metadata = { title: "Sign in" };
8 +export const dynamic = "force-dynamic";
9 +
10 +export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
11 + const sp = await searchParams;
12 + const next = typeof sp.next === "string" ? sp.next : "/admin";
13 + const reason = typeof sp.reason === "string" ? sp.reason : undefined;
14 + const jar = await cookies();
15 + if (jar.get(ADMIN_COOKIE)?.value && reason !== "expired") redirect("/admin");
16 + return (
17 + <main id="main" className="flex min-h-dvh items-center justify-center px-4 py-10">
18 + <LoginForm next={next} reason={reason} />
19 + </main>
20 + );
21 +}
added apps/web/src/app/admin/logout/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { cookies } from "next/headers";
2 +import { NextResponse, type NextRequest } from "next/server";
3 +import { ADMIN_COOKIE } from "@/lib/admin-api";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +async function logout(req: NextRequest): Promise<NextResponse> {
8 + const jar = await cookies();
9 + jar.set(ADMIN_COOKIE, "", { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/admin", maxAge: 0 });
10 + const res = NextResponse.redirect(new URL("/admin/login?reason=logged-out", req.url), 303);
11 + res.headers.set("cache-control", "no-store");
12 + return res;
13 +}
14 +
15 +export async function POST(req: NextRequest): Promise<NextResponse> {
16 + return logout(req);
17 +}
18 +
19 +export async function GET(req: NextRequest): Promise<NextResponse> {
20 + return logout(req);
21 +}
added apps/web/src/components/admin/login-form.tsx +50 −0
@@ -0,0 +1,50 @@
1 +"use client";
2 +
3 +import { KeyRound } from "lucide-react";
4 +import { useActionState } from "react";
5 +import { LogoMark } from "@/components/brand/logo";
6 +import { Button } from "@/components/ui";
7 +import { loginAction, type LoginState } from "@/app/admin/login/actions";
8 +
9 +export function LoginForm({ next, reason }: { next: string; reason?: string }) {
10 + const [state, action, pending] = useActionState<LoginState, FormData>(loginAction, {});
11 + return (
12 + <div className="w-full max-w-[380px] rounded-sm border border-hair bg-surface">
13 + <div className="flex items-center gap-2.5 border-b border-hair px-4 py-3">
14 + <LogoMark size={22} />
15 + <div>
16 + <div className="text-[13.5px] font-semibold leading-tight text-ink">DataCenterIndex · Admin</div>
17 + <div className="text-[11.5px] text-ink-3">Operations console</div>
18 + </div>
19 + </div>
20 + <form action={action} className="space-y-3 px-4 py-4">
21 + <input type="hidden" name="next" value={next} />
22 + {reason === "expired" && !state.error && <p className="rounded-xs border border-warn/30 bg-warn/10 px-2.5 py-2 text-[12.5px] text-warn">Your session expired or the token was rejected. Sign in again.</p>}
23 + {reason === "logged-out" && !state.error && <p className="rounded-xs border border-hair bg-surface-2 px-2.5 py-2 text-[12.5px] text-ink-2">Signed out.</p>}
24 + <label className="block">
25 + <span className="label-caps mb-1 block">Admin token</span>
26 + <input
27 + name="token"
28 + type="password"
29 + autoComplete="current-password"
30 + required
31 + autoFocus
32 + spellCheck={false}
33 + className="h-10 w-full rounded-sm border border-hair-2 bg-paper px-3 font-mono text-[13px] text-ink outline-none focus:border-ink"
34 + placeholder="x-dci-admin-token"
35 + />
36 + </label>
37 + {state.error && (
38 + <p role="alert" className="rounded-xs border border-danger/30 bg-danger/10 px-2.5 py-2 text-[12.5px] text-danger">
39 + {state.error}
40 + </p>
41 + )}
42 + <Button type="submit" variant="primary" className="w-full" disabled={pending}>
43 + <KeyRound className="size-4" strokeWidth={1.75} />
44 + {pending ? "Checking…" : "Sign in"}
45 + </Button>
46 + <p className="text-[11.5px] leading-snug text-ink-4">The token is verified against the API and kept in an httpOnly cookie for 12 hours. It is never sent to the browser bundle.</p>
47 + </form>
48 + </div>
49 + );
50 +}
added apps/web/src/components/admin/toast.tsx +66 −0
@@ -0,0 +1,66 @@
1 +"use client";
2 +
3 +import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
4 +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";
5 +import { cn } from "@/components/ui/cn";
6 +
7 +export type ToastTone = "ok" | "warn" | "danger" | "info";
8 +export interface ToastInput {
9 + title: string;
10 + description?: string;
11 + tone?: ToastTone;
12 + /** ms; default 4500, 0 = sticky */
13 + duration?: number;
14 +}
15 +interface ToastItem extends ToastInput {
16 + id: number;
17 +}
18 +
19 +const Ctx = createContext<{ toast: (t: ToastInput) => void } | null>(null);
20 +
21 +export function useToast(): (t: ToastInput) => void {
22 + const ctx = useContext(Ctx);
23 + if (!ctx) throw new Error("useToast outside ToastProvider");
24 + return ctx.toast;
25 +}
26 +
27 +const ICON: Record<ToastTone, typeof Info> = { ok: CheckCircle2, warn: AlertTriangle, danger: AlertTriangle, info: Info };
28 +const TONE: Record<ToastTone, string> = { ok: "text-ok", warn: "text-warn", danger: "text-danger", info: "text-info" };
29 +
30 +export function ToastProvider({ children }: { children: ReactNode }) {
31 + const [items, setItems] = useState<ToastItem[]>([]);
32 + const seq = useRef(0);
33 + const dismiss = useCallback((id: number) => setItems((p) => p.filter((t) => t.id !== id)), []);
34 + const toast = useCallback(
35 + (t: ToastInput) => {
36 + const id = ++seq.current;
37 + setItems((p) => [...p.slice(-4), { ...t, id }]);
38 + const ms = t.duration ?? (t.tone === "danger" ? 7000 : 4500);
39 + if (ms > 0) setTimeout(() => dismiss(id), ms);
40 + },
41 + [dismiss],
42 + );
43 + const value = useMemo(() => ({ toast }), [toast]);
44 + return (
45 + <Ctx.Provider value={value}>
46 + {children}
47 + <div aria-live="polite" className="pointer-events-none fixed inset-x-3 bottom-3 z-[120] flex flex-col items-center gap-2 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:items-end">
48 + {items.map((t) => {
49 + const Icon = ICON[t.tone ?? "info"];
50 + return (
51 + <div key={t.id} role="status" className="pointer-events-auto flex w-full max-w-[420px] items-start gap-2.5 rounded-sm border border-hair-2 bg-surface px-3 py-2.5 text-[13px] text-ink animate-fade-in">
52 + <Icon className={cn("mt-0.5 size-4 shrink-0", TONE[t.tone ?? "info"])} strokeWidth={1.75} />
53 + <div className="min-w-0 flex-1">
54 + <div className="font-medium leading-snug">{t.title}</div>
55 + {t.description && <div className="mt-0.5 break-words text-[12px] leading-snug text-ink-3">{t.description}</div>}
56 + </div>
57 + <button type="button" onClick={() => dismiss(t.id)} aria-label="Dismiss" className="-mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-ink">
58 + <X className="size-3.5" />
59 + </button>
60 + </div>
61 + );
62 + })}
63 + </div>
64 + </Ctx.Provider>
65 + );
66 +}
added apps/web/src/components/entity/entity-map.tsx +103 −0
@@ -0,0 +1,103 @@
1 +"use client";
2 +
3 +import type { FacilityFilters, GeoPrecision } from "@dci/core";
4 +import dynamic from "next/dynamic";
5 +import Link from "next/link";
6 +import { useEffect, useRef, useState } from "react";
7 +import { cn } from "@/components/ui/cn";
8 +import { Skeleton } from "@/components/ui/skeleton";
9 +import { isImprecise, PRECISION_LABEL } from "@/lib/labels";
10 +import { filtersToQuery } from "@/lib/query";
11 +import { routes } from "@/lib/routes";
12 +
13 +const MapCanvas = dynamic(() => import("@/components/map/map-canvas").then((m) => m.MapCanvas), { ssr: false, loading: () => <Skeleton className="h-full w-full rounded-none" /> });
14 +
15 +export interface EntityMapProps {
16 + /** map centre; falls back to the world view */
17 + center?: { lat: number; lng: number } | null;
18 + zoom?: number;
19 + /** facility filters applied to /map (operator, country, status…) */
20 + filters?: FacilityFilters;
21 + /** draws a hollow precision ring at the centre when the point is city/metro/approximate */
22 + precision?: GeoPrecision;
23 + /** height classes (mobile → desktop) */
24 + className?: string;
25 + /** label for the “open in map” affordance */
26 + openLabel?: string;
27 + /** hide the open-map link (e.g. facility without exact geo) */
28 + noLink?: boolean;
29 +}
30 +
31 +/** Zoom that fits a point given its geo precision. */
32 +export function zoomForPrecision(p: GeoPrecision | undefined): number {
33 + switch (p) {
34 + case "exact":
35 + case "parcel":
36 + return 13.5;
37 + case "street":
38 + return 12.5;
39 + case "approximate":
40 + return 10.5;
41 + case "city":
42 + return 9.5;
43 + case "metro":
44 + return 8.5;
45 + default:
46 + return 9;
47 + }
48 +}
49 +
50 +/**
51 + * Lazy, non-interactive map preview for entity pages (facility point, operator footprint, country / metro view).
52 + * Rendered only when scrolled near the viewport; the whole surface links to the interactive map with the same view.
53 + */
54 +export function EntityMap({ center, zoom, filters, precision, className, openLabel = "Open in map", noLink }: EntityMapProps) {
55 + const ref = useRef<HTMLDivElement>(null);
56 + const [visible, setVisible] = useState(false);
57 + useEffect(() => {
58 + const el = ref.current;
59 + if (!el) return;
60 + if (typeof IntersectionObserver === "undefined") {
61 + setVisible(true);
62 + return;
63 + }
64 + const io = new IntersectionObserver(
65 + (entries) => {
66 + if (entries.some((e) => e.isIntersecting)) {
67 + setVisible(true);
68 + io.disconnect();
69 + }
70 + },
71 + { rootMargin: "240px" },
72 + );
73 + io.observe(el);
74 + return () => io.disconnect();
75 + }, []);
76 +
77 + const view = center ? { lng: center.lng, lat: center.lat, zoom: zoom ?? zoomForPrecision(precision) } : undefined;
78 + const hollow = precision ? isImprecise(precision) : false;
79 + const qs = [filtersToQuery(filters ?? {}), view ? `lng=${view.lng.toFixed(4)}&lat=${view.lat.toFixed(4)}&z=${view.zoom}` : ""].filter(Boolean).join("&");
80 + const href = routes.map(qs || undefined);
81 +
82 + return (
83 + <div ref={ref} className={cn("relative w-full overflow-hidden rounded-sm border border-hair bg-surface-2", className ?? "h-[220px] sm:h-[320px]")}>
84 + {visible ? <MapCanvas preview initialView={view} filters={filters} className="h-full w-full" /> : <Skeleton className="h-full w-full rounded-none" />}
85 + {center && (
86 + <div aria-hidden className="pointer-events-none absolute left-1/2 top-1/2 z-[2] -translate-x-1/2 -translate-y-1/2">
87 + {hollow ? (
88 + <span className="block size-16 rounded-full border-2 border-dashed border-accent/70 bg-accent/5 sm:size-20" title={precision ? PRECISION_LABEL[precision] : undefined} />
89 + ) : (
90 + <span className="block size-3 rounded-full border-2 border-surface bg-accent ring-1 ring-accent/40" />
91 + )}
92 + </div>
93 + )}
94 + {!noLink && (
95 + <Link href={href} aria-label={openLabel} className="absolute inset-0 z-[3] flex items-end justify-end p-2 outline-none focus-visible:ring-2 focus-visible:ring-accent sm:p-3">
96 + <span className="inline-flex h-8 items-center gap-1.5 rounded-sm border border-hair-2 bg-surface px-2.5 text-[12px] font-medium text-ink">
97 + {openLabel} <span aria-hidden>→</span>
98 + </span>
99 + </Link>
100 + )}
101 + </div>
102 + );
103 +}
added apps/web/src/components/entity/kv.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import type { ReactNode } from "react";
2 +import { cn } from "@/components/ui/cn";
3 +
4 +export interface KvItem {
5 + label: ReactNode;
6 + value: ReactNode;
7 + /** hide the row when the value is unknown (default) or show “—” */
8 + keepEmpty?: boolean;
9 +}
10 +
11 +/**
12 + * Compact definition list for entity facts (address, tier, cooling, website…). Rows with null / undefined / "" / "—"
13 + * values are dropped unless `keepEmpty`, so pages never show a wall of dashes.
14 + */
15 +export function KeyValueList({ items, className, columns = 1 }: { items: KvItem[]; className?: string; columns?: 1 | 2 }) {
16 + const rows = items.filter((it) => it.keepEmpty || (it.value !== null && it.value !== undefined && it.value !== "" && it.value !== "—"));
17 + if (!rows.length) return null;
18 + return (
19 + <dl className={cn("grid gap-x-6", columns === 2 ? "sm:grid-cols-2" : "", className)}>
20 + {rows.map((it, i) => (
21 + <div key={i} className="grid grid-cols-[minmax(96px,120px)_minmax(0,1fr)] gap-x-3 border-b border-hair py-2 text-[13px] last:border-b-0 sm:grid-cols-[minmax(120px,160px)_minmax(0,1fr)]">
22 + <dt className="text-[12px] text-ink-3">{it.label}</dt>
23 + <dd className="min-w-0 break-words text-ink">{it.value ?? <span className="text-ink-4">—</span>}</dd>
24 + </div>
25 + ))}
26 + </dl>
27 + );
28 +}
29 +
30 +/** Honest placeholder for a section whose data the source does not publish. */
31 +export function NotPublished({ what, className }: { what: string; className?: string }) {
32 + return <p className={cn("rounded-sm border border-dashed border-hair-2 px-3 py-2.5 text-[12.5px] text-ink-3", className)}>{what} — not published by the sources indexed so far.</p>;
33 +}
34 +
35 +/** External link with safe rel and host-only label by default. */
36 +export function ExtLink({ href, children, className }: { href: string; children?: ReactNode; className?: string }) {
37 + let label: ReactNode = children;
38 + if (!label) {
39 + try {
40 + label = new URL(href).host.replace(/^www\./, "");
41 + } catch {
42 + label = href;
43 + }
44 + }
45 + return (
46 + <a href={href} target="_blank" rel="noopener noreferrer" className={cn("break-all text-ink underline decoration-hair-2 underline-offset-2 hover:text-accent", className)}>
47 + {label}
48 + </a>
49 + );
50 +}
added apps/web/src/components/entity/paged-facilities.tsx +35 −0
@@ -0,0 +1,35 @@
1 +import type { FacilitySummary } from "@dci/core";
2 +import { FacilityTable, type FacilityTableProps } from "@/components/facility-table";
3 +import { Pagination } from "@/components/ui/pagination";
4 +
5 +/**
6 + * Facilities block of operator / country / metro pages: table + `?fPage=` pager (50 per page, sorted by MW server-side).
7 + * `hrefFor` must preserve the other query params of the page.
8 + */
9 +export function PagedFacilities({ items, total, page, perPage = 50, hrefFor, columns, emptyTitle, emptyDescription }: { items: FacilitySummary[]; total: number; page: number; perPage?: number; hrefFor: (page: number) => string; columns?: FacilityTableProps["columns"]; emptyTitle?: string; emptyDescription?: string }) {
10 + return (
11 + <div>
12 + <FacilityTable items={items} columns={columns} emptyTitle={emptyTitle ?? "No facilities indexed yet"} emptyDescription={emptyDescription ?? "Facilities will appear as connectors observe them."} />
13 + <Pagination page={page} perPage={perPage} total={total} hrefFor={hrefFor} label="facilities" className="mt-3" />
14 + </div>
15 + );
16 +}
17 +
18 +/** Parse `?fPage=` (1-based, integer). */
19 +export function parseFPage(v: string | string[] | undefined): number {
20 + const s = Array.isArray(v) ? v[0] : v;
21 + const n = Number(s);
22 + return Number.isFinite(n) && n > 1 ? Math.floor(n) : 1;
23 +}
24 +
25 +/** Build `<base>?fPage=N#facilities`, keeping other params. */
26 +export function fPageHref(base: string, params: Record<string, string | string[] | undefined>, page: number, anchor = "facilities"): string {
27 + const sp = new URLSearchParams();
28 + for (const [k, v] of Object.entries(params)) {
29 + if (k === "fPage" || v === undefined) continue;
30 + sp.set(k, Array.isArray(v) ? v[0] ?? "" : v);
31 + }
32 + if (page > 1) sp.set("fPage", String(page));
33 + const qs = sp.toString();
34 + return `${base}${qs ? `?${qs}` : ""}${anchor ? `#${anchor}` : ""}`;
35 +}
added apps/web/src/components/entity/provenance.tsx +145 −0
@@ -0,0 +1,145 @@
1 +import type { ProvenanceDTO } from "@dci/core";
2 +import { ExternalLink } from "lucide-react";
3 +import type { ReactNode } from "react";
4 +import { ConfidenceBadge, EstimateMark } from "@/components/ui/badge";
5 +import { cn } from "@/components/ui/cn";
6 +import { ProvenancePopover } from "@/components/ui/popover";
7 +import { EmptyState } from "@/components/ui/states";
8 +import { TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
9 +import { fmtDate, fmtValue } from "@/lib/format";
10 +import { SOURCE_KIND_LABEL } from "@/lib/labels";
11 +
12 +/** Provenance rows backing one or more fields (API field names are camelCase; snake_case tolerated). */
13 +export function provFor(items: ProvenanceDTO[] | undefined, ...fields: string[]): ProvenanceDTO[] {
14 + if (!items?.length) return [];
15 + const want = new Set(fields.flatMap((f) => [f, f.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`), f.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())]));
16 + return items.filter((p) => want.has(p.field));
17 +}
18 +
19 +/** Human label for a provenance field name. */
20 +export const FIELD_LABEL: Record<string, string> = {
21 + name: "Name",
22 + status: "Status",
23 + facilityType: "Type",
24 + itCapacityMw: "IT capacity",
25 + totalPowerMw: "Total power",
26 + plannedPowerMw: "Planned power",
27 + plannedMw: "Planned power",
28 + mwIsEstimate: "MW estimate flag",
29 + openedOn: "Opened",
30 + announcedOn: "Announced",
31 + constructionStartedOn: "Construction started",
32 + expectedOpening: "Expected opening",
33 + investmentUsd: "Investment",
34 + address: "Address",
35 + postalCode: "Postal code",
36 + city: "City",
37 + regionName: "Region",
38 + countryIso2: "Country",
39 + lat: "Latitude",
40 + lng: "Longitude",
41 + geo: "Coordinates",
42 + coordinates: "Coordinates",
43 + geoPrecision: "Geo precision",
44 + buildingSqm: "Building area",
45 + siteAreaHa: "Site area",
46 + rackCount: "Racks",
47 + pue: "PUE",
48 + tier: "Tier",
49 + coolingType: "Cooling",
50 + renewableClaim: "Renewable claim",
51 + certifications: "Certifications",
52 + website: "Website",
53 + description: "Description",
54 + operator: "Operator",
55 + operatorName: "Operator",
56 + owner: "Owner",
57 + ownerName: "Owner",
58 + isAi: "AI flag",
59 + isHyperscale: "Hyperscale flag",
60 + aliases: "Aliases",
61 + carriers: "Carriers",
62 + cloudProviders: "Cloud providers",
63 + ixps: "Internet exchanges",
64 + acreage: "Acreage",
65 + phaseCount: "Phases",
66 +};
67 +
68 +export function fieldLabel(field: string): string {
69 + if (FIELD_LABEL[field]) return FIELD_LABEL[field];
70 + const camel = field.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
71 + if (FIELD_LABEL[camel]) return FIELD_LABEL[camel];
72 + return field.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
73 +}
74 +
75 +function valueHint(field: string): "mw" | "date" | "usd" | undefined {
76 + if (/Mw$|_mw$/.test(field)) return "mw";
77 + if (/On$|_on$|Opening$|_opening$/.test(field)) return "date";
78 + if (/Usd$|_usd$/.test(field)) return "usd";
79 + return undefined;
80 +}
81 +
82 +/** Value + inline provenance popover for the rows backing `fields`. Renders nothing extra when no provenance exists. */
83 +export function WithProvenance({ items, fields, children, className }: { items: ProvenanceDTO[] | undefined; fields: string | string[]; children: ReactNode; className?: string }) {
84 + const rows = provFor(items, ...(Array.isArray(fields) ? fields : [fields]));
85 + return (
86 + <span className={cn("inline-flex items-center gap-1", className)}>
87 + {children}
88 + {rows.length > 0 && <ProvenancePopover items={rows} size={12} />}
89 + </span>
90 + );
91 +}
92 +
93 +/** Full provenance table: field, value, source, first/last observed, confidence, method, link. Stacks to cards < 640 px. */
94 +export function ProvenanceTable({ items, emptyDescription = "No field-level provenance has been published for this record yet." }: { items: ProvenanceDTO[]; emptyDescription?: string }) {
95 + if (!items.length) return <EmptyState compact title="No provenance rows" description={emptyDescription} />;
96 + const sorted = [...items].sort((a, b) => fieldLabel(a.field).localeCompare(fieldLabel(b.field)) || (a.lastObserved < b.lastObserved ? 1 : -1));
97 + return (
98 + <Table dense>
99 + <THead>
100 + <tr>
101 + <Th>Field</Th>
102 + <Th>Value</Th>
103 + <Th>Source</Th>
104 + <Th className="hidden md:table-cell">First observed</Th>
105 + <Th className="hidden md:table-cell">Last observed</Th>
106 + <Th>Confidence</Th>
107 + <Th className="hidden lg:table-cell">Method</Th>
108 + <Th className="hidden sm:table-cell">Link</Th>
109 + </tr>
110 + </THead>
111 + <TBody>
112 + {sorted.map((p, i) => (
113 + <Tr key={`${p.field}-${p.sourceId}-${i}`}>
114 + <Td primary>{fieldLabel(p.field)}</Td>
115 + <Td label="Value" className="figure max-w-[280px] break-words text-[12.5px]">
116 + {p.isEstimate && <EstimateMark className="mr-0.5" />}
117 + {fmtValue(p.value, valueHint(p.field))}
118 + </Td>
119 + <Td label="Source">
120 + <span className="text-ink">{p.sourceName}</span>
121 + <span className="ml-1 text-[11px] text-ink-3">{SOURCE_KIND_LABEL[p.sourceKind]}</span>
122 + </Td>
123 + <Td label="First observed" className="figure hidden text-[12px] text-ink-3 md:table-cell">
124 + {fmtDate(p.firstObserved)}
125 + </Td>
126 + <Td label="Last observed" className="figure hidden text-[12px] text-ink-3 md:table-cell">
127 + {fmtDate(p.lastObserved)}
128 + </Td>
129 + <Td label="Confidence">
130 + <ConfidenceBadge level={p.confidence} size="xs" />
131 + </Td>
132 + <Td label="Method" className="figure hidden text-[11.5px] text-ink-3 lg:table-cell">
133 + {p.method ?? "—"}
134 + </Td>
135 + <Td label="Link" className="hidden sm:table-cell">
136 + <a href={p.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-[12px] text-ink-2 hover:text-accent" title={p.url}>
137 + source <ExternalLink className="size-3" />
138 + </a>
139 + </Td>
140 + </Tr>
141 + ))}
142 + </TBody>
143 + </Table>
144 + );
145 +}
added apps/web/src/components/entity/sources-footer.tsx +49 −0
@@ -0,0 +1,49 @@
1 +import type { SourceRef } from "@dci/core";
2 +import { ExternalLink } from "lucide-react";
3 +import Link from "next/link";
4 +import { SOURCE_KIND_LABEL } from "@/lib/labels";
5 +import { routes } from "@/lib/routes";
6 +
7 +/** Attribution footer listing the sources behind the figures on the page (from the API envelope `sources`). */
8 +export function SourcesFooter({ sources, updatedAt, note }: { sources: SourceRef[] | undefined | null; updatedAt?: string | null; note?: string }) {
9 + const list = sources ?? [];
10 + return (
11 + <footer className="mt-10 border-t border-hair pt-4 text-[12px] text-ink-3">
12 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
13 + <div className="label-caps">Sources &amp; attribution</div>
14 + {updatedAt && <div className="figure text-[11.5px]">Record updated {new Date(updatedAt).toISOString().slice(0, 10)} UTC</div>}
15 + </div>
16 + {list.length ? (
17 + <ul className="mt-2 divide-y divide-hair">
18 + {list.map((s) => (
19 + <li key={s.id} className="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-baseline sm:gap-3">
20 + <div className="flex min-w-0 items-center gap-2 sm:w-[260px] sm:shrink-0">
21 + <Link href={routes.source(s.id)} className="truncate font-medium text-ink-2 hover:text-ink hover:underline">
22 + {s.name}
23 + </Link>
24 + <span className="shrink-0 text-[11px] text-ink-4">{SOURCE_KIND_LABEL[s.kind]}</span>
25 + </div>
26 + <div className="min-w-0 flex-1">
27 + {s.attribution && <span className="text-ink-3">{s.attribution}</span>}
28 + {s.license && <span className="text-ink-4"> · {s.license.length > 90 ? `${s.license.slice(0, 87)}…` : s.license}</span>}
29 + </div>
30 + {s.url && (
31 + <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex shrink-0 items-center gap-1 text-ink-3 hover:text-accent">
32 + {s.domain} <ExternalLink className="size-3" />
33 + </a>
34 + )}
35 + </li>
36 + ))}
37 + </ul>
38 + ) : (
39 + <p className="mt-2">Every figure on this page links to the page it was observed on; see the provenance table above.</p>
40 + )}
41 + <p className="mt-3 text-[11.5px] text-ink-4">
42 + {note ?? "Figures are lower bounds computed from published or filed values only; estimates are marked ≈. Nothing is imputed."}{" "}
43 + <Link href={routes.methodology()} className="underline decoration-hair-2 hover:text-ink">
44 + Methodology
45 + </Link>
46 + </p>
47 + </footer>
48 + );
49 +}
added apps/web/src/lib/admin-api.ts +446 −0
@@ -0,0 +1,446 @@
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 the
6 + * 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 + */
11 +import type { ConnectorHealthDTO, DetectedChange, EventDTO, FacilityDetail, FacilitySummary, ProvenanceDTO, SourceKind } from "@dci/core";
12 +
13 +export const ADMIN_COOKIE = "dci_admin";
14 +export const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 12; // 12 h
15 +
16 +/** Base URL of the API as seen from the Next server (never exposed to the client). */
17 +export 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 +}
21 +
22 +/** Server-only: check a token against the API (GET /api/admin/ops). Never throws. */
23 +export 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 +}
39 +
40 +/* ------------------------------------------------------------------ client fetch */
41 +
42 +export 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 };
45 +
46 +export type AdminQuery = Record<string, string | number | boolean | undefined | null>;
47 +
48 +export 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 +}
58 +
59 +export function adminProxyUrl(path: string, q?: AdminQuery): string {
60 + return `/admin/api/${path.replace(/^\/+/, "")}${adminQuery(q)}`;
61 +}
62 +
63 +interface 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 +}
72 +
73 +let redirecting = false;
74 +
75 +/** Browser-side JSON call through the admin proxy. NEVER throws. */
76 +export 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 + window.location.assign(`/admin/login?next=${next}&reason=expired`);
94 + }
95 + const text = await res.text();
96 + let json: unknown = null;
97 + try {
98 + json = text ? JSON.parse(text) : null;
99 + } catch {
100 + json = null;
101 + }
102 + if (!res.ok) {
103 + const err = (json as { error?: string; message?: string; details?: unknown } | null) ?? {};
104 + return { ok: false, data: null, error: err.error ?? err.message ?? (text ? text.slice(0, 200) : `HTTP ${res.status}`), details: err.details, status: res.status };
105 + }
106 + if (json && typeof json === "object" && "data" in (json as object)) {
107 + const env = json as { data: T; meta?: Record<string, unknown> };
108 + return { ok: true, data: env.data, meta: env.meta, status: res.status };
109 + }
110 + return { ok: true, data: json as T, meta: undefined, status: res.status };
111 + } catch (e) {
112 + const aborted = e instanceof Error && (e.name === "AbortError" || e.message === "timeout");
113 + return { ok: false, data: null, error: aborted ? (init.signal?.aborted ? "aborted" : "timeout") : "network error", status: 0 };
114 + } finally {
115 + clearTimeout(timer);
116 + }
117 +}
118 +
119 +/** Raw (non-JSON) proxy call — used for document bodies and YAML text. */
120 +export async function adminFetchText(path: string, query?: AdminQuery, signal?: AbortSignal): Promise<{ ok: boolean; text: string; contentType: string | null; status: number; truncated: boolean; error?: string }> {
121 + try {
122 + const res = await fetch(adminProxyUrl(path, query), { credentials: "same-origin", cache: "no-store", signal });
123 + const text = await res.text();
124 + if (!res.ok) {
125 + let err = `HTTP ${res.status}`;
126 + try {
127 + err = (JSON.parse(text) as { error?: string }).error ?? err;
128 + } catch {
129 + /* keep */
130 + }
131 + return { ok: false, text: "", contentType: null, status: res.status, truncated: false, error: err };
132 + }
133 + return { ok: true, text, contentType: res.headers.get("content-type"), status: res.status, truncated: res.headers.get("x-truncated") === "1" };
134 + } catch (e) {
135 + return { ok: false, text: "", contentType: null, status: 0, truncated: false, error: e instanceof Error ? e.message : "network error" };
136 + }
137 +}
138 +
139 +/* ------------------------------------------------------------------ admin DTOs (mirror apps/api/src/routes/admin) */
140 +
141 +export type { ConnectorHealthDTO };
142 +
143 +export interface AdminRun {
144 + id: string;
145 + connectorId: string;
146 + task: string;
147 + startedAt: string | null;
148 + finishedAt: string | null;
149 + status: string;
150 + stats: Record<string, unknown>;
151 + error: string | null;
152 + log?: Array<{ t?: string; level?: string; msg?: string; [k: string]: unknown }>;
153 +}
154 +
155 +export interface ConnectorAdminDetail {
156 + health: ConnectorHealthDTO;
157 + config: Record<string, unknown>;
158 + paused: boolean;
159 + lastError: string | null;
160 + runs: Array<Omit<AdminRun, "connectorId">>;
161 + documentsByPageType: Array<{ pageType: string; count: number; changed: number; failed: number; quarantined: number }>;
162 + errorSamples: Array<{ id: string; url: string; error: string | null; statusCode: number | null; errorCount: number; lastChecked: string | null }>;
163 + createdAt: string | null;
164 + updatedAt: string | null;
165 +}
166 +
167 +export interface AdminDocument {
168 + id: string;
169 + connectorId: string;
170 + sourceId: string;
171 + url: string;
172 + canonicalUrl: string;
173 + pageType: string;
174 + classifier: string | null;
175 + fetchLevel: number;
176 + priority: number;
177 + contentHash: string | null;
178 + etag: string | null;
179 + lastModified: string | null;
180 + statusCode: number | null;
181 + contentType: string | null;
182 + sizeBytes: number | null;
183 + title: string | null;
184 + storageKey: string | null;
185 + extractorVersion: string | null;
186 + extractOk: boolean | null;
187 + extractCount: number;
188 + error: string | null;
189 + errorCount: number;
190 + firstSeen: string | null;
191 + lastFetched: string | null;
192 + lastChanged: string | null;
193 + lastChecked: string | null;
194 + nextCheck: string | null;
195 + changeFrequencyScore: number | null;
196 + fetchCount: number;
197 + changeCount: number;
198 + discoveredFrom: string | null;
199 + quarantined: boolean;
200 + entityRefs: Array<{ type: string; id: string }>;
201 + updatedAt: string | null;
202 + versionCount?: number;
203 +}
204 +
205 +export interface AdminDocVersion {
206 + id: string;
207 + documentId: string;
208 + contentHash: string;
209 + fetchedAt: string | null;
210 + fetchLevel: number;
211 + statusCode: number | null;
212 + sizeBytes: number | null;
213 + storageKey: string | null;
214 + significance: number;
215 + diffSummary: { added?: number; removed?: number; addedCount?: number; removedCount?: number; ratio?: number; [k: string]: unknown } | null;
216 + detectedChanges: DetectedChange[];
217 +}
218 +
219 +export interface AdminDocumentDetail {
220 + document: AdminDocument;
221 + versions: AdminDocVersion[];
222 + provenance: Array<ProvenanceDTO & { entityType: string; entityId: string; isCurrent: boolean }>;
223 + entities: Array<{ type: string; id: string; slug: string | null; name: string | null }>;
224 +}
225 +
226 +export interface AdminVersionDiff {
227 + version: AdminDocVersion;
228 + previous: { id: string; contentHash: string; fetchedAt: string | null } | null;
229 + diffSummary: AdminDocVersion["diffSummary"];
230 + detectedChanges: DetectedChange[];
231 +}
232 +
233 +export interface MatchCandidate {
234 + key?: string;
235 + url?: string | null;
236 + name?: string | null;
237 + city?: string | null;
238 + address?: string | null;
239 + status?: string | null;
240 + countryIso2?: string | null;
241 + operatorName?: string | null;
242 + itCapacityMw?: number | null;
243 + totalPowerMw?: number | null;
244 + plannedPowerMw?: number | null;
245 + geo?: { lat?: number; lng?: number; precision?: string } | null;
246 + lat?: number | null;
247 + lng?: number | null;
248 + sourceId?: string | null;
249 + externalIds?: Record<string, string | number>;
250 + createdFacilityId?: string | null;
251 + [k: string]: unknown;
252 +}
253 +
254 +export interface AdminMatch {
255 + id: string;
256 + connectorId: string;
257 + candidateKey: string;
258 + candidate: MatchCandidate;
259 + matchedFacilityId: string | null;
260 + score: number | null;
261 + reasons: string[];
262 + status: string;
263 + decidedBy: string | null;
264 + decidedAt: string | null;
265 + createdAt: string | null;
266 + candidateFacilityId: string | null;
267 + matched: FacilitySummary | null;
268 + candidateFacility: FacilitySummary | null;
269 +}
270 +
271 +export interface QueueCounts {
272 + waiting?: number;
273 + active?: number;
274 + completed?: number;
275 + failed?: number;
276 + delayed?: number;
277 + paused?: number;
278 + prioritized?: number;
279 +}
280 +
281 +export interface OpsOverview {
282 + worker: unknown;
283 + queues: Record<string, QueueCounts>;
284 + budgets: {
285 + source: string;
286 + day?: string;
287 + limits: { scrapfly: number; firecrawl: number };
288 + used?: Record<string, { credits: number; requests: number }>;
289 + remaining?: { scrapfly: number; firecrawl: number };
290 + [k: string]: unknown;
291 + };
292 + db: { sizeBytes: number | null; sizePretty: string | null; tables: Array<{ table: string; rows: number; bytes: number | null }> };
293 + clickhouse: { ok: boolean; url: string };
294 + minio: { ok: boolean; endpoint: string; bucket: string };
295 + redis: { ok: boolean };
296 + apiCache: { entries: number; max: number };
297 + alerts: Array<{ id: string; level: string; component: string; message: string; details: unknown; createdAt: string | null }>;
298 + lastRuns: AdminRun[];
299 +}
300 +
301 +export interface SourceListItem {
302 + id: string;
303 + name: string;
304 + domain: string;
305 + kind: SourceKind;
306 + url: string | null;
307 + license: string | null;
308 + attribution: string | null;
309 + connectorId: string | null;
310 + priority: number | null;
311 + robotsAllowed: boolean | null;
312 + notes: string | null;
313 + documents: number;
314 + provenanceRows: number;
315 + facilities: number;
316 + lastDocumentAt: string | null;
317 + updatedAt: string | null;
318 +}
319 +
320 +/* -------- dev tool */
321 +
322 +export interface DevtoolFetchResult {
323 + url: string;
324 + finalUrl: string;
325 + status: number;
326 + contentType: string | null;
327 + fetcher: string;
328 + level: number;
329 + credits: number;
330 + durationMs: number;
331 + bytes: number;
332 + error: { message?: string; code?: string; [k: string]: unknown } | null;
333 + attempts: unknown;
334 + title: string | null;
335 + description: string | null;
336 + published: string | null;
337 + classification: unknown;
338 + geo: unknown;
339 + address: unknown;
340 + jsonLd: unknown[];
341 + embeddedJson: Array<{ id: string; preview: string }>;
342 + links: Array<{ href: string; text: string }>;
343 + html?: string;
344 + htmlTruncated?: boolean;
345 + text?: string;
346 + markdown?: string | null;
347 +}
348 +
349 +export interface ValidationIssue {
350 + entity?: string | number;
351 + key?: string;
352 + field?: string;
353 + path?: string;
354 + level?: string;
355 + severity?: string;
356 + message?: string;
357 + [k: string]: unknown;
358 +}
359 +
360 +export interface ValidationReport {
361 + total: number;
362 + valid: number;
363 + rejected: number;
364 + issues: ValidationIssue[];
365 +}
366 +
367 +export interface DevtoolPreviewResult {
368 + fetch: DevtoolFetchResult;
369 + records: Array<Record<string, unknown>>;
370 + entities: Array<Record<string, unknown>>;
371 + validation: ValidationReport;
372 + logs: Array<{ level: string; msg: string }>;
373 + pageMeta?: unknown;
374 +}
375 +
376 +export interface ConfigError {
377 + path: string;
378 + message: string;
379 +}
380 +
381 +export type ValidateConfigResult = { ok: false; errors: ConfigError[] } | { ok: true; config: Record<string, unknown>; warnings: string[] };
382 +
383 +export interface SaveConfigResult {
384 + id: string;
385 + path: string;
386 + backup: string | null;
387 + parserVersion: string;
388 + enabled: boolean;
389 +}
390 +
391 +export interface RunSampleUrlResult {
392 + url: string;
393 + ok: boolean;
394 + fetch?: DevtoolFetchResult;
395 + error?: string;
396 + records?: Array<Record<string, unknown>>;
397 + entities?: Array<Record<string, unknown>>;
398 + validation?: ValidationReport;
399 + logs?: Array<{ level: string; msg: string }>;
400 + durationMs: number;
401 +}
402 +
403 +export interface RunSampleResult {
404 + results: RunSampleUrlResult[];
405 + summary: { urls: number; ok: number; credits: number; fieldCoverage: Record<string, number> };
406 +}
407 +
408 +export interface ConfigListItem {
409 + file: string;
410 + id: string;
411 + name: string | null;
412 + enabled: boolean;
413 + parserVersion: string;
414 + kind: string | null;
415 + domain: string | null;
416 + valid: boolean;
417 + errors: string[];
418 +}
419 +
420 +export interface FacilityPatch {
421 + name?: string;
422 + status?: string;
423 + facility_type?: string;
424 + lat?: number | null;
425 + lng?: number | null;
426 + geo_precision?: string;
427 + it_capacity_mw?: number | null;
428 + total_power_mw?: number | null;
429 + planned_power_mw?: number | null;
430 + mw_is_estimate?: boolean;
431 + opened_on?: string | null;
432 + operator_id?: string | null;
433 + is_ai?: boolean;
434 + is_hyperscale?: boolean;
435 + description?: string | null;
436 + note?: string;
437 +}
438 +
439 +export interface FacilityPatchResult {
440 + id: string;
441 + changed: Array<{ field: string; column: string; oldValue: unknown; newValue: unknown }>;
442 + facility: FacilityDetail | null;
443 + message?: string;
444 +}
445 +
446 +export type { EventDTO, FacilityDetail, FacilitySummary };
added apps/web/src/lib/sort.ts +79 −0
@@ -0,0 +1,79 @@
1 +/** URL-state sorting for server-rendered index tables (operators, countries, markets, projects, cloud regions…). */
2 +
3 +export type SearchParams = Record<string, string | string[] | undefined>;
4 +
5 +export function spGet(sp: SearchParams, key: string): string | undefined {
6 + const v = sp[key];
7 + const s = Array.isArray(v) ? v[0] : v;
8 + return s === undefined || s === "" ? undefined : s;
9 +}
10 +
11 +export function spInt(sp: SearchParams, key: string, fallback = 1): number {
12 + const n = Number(spGet(sp, key));
13 + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
14 +}
15 +
16 +/** Build an href for the same page with params merged (undefined removes; `page` reset unless kept). */
17 +export function hrefWith(base: string, sp: SearchParams, patch: Record<string, string | number | undefined | null>, opts: { keepPage?: boolean; anchor?: string } = {}): string {
18 + const out = new URLSearchParams();
19 + for (const [k, v] of Object.entries(sp)) {
20 + if (v === undefined) continue;
21 + if (k === "page" && !opts.keepPage) continue;
22 + out.set(k, Array.isArray(v) ? v[0] ?? "" : v);
23 + }
24 + for (const [k, v] of Object.entries(patch)) {
25 + if (v === undefined || v === null || v === "") out.delete(k);
26 + else out.set(k, String(v));
27 + }
28 + const qs = out.toString();
29 + return `${base}${qs ? `?${qs}` : ""}${opts.anchor ? `#${opts.anchor}` : ""}`;
30 +}
31 +
32 +export interface SortState<K extends string> {
33 + key: K;
34 + order: "asc" | "desc";
35 +}
36 +
37 +/** Read ?sort=&order= against an allow-list. */
38 +export function parseSort<K extends string>(sp: SearchParams, allowed: readonly K[], fallback: SortState<K>): SortState<K> {
39 + const s = spGet(sp, "sort");
40 + const o = spGet(sp, "order");
41 + const key = (allowed as readonly string[]).includes(s ?? "") ? (s as K) : fallback.key;
42 + const order = o === "asc" || o === "desc" ? o : key === fallback.key ? fallback.order : defaultOrderFor(key);
43 + return { key, order };
44 +}
45 +
46 +function defaultOrderFor(key: string): "asc" | "desc" {
47 + return key === "name" || key === "code" || key === "provider" || key === "country" || key === "region" ? "asc" : "desc";
48 +}
49 +
50 +/** Props for <Th sort sortHref> given the current state. */
51 +export function sortTh<K extends string>(base: string, sp: SearchParams, state: SortState<K>, key: K): { sort: "asc" | "desc" | null; sortHref: string } {
52 + const active = state.key === key;
53 + const next = active ? (state.order === "asc" ? "desc" : "asc") : defaultOrderFor(key);
54 + return { sort: active ? state.order : null, sortHref: hrefWith(base, sp, { sort: key, order: next }) };
55 +}
56 +
57 +/** Generic in-memory comparator honouring nulls last. */
58 +export function compareBy<T>(get: (t: T) => number | string | null | undefined, order: "asc" | "desc") {
59 + return (a: T, b: T): number => {
60 + const va = get(a);
61 + const vb = get(b);
62 + const na = va === null || va === undefined || va === "";
63 + const nb = vb === null || vb === undefined || vb === "";
64 + if (na && nb) return 0;
65 + if (na) return 1;
66 + if (nb) return -1;
67 + let c: number;
68 + if (typeof va === "number" && typeof vb === "number") c = va - vb;
69 + else c = String(va).localeCompare(String(vb), "en", { sensitivity: "base", numeric: true });
70 + return order === "asc" ? c : -c;
71 + };
72 +}
73 +
74 +/** Slice for client-agnostic pagination of an in-memory list. */
75 +export function paginate<T>(items: T[], page: number, perPage: number): { items: T[]; page: number; pages: number } {
76 + const pages = Math.max(1, Math.ceil(items.length / perPage));
77 + const p = Math.min(Math.max(1, page), pages);
78 + return { items: items.slice((p - 1) * perPage, p * perPage), page: p, pages };
79 +}
modified apps/worker/src/configs.ts +5 −0
@@ -83,6 +83,11 @@ export async function syncConnectorsToDb(loaded: LoadedConnector[] = loadAllConn
83 83 const ids = loaded.map((l) => l.cfg.id);
84 84 const orphans = await db.execute<{ id: string }>(ids.length ? sql`select id from connectors where enabled = true and id not in (${sql.join(ids.map((i) => sql`${i}`), sql`, `)})` : sql`select id from connectors where enabled = true`);
85 85 const disabledInDb: string[] = [];
86 + // Safety: a partial view of the YAML directory (wrong cwd, half-synced checkout) must never mass-disable connectors.
87 + if (orphans.length > 0 && orphans.length > Math.max(3, Math.floor(ids.length * 0.25))) {
88 + console.warn(`[configs] refusing to disable ${orphans.length} connectors without YAML (only ${ids.length} configs loaded) — check the config directory`);
89 + return { connectors: n, sources: s, disabledInDb };
90 + }
86 91 for (const row of orphans) {
87 92 await db.update(connectorsTable).set({ enabled: false, lastError: "no YAML config found at sync", updatedAt: now }).where(eq(connectorsTable.id, row.id));
88 93 disabledInDb.push(row.id);
89 94