Safer config sync; production deployed on BHS128 + BHS64b
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 & 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 | |