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%

Web entity pages: facility, operator, country, market, project, rankings, cloud regions, IXPs, sources, methodology, sitemaps; PeeringDB approval draft

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 21 days ago (Sep 11, 2026) parent 44118d1

203 changed files +12,080 −22

added apps/web/qa/entities/bot-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/bot-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/bot-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/bot-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-region-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-region-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-region-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-region-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-regions-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-regions-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-regions-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/cloud-regions-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/countries-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/countries-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/countries-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/countries-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/country-1280-top.png +0 −0

Binary file not shown.

added apps/web/qa/entities/country-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/country-360-top.png +0 −0

Binary file not shown.

added apps/web/qa/entities/country-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/country-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/country-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/facility-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/facility-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/facility-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/facility-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/facility-lrz-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/facility-lrz-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ixps-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ixps-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ixps-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ixps-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/market-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/market-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/market-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/market-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/markets-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/markets-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/markets-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/markets-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/methodology-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/methodology-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/methodology-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/methodology-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/notfound-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/notfound-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/notfound-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/notfound-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operator-1280-top.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operator-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operator-360-top.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operator-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operator-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operator-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operators-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operators-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operators-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/operators-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/project-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/project-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/project-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/project-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/projects-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/projects-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/projects-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/projects-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ranking-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ranking-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ranking-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/ranking-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/rankings-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/rankings-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/rankings-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/rankings-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/source-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/source-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/source-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/source-768.png +0 −0

Binary file not shown.

added apps/web/qa/entities/sources-1280.png +0 −0

Binary file not shown.

added apps/web/qa/entities/sources-360.png +0 −0

Binary file not shown.

added apps/web/qa/entities/sources-390.png +0 −0

Binary file not shown.

added apps/web/qa/entities/sources-768.png +0 −0

Binary file not shown.

added apps/web/src/app/(map)/map/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Skeleton } from "@/components/ui/skeleton";
2 +
3 +export default function Loading() {
4 + return <Skeleton className="h-[calc(100dvh-48px)] w-full rounded-none sm:h-[calc(100dvh-56px)]" />;
5 +}
renamed apps/web/src/app/loading.tsx → apps/web/src/app/(site)/(home)/loading.tsx +0 −0
renamed apps/web/src/app/(site)/page.tsx → apps/web/src/app/(site)/(home)/page.tsx +0 −0
added apps/web/src/app/(site)/about/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-24" />
8 + <Skeleton className="mb-6 h-7 w-64" />
9 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
10 + {Array.from({ length: 4 }).map((_, i) => (
11 + <div key={i} className="flex flex-col gap-2">
12 + <Skeleton className="h-7 w-20" />
13 + <Skeleton className="h-2.5 w-24" />
14 + </div>
15 + ))}
16 + </div>
17 + <SkeletonRows rows={8} className="mt-6" />
18 + </Page>
19 + );
20 +}
added apps/web/src/app/(site)/bot/page.tsx +129 −0
@@ -0,0 +1,129 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import type { ReactNode } from "react";
4 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
5 +import { SectionNav } from "@/components/entity/section-nav";
6 +import { Page, PageHeader, Section } from "@/components/ui/card";
7 +import { TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
8 +import { routes } from "@/lib/routes";
9 +import { pageMetadata } from "@/lib/seo";
10 +
11 +export const metadata: Metadata = pageMetadata({
12 + title: "About our crawler",
13 + description: "DataCenterIndexBot indexes public data center facility pages, press releases, planning filings and cloud region lists. How it identifies itself, how it behaves and how to opt out.",
14 + path: routes.bot(),
15 +});
16 +
17 +/** Default identity of L1 fetches — packages/connectors/src/fetchers.ts (DEFAULT_UA, overridable via DCI_USER_AGENT). */
18 +const UA = "DataCenterIndexBot/0.1 (+https://www.datacenterindex.io/bot; contact@spboucher.ai)";
19 +
20 +const NAV = [
21 + { id: "what", label: "What it is" },
22 + { id: "behaviour", label: "Behaviour" },
23 + { id: "identification", label: "Identification" },
24 + { id: "opt-out", label: "Opt out" },
25 + { id: "contact", label: "Contact" },
26 +];
27 +
28 +const FREQUENCY: Array<[string, string]> = [
29 + ["Facility / site pages", "about weekly"],
30 + ["Newsroom, press releases, RSS", "daily"],
31 + ["Cloud provider region lists", "daily to weekly"],
32 + ["Index / listing pages and sitemaps", "weekly to monthly"],
33 + ["Planning filings, registries, datasets", "weekly to monthly, per the source's own cadence"],
34 +];
35 +
36 +function P({ children }: { children: ReactNode }) {
37 + return <p className="mt-3 first:mt-0">{children}</p>;
38 +}
39 +
40 +const linkCls = "text-ink underline decoration-hair-2 hover:text-accent";
41 +
42 +export default function BotPage() {
43 + return (
44 + <Page className="max-w-[880px]">
45 + <Breadcrumbs items={[{ name: "Crawler", href: routes.bot() }]} className="pt-3" />
46 + <PageHeader eyebrow="DataCenterIndexBot" title="About our crawler" description="If you found this page in your server logs: the requests come from the crawler that builds DataCenterIndex. Here is what it reads, how it behaves and how to make it stop." className="pt-3" />
47 + <SectionNav items={NAV} />
48 +
49 + <div className="mt-6 flex flex-col gap-10 text-[14.5px] leading-relaxed text-ink-2">
50 + <Section id="what" className="scroll-mt-28" title="What it is">
51 + <P>
52 + DataCenterIndexBot collects <strong className="text-ink">public</strong> information about data center infrastructure: operators&rsquo; facility and campus pages, press releases and newsrooms, cloud providers&rsquo; published region and availability-zone lists, planning and permitting filings, utility and regulator publications, and open datasets and registries such as PeeringDB, OpenStreetMap, Wikidata and the World Bank.
53 + </P>
54 + <P>
55 + From these pages it extracts <em>facts</em> — a facility name, an address, an IT capacity in MW, an opening date, a status, a certification — and records where each fact was read, when, and with what confidence. The result is the structured, versioned graph published on this site and through the public API, with every value linked back to the page it came from and an attribution line for every source (see the <Link href={routes.sources()} className={linkCls}>source registry</Link>). It does not copy articles or reproduce pages; descriptions are short and attributed.
56 + </P>
57 + </Section>
58 +
59 + <Section id="behaviour" className="scroll-mt-28" title="How it behaves">
60 + <ul className="list-disc space-y-1.5 pl-5">
61 + <li>
62 + <strong className="text-ink">robots.txt</strong> is fetched and honoured for every host, including <code className="figure text-[12.5px]">Disallow</code> rules and <code className="figure text-[12.5px]">Crawl-delay</code>; the file is re-read at least once a day. Both the group for <code className="figure text-[12.5px]">DataCenterIndexBot</code> and the <code className="figure text-[12.5px]">*</code> group are respected.
63 + </li>
64 + <li>
65 + <strong className="text-ink">Rate limits per host</strong>: by default at most 30 requests per minute and 2 concurrent connections, lower when the source asks for it or when responses slow down. Sources with published API limits (for example SEC EDGAR fair-access rules or the Wikidata query service) are read within those limits.
66 + </li>
67 + <li>
68 + <strong className="text-ink">Conditional requests</strong>: pages are requested with <code className="figure text-[12.5px]">If-None-Match</code> / <code className="figure text-[12.5px]">If-Modified-Since</code> when the server supports them, and every body is content-hashed so an unchanged page is never re-processed.
69 + </li>
70 + <li>
71 + <strong className="text-ink">Public pages only</strong>: no login, paywall, CAPTCHA or bot-wall is bypassed. When a page is only served as a JavaScript shell, a rendering service may be used to read it — the same page a visitor would see, at the same rate limits.
72 + </li>
73 + <li>
74 + <strong className="text-ink">Scoped discovery</strong>: only page types relevant to the source (facility pages, newsroom, region lists, filings) are followed; the crawler does not wander through an entire site.
75 + </li>
76 + </ul>
77 + <P>Typical revisit frequency by page type — actual schedules are set per source and are usually less frequent:</P>
78 + <div className="mt-3">
79 + <Table dense>
80 + <THead>
81 + <tr>
82 + <Th>Page type</Th>
83 + <Th>Revisit</Th>
84 + </tr>
85 + </THead>
86 + <TBody>
87 + {FREQUENCY.map(([t, f]) => (
88 + <Tr key={t}>
89 + <Td primary>{t}</Td>
90 + <Td label="Revisit" className="text-ink-2">
91 + {f}
92 + </Td>
93 + </Tr>
94 + ))}
95 + </TBody>
96 + </Table>
97 + </div>
98 + </Section>
99 +
100 + <Section id="identification" className="scroll-mt-28" title="Identification">
101 + <P>Direct requests carry this User-Agent header:</P>
102 + <pre className="mt-3 overflow-x-auto rounded-sm border border-hair bg-surface px-3 py-2.5 text-[12.5px] leading-relaxed text-ink">
103 + <code className="figure">{UA}</code>
104 + </pre>
105 + <P>
106 + Requests originate from the infrastructure that hosts the index — the MacLustr cluster and dedicated servers at OVHcloud (Canada and France). Reverse DNS on those addresses is not guaranteed to resolve to a datacenterindex.io name, so please rely on the User-Agent string above, or write to us to confirm a specific address. When a source blocks the bot identity and a browser identity is used instead, the same rate limits apply and the source is listed in the <Link href={routes.sources()} className={linkCls}>registry</Link>.
107 + </P>
108 + </Section>
109 +
110 + <Section id="opt-out" className="scroll-mt-28" title="How to opt out">
111 + <P>Add a group for the bot to your robots.txt. The change takes effect at the next robots.txt refresh (within 24 hours); pages already archived are not fetched again.</P>
112 + <pre className="mt-3 overflow-x-auto rounded-sm border border-hair bg-surface px-3 py-2.5 text-[12.5px] leading-relaxed text-ink">
113 + <code className="figure">{"User-agent: DataCenterIndexBot\nDisallow: /"}</code>
114 + </pre>
115 + <P>To exclude only part of a site, list the paths instead of <code className="figure text-[12.5px]">/</code>. A <code className="figure text-[12.5px]">Crawl-delay</code> directive slows the bot down without blocking it.</P>
116 + <P>
117 + You can also email <a href="mailto:contact@spboucher.ai" className={linkCls}>contact@spboucher.ai</a> to have a source removed from the index or to correct a value. Removal requests are honoured within 7 days: the connector is paused and archived documents are deleted. Facts already published (for example that a facility exists at an address, with its capacity) remain in the index with their attribution unless you ask for them to be removed as well; corrections are applied at the source and propagate through the normal pipeline, so the change is visible in the <Link href={routes.live()} className={linkCls}>live feed</Link>.
118 + </P>
119 + </Section>
120 +
121 + <Section id="contact" className="scroll-mt-28" title="Contact">
122 + <P>
123 + DataCenterIndex is built and operated by Simon-Pierre Boucher. Questions about the crawler, data corrections, removal requests, data partnerships or API access: <a href="mailto:contact@spboucher.ai" className={linkCls}>contact@spboucher.ai</a>. See also the <Link href={routes.methodology()} className={linkCls}>methodology</Link> for the full crawling and reconciliation rules.
124 + </P>
125 + </Section>
126 + </div>
127 + </Page>
128 + );
129 +}
added apps/web/src/app/(site)/cloud-regions/(index)/loading.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-16" />
8 + <Skeleton className="mb-3 h-7 w-48" />
9 + <Skeleton className="mb-5 h-4 w-full max-w-xl" />
10 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-5">
11 + {Array.from({ length: 5 }).map((_, i) => (
12 + <div key={i} className="flex flex-col gap-2">
13 + <Skeleton className="h-5 w-16" />
14 + <Skeleton className="h-2.5 w-20" />
15 + </div>
16 + ))}
17 + </div>
18 + <div className="mt-5 flex gap-2">
19 + <Skeleton className="h-9 w-40" />
20 + <Skeleton className="h-9 w-40" />
21 + <Skeleton className="h-9 w-28" />
22 + </div>
23 + <SkeletonRows rows={10} className="mt-6" />
24 + </Page>
25 + );
26 +}
added apps/web/src/app/(site)/cloud-regions/(index)/page.tsx +281 −0
@@ -0,0 +1,281 @@
1 +import type { CloudRegionSummary } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import { SectionNav } from "@/components/entity/section-nav";
5 +import { Badge } from "@/components/ui/badge";
6 +import { Button } from "@/components/ui/button";
7 +import { Page, PageHeader, Section } from "@/components/ui/card";
8 +import { CountryFlag } from "@/components/ui/country-flag";
9 +import { FilterChips, type Chip } from "@/components/ui/filter-chips";
10 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
11 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
12 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
13 +import { api } from "@/lib/api";
14 +import { countryName, fmtDate, fmtInt } from "@/lib/format";
15 +import { routes } from "@/lib/routes";
16 +import { pageMetadata } from "@/lib/seo";
17 +import { compareBy, hrefWith, parseSort, sortTh, spGet, type SearchParams, type SortState } from "@/lib/sort";
18 +import { CLOUD_STATUSES, CLOUD_STATUS_LABEL, CloudStatusBadge, type CloudStatus } from "../cloud-format";
19 +
20 +export const revalidate = 120;
21 +
22 +export const metadata: Metadata = pageMetadata({
23 + title: "Cloud regions",
24 + description: "Public cloud regions by provider — code, location, availability zones, launch date, status and sovereignty, each linked to the provider's published source.",
25 + path: routes.cloudRegions(),
26 +});
27 +
28 +const SORT_KEYS = ["provider", "code", "name", "city", "country", "azs", "launched", "status"] as const;
29 +type SortKey = (typeof SORT_KEYS)[number];
30 +
31 +const BASE = routes.cloudRegions();
32 +
33 +const sel = "h-9 rounded-sm border border-hair-2 bg-surface px-2 text-[13px] text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent";
34 +
35 +function RegionTable({ items, showProvider, sp, sort }: { items: CloudRegionSummary[]; showProvider: boolean; sp: SearchParams; sort: SortState<SortKey> | null }) {
36 + const th = (key: SortKey) => (sort ? sortTh(BASE, sp, sort, key) : {});
37 + return (
38 + <Table dense>
39 + <THead>
40 + <tr>
41 + {showProvider && <Th {...th("provider")}>Provider</Th>}
42 + <Th {...th("code")}>Code</Th>
43 + <Th {...th("name")}>Region</Th>
44 + <Th className="hidden sm:table-cell" {...th("city")}>
45 + City
46 + </Th>
47 + <Th {...th("country")}>Country</Th>
48 + <Th numeric {...th("azs")}>
49 + AZs
50 + </Th>
51 + <Th className="hidden lg:table-cell" {...th("launched")}>
52 + Launched
53 + </Th>
54 + <Th {...th("status")}>Status</Th>
55 + <Th className="hidden lg:table-cell">Sovereign</Th>
56 + </tr>
57 + </THead>
58 + <TBody>
59 + {items.map((r) => (
60 + <Tr key={r.id}>
61 + {showProvider && (
62 + <Td label="Provider">
63 + <Link href={routes.operator(r.provider.slug)} className="text-ink-2 hover:text-ink hover:underline">
64 + {r.provider.name}
65 + </Link>
66 + </Td>
67 + )}
68 + <Td primary>
69 + <RowLink href={routes.cloudRegion(r.slug)} className="figure text-[12.5px]">
70 + {r.code}
71 + </RowLink>
72 + <div className="text-[11.5px] text-ink-3 sm:hidden">
73 + {r.name}
74 + {r.city ? ` · ${r.city}` : ""}
75 + </div>
76 + </Td>
77 + <Td label="Region" className="hidden max-w-[220px] truncate text-ink-2 sm:table-cell">
78 + {r.name}
79 + </Td>
80 + <Td label="City" className="hidden max-w-[140px] truncate text-ink-3 sm:table-cell">
81 + {r.city ?? "—"}
82 + </Td>
83 + <Td label="Country">
84 + <CountryFlag iso2={r.countryIso2} withName className="max-w-[120px] text-ink-2 lg:max-w-[160px]" />
85 + </Td>
86 + <Td label="AZs" numeric>
87 + {r.availabilityZones ?? <span className="text-ink-4">—</span>}
88 + </Td>
89 + <Td label="Launched" className="figure hidden text-[12px] text-ink-3 lg:table-cell">
90 + {fmtDate(r.launchedOn)}
91 + </Td>
92 + <Td label="Status">
93 + <CloudStatusBadge status={r.status} />
94 + </Td>
95 + <Td label="Sovereign" className="hidden lg:table-cell">
96 + {r.isSovereign ? (
97 + <Badge tone="accent" size="xs">
98 + Sovereign
99 + </Badge>
100 + ) : (
101 + <span className="text-ink-4">—</span>
102 + )}
103 + </Td>
104 + </Tr>
105 + ))}
106 + </TBody>
107 + </Table>
108 + );
109 +}
110 +
111 +export default async function CloudRegionsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
112 + const sp = await searchParams;
113 + const provider = spGet(sp, "provider");
114 + const country = spGet(sp, "country")?.toUpperCase();
115 + const statusRaw = spGet(sp, "status");
116 + const status = (CLOUD_STATUSES as string[]).includes(statusRaw ?? "") ? (statusRaw as CloudStatus) : undefined;
117 + const sorted = spGet(sp, "sort") !== undefined;
118 +
119 + // Unfiltered list for facets + metrics; filtered list for the table (the API filters server-side).
120 + const [all, filtered] = await Promise.all([api.cloudRegions({ per_page: 400 }), provider || country || status ? api.cloudRegions({ per_page: 400, provider, country, status }) : null]);
121 + const ok = all.items.length > 0;
122 + const items = filtered ? filtered.items : all.items;
123 +
124 + const providers = Array.from(new Map(all.items.map((r) => [r.provider.slug, { slug: r.provider.slug, name: r.provider.name, n: 0 }])).values());
125 + for (const r of all.items) providers.find((p) => p.slug === r.provider.slug)!.n++;
126 + providers.sort((a, b) => b.n - a.n || a.name.localeCompare(b.name));
127 + const countries = Array.from(new Set(all.items.map((r) => r.countryIso2).filter((x): x is string => !!x))).sort((a, b) => countryName(a).localeCompare(countryName(b)));
128 + const operational = all.items.filter((r) => r.status === "operational").length;
129 + const announced = all.items.filter((r) => r.status === "announced").length;
130 +
131 + const chips: Chip[] = [];
132 + if (provider) chips.push({ key: "provider", label: "Provider", value: providers.find((p) => p.slug === provider)?.name ?? provider, removeHref: hrefWith(BASE, sp, { provider: undefined }) });
133 + if (country) chips.push({ key: "country", label: "Country", value: countryName(country), removeHref: hrefWith(BASE, sp, { country: undefined }) });
134 + if (status) chips.push({ key: "status", label: "Status", value: CLOUD_STATUS_LABEL[status], removeHref: hrefWith(BASE, sp, { status: undefined }) });
135 +
136 + const sort = sorted ? parseSort<SortKey>(sp, SORT_KEYS, { key: "provider", order: "asc" }) : null;
137 + let flat: CloudRegionSummary[] = items;
138 + if (sort) {
139 + const get = (r: CloudRegionSummary): string | number | null => {
140 + switch (sort.key) {
141 + case "provider":
142 + return r.provider.name;
143 + case "code":
144 + return r.code;
145 + case "name":
146 + return r.name;
147 + case "city":
148 + return r.city;
149 + case "country":
150 + return r.countryIso2 ? countryName(r.countryIso2) : null;
151 + case "azs":
152 + return r.availabilityZones;
153 + case "launched":
154 + return r.launchedOn;
155 + case "status":
156 + return r.status;
157 + }
158 + };
159 + flat = [...items].sort(compareBy(get, sort.order));
160 + }
161 + const groups = sorted
162 + ? []
163 + : providers
164 + .map((p) => ({ ...p, items: items.filter((r) => r.provider.slug === p.slug).sort((a, b) => a.code.localeCompare(b.code)) }))
165 + .filter((g) => g.items.length);
166 +
167 + return (
168 + <Page>
169 + <PageHeader
170 + eyebrow="Index"
171 + title="Cloud regions"
172 + description={ok ? `${fmtInt(all.total)} public cloud regions across ${providers.length} providers — code, location, availability zones, launch date, status and sovereignty, each linked to the provider's published source.` : "Public cloud regions by provider: code, location, availability zones, launch date, status and sovereignty."}
173 + aside={
174 + <Button href={routes.map("cr=1")} size="sm">
175 + Show on map
176 + </Button>
177 + }
178 + />
179 + {!ok && <OfflineNotice detail={all.total === 0 ? "no cloud regions returned" : undefined} className="mb-3" />}
180 + <p className="-mt-3 mb-4 text-[12px] text-ink-3">Coordinates are city-level (the published location), never the physical data center.</p>
181 +
182 + {ok && (
183 + <MetricRow cols={5} className="mb-5">
184 + <MetricCell>
185 + <Metric value={fmtInt(all.total)} label="Regions" size="sm" />
186 + </MetricCell>
187 + <MetricCell>
188 + <Metric value={fmtInt(providers.length)} label="Providers" size="sm" />
189 + </MetricCell>
190 + <MetricCell>
191 + <Metric value={fmtInt(countries.length)} label="Countries" size="sm" />
192 + </MetricCell>
193 + <MetricCell>
194 + <Metric value={fmtInt(operational)} label="Operational" size="sm" />
195 + </MetricCell>
196 + <MetricCell>
197 + <Metric value={fmtInt(announced)} label="Announced" size="sm" />
198 + </MetricCell>
199 + </MetricRow>
200 + )}
201 +
202 + <form method="get" action={BASE} className="flex flex-wrap items-center gap-2">
203 + <label className="sr-only" htmlFor="cr-provider">
204 + Provider
205 + </label>
206 + <select id="cr-provider" name="provider" defaultValue={provider ?? ""} className={`${sel} max-w-[46vw] sm:max-w-none`}>
207 + <option value="">All providers</option>
208 + {providers.map((p) => (
209 + <option key={p.slug} value={p.slug}>
210 + {p.name} ({p.n})
211 + </option>
212 + ))}
213 + </select>
214 + <label className="sr-only" htmlFor="cr-country">
215 + Country
216 + </label>
217 + <select id="cr-country" name="country" defaultValue={country ?? ""} className={`${sel} max-w-[46vw] sm:max-w-none`}>
218 + <option value="">All countries</option>
219 + {countries.map((c) => (
220 + <option key={c} value={c}>
221 + {countryName(c)}
222 + </option>
223 + ))}
224 + </select>
225 + <label className="sr-only" htmlFor="cr-status">
226 + Status
227 + </label>
228 + <select id="cr-status" name="status" defaultValue={status ?? ""} className={sel}>
229 + <option value="">Any status</option>
230 + {CLOUD_STATUSES.map((s) => (
231 + <option key={s} value={s}>
232 + {CLOUD_STATUS_LABEL[s]}
233 + </option>
234 + ))}
235 + </select>
236 + {sort && <input type="hidden" name="sort" value={sort.key} />}
237 + {sort && <input type="hidden" name="order" value={sort.order} />}
238 + <Button type="submit" size="sm" variant="primary">
239 + Apply
240 + </Button>
241 + <Link href={sorted ? hrefWith(BASE, sp, { sort: undefined, order: undefined }) : hrefWith(BASE, sp, { sort: "provider", order: "asc" })} className="ml-auto text-[12.5px] text-ink-3 hover:text-ink">
242 + {sorted ? "Group by provider" : "Flat sortable table"}
243 + </Link>
244 + </form>
245 + <FilterChips chips={chips} resetHref={BASE} className="mt-2" />
246 +
247 + {ok && items.length === 0 && <EmptyState className="mt-4" title="No cloud region matches these filters" description="Loosen a filter or clear them all." />}
248 +
249 + {!sorted && groups.length > 0 && <SectionNav items={groups.map((g) => ({ id: g.slug, label: g.name, count: g.items.length }))} className="mt-3" />}
250 +
251 + {sorted ? (
252 + <div className="mt-4">
253 + <RegionTable items={flat} showProvider sp={sp} sort={sort} />
254 + </div>
255 + ) : (
256 + <div className="mt-2 flex flex-col gap-8">
257 + {groups.map((g) => (
258 + <Section
259 + key={g.slug}
260 + id={g.slug}
261 + title={
262 + <Link href={routes.operator(g.slug)} className="hover:text-accent hover:underline">
263 + {g.name}
264 + </Link>
265 + }
266 + description={`${g.items.length} ${g.items.length === 1 ? "region" : "regions"} · ${g.items.filter((r) => r.status === "operational").length} operational`}
267 + className="scroll-mt-28"
268 + aside={
269 + <Link href={hrefWith(BASE, sp, { provider: g.slug })} className="text-[12px] text-ink-3 hover:text-ink">
270 + Only {g.name}
271 + </Link>
272 + }
273 + >
274 + <RegionTable items={g.items} showProvider={false} sp={sp} sort={null} />
275 + </Section>
276 + ))}
277 + </div>
278 + )}
279 + </Page>
280 + );
281 +}
added apps/web/src/app/(site)/cloud-regions/[slug]/layout.tsx +11 −0
@@ -0,0 +1,11 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { api } from "@/lib/api";
4 +
5 +/** Existence check outside the loading boundary so unknown slugs return a real 404 (the fetch is cached for the page). */
6 +export default async function CloudRegionLayout({ children, params }: { children: ReactNode; params: Promise<{ slug: string }> }) {
7 + const { slug } = await params;
8 + const r = await api.cloudRegion(slug);
9 + if (!r.ok && r.status === 404) notFound();
10 + return children;
11 +}
added apps/web/src/app/(site)/cloud-regions/[slug]/loading.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonBlock, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-56" />
8 + <Skeleton className="mb-2 h-3 w-32" />
9 + <Skeleton className="mb-3 h-7 w-64" />
10 + <div className="mb-5 flex gap-2">
11 + <Skeleton className="h-5 w-20" />
12 + <Skeleton className="h-5 w-24" />
13 + <Skeleton className="h-5 w-16" />
14 + </div>
15 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
16 + {Array.from({ length: 4 }).map((_, i) => (
17 + <div key={i} className="flex flex-col gap-2">
18 + <Skeleton className="h-7 w-20" />
19 + <Skeleton className="h-2.5 w-24" />
20 + </div>
21 + ))}
22 + </div>
23 + <div className="mt-6 grid gap-4 md:grid-cols-2">
24 + <SkeletonRows rows={5} />
25 + <SkeletonBlock className="h-[220px]" />
26 + </div>
27 + <SkeletonRows rows={6} className="mt-8" />
28 + </Page>
29 + );
30 +}
added apps/web/src/app/(site)/cloud-regions/[slug]/opengraph-image.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import { api } from "@/lib/api";
2 +import { countryName, fmtDate } from "@/lib/format";
3 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
4 +import { CLOUD_STATUS_LABEL } from "../cloud-format";
5 +
6 +export const alt = "DataCenterIndex cloud region";
7 +export const size = OG_SIZE;
8 +export const contentType = OG_CONTENT_TYPE;
9 +export const revalidate = 3600;
10 +
11 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
12 + const { slug } = await params;
13 + const r = await api.cloudRegion(slug);
14 + if (!r.ok) return renderOg({ eyebrow: "Cloud region", title: "DataCenterIndex cloud regions", subtitle: "Public cloud regions by provider — location, availability zones, launch date, status." });
15 + const d = r.data;
16 + const country = d.countryName ?? (d.countryIso2 ? countryName(d.countryIso2) : null);
17 + return renderOg({
18 + eyebrow: d.provider.name,
19 + title: `${d.code} · ${d.name}`,
20 + subtitle: [d.city, country].filter(Boolean).join(", ") || undefined,
21 + stats: [
22 + { label: "Availability zones", value: d.availabilityZones === null ? "—" : String(d.availabilityZones) },
23 + { label: "Launched", value: fmtDate(d.launchedOn) },
24 + { label: "Status", value: CLOUD_STATUS_LABEL[d.status] },
25 + { label: "Country", value: country ?? "—" },
26 + ],
27 + });
28 +}
added apps/web/src/app/(site)/cloud-regions/[slug]/page.tsx +237 −0
@@ -0,0 +1,237 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
5 +import { EntityMap } from "@/components/entity/entity-map";
6 +import { JsonLd } from "@/components/entity/json-ld";
7 +import { ExtLink, KeyValueList } from "@/components/entity/kv";
8 +import { SectionNav } from "@/components/entity/section-nav";
9 +import { SourcesFooter } from "@/components/entity/sources-footer";
10 +import { FacilityTable } from "@/components/facility-table";
11 +import { Badge, PrecisionBadge } from "@/components/ui/badge";
12 +import { Page, Section } from "@/components/ui/card";
13 +import { CountryFlag } from "@/components/ui/country-flag";
14 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
15 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
16 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
17 +import { api } from "@/lib/api";
18 +import { countryName, fmtDate, fmtInt } from "@/lib/format";
19 +import { PRECISION_LABEL } from "@/lib/labels";
20 +import { routes } from "@/lib/routes";
21 +import { NOT_FOUND_METADATA, absoluteUrl, compactJsonLd, pageMetadata } from "@/lib/seo";
22 +import { CLOUD_STATUS_LABEL, CloudStatusBadge, countryHrefMap } from "../cloud-format";
23 +
24 +export const revalidate = 300;
25 +
26 +type Params = Promise<{ slug: string }>;
27 +
28 +function describe(d: { provider: { name: string }; code: string; name: string; city: string | null; countryIso2: string | null; availabilityZones: number | null; launchedOn: string | null; status: string; isSovereign: boolean }): string {
29 + const parts = [`${d.provider.name} cloud region ${d.code} (${d.name})`];
30 + const place = [d.city, d.countryIso2 ? countryName(d.countryIso2) : null].filter(Boolean).join(", ");
31 + if (place) parts.push(`located in ${place}`);
32 + if (d.availabilityZones) parts.push(`${d.availabilityZones} availability ${d.availabilityZones === 1 ? "zone" : "zones"}`);
33 + if (d.launchedOn) parts.push(`launched ${fmtDate(d.launchedOn)}`);
34 + parts.push(`status ${CLOUD_STATUS_LABEL[d.status as keyof typeof CLOUD_STATUS_LABEL] ?? d.status}${d.isSovereign ? ", sovereign" : ""}`);
35 + return `${parts.join(" — ")}. Source-linked, city-level location.`;
36 +}
37 +
38 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
39 + const { slug } = await params;
40 + const r = await api.cloudRegion(slug);
41 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Cloud region" };
42 + const d = r.data;
43 + return pageMetadata({ title: `${d.provider.name} ${d.code} — ${d.name}`, description: describe(d), path: routes.cloudRegion(d.slug) });
44 +}
45 +
46 +export default async function CloudRegionPage({ params }: { params: Params }) {
47 + const { slug } = await params;
48 + const [r, countryHref] = await Promise.all([api.cloudRegion(slug), countryHrefMap()]);
49 + if (!r.ok && r.status === 404) notFound();
50 + if (!r.ok) {
51 + return (
52 + <Page className="py-6">
53 + <Breadcrumbs items={[{ name: "Cloud regions", href: routes.cloudRegions() }, { name: slug, href: routes.cloudRegion(slug) }]} className="mb-4" />
54 + <OfflineNotice detail={r.error} />
55 + </Page>
56 + );
57 + }
58 + const d = r.data;
59 + const providerHref = `${routes.cloudRegions()}?provider=${encodeURIComponent(d.provider.slug)}`;
60 + const cHref = countryHref(d.countryIso2);
61 + const cName = d.countryName ?? (d.countryIso2 ? countryName(d.countryIso2) : null);
62 + const siblings = (d.siblingRegions ?? []).filter((s) => s.id !== d.id);
63 + const facilities = d.facilitiesInMetro ?? [];
64 + const ids = Object.entries(d.externalIds ?? {}).filter(([k]) => k !== "kind");
65 + const hasGeo = typeof d.lat === "number" && typeof d.lng === "number";
66 +
67 + const jsonLd = compactJsonLd({
68 + "@context": "https://schema.org",
69 + "@type": "Place",
70 + name: `${d.provider.name} ${d.code} — ${d.name}`,
71 + url: absoluteUrl(routes.cloudRegion(d.slug)),
72 + description: describe(d),
73 + geo: hasGeo ? { "@type": "GeoCoordinates", latitude: d.lat, longitude: d.lng } : undefined,
74 + address: d.city || cName ? compactJsonLd({ "@type": "PostalAddress", addressLocality: d.city, addressCountry: d.countryIso2 }) : undefined,
75 + containedInPlace: cName ? { "@type": "Country", name: cName } : undefined,
76 + sameAs: d.sourceUrl ?? undefined,
77 + });
78 +
79 + const nav = [
80 + { id: "location", label: "Location" },
81 + { id: "siblings", label: `Other ${d.provider.name} regions`, count: siblings.length },
82 + { id: "facilities", label: "Facilities in this market" },
83 + { id: "identifiers", label: "Identifiers", count: ids.length },
84 + { id: "source", label: "Source" },
85 + ];
86 +
87 + return (
88 + <Page>
89 + <JsonLd data={jsonLd} />
90 + <Breadcrumbs items={[{ name: "Cloud regions", href: routes.cloudRegions() }, { name: d.provider.name, href: providerHref }, { name: d.code, href: routes.cloudRegion(d.slug) }]} className="pt-4" />
91 +
92 + <header className="py-4 sm:py-6">
93 + <div className="label-caps mb-1.5">
94 + <Link href={routes.operator(d.provider.slug)} className="hover:text-ink">
95 + {d.provider.name}
96 + </Link>{" "}
97 + · Cloud region
98 + </div>
99 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{d.name}</h1>
100 + <div className="mt-2 flex flex-wrap items-center gap-2">
101 + <Badge tone="outline" mono>
102 + {d.code}
103 + </Badge>
104 + <CloudStatusBadge status={d.status} size="sm" />
105 + {d.isSovereign && <Badge tone="accent">Sovereign</Badge>}
106 + <PrecisionBadge precision={d.geoPrecision} />
107 + </div>
108 + <p className="mt-2 flex flex-wrap items-center gap-x-1.5 text-[13px] text-ink-2">
109 + {d.countryIso2 && <CountryFlag iso2={d.countryIso2} size={13} />}
110 + {d.city && <span>{d.city}</span>}
111 + {d.regionName && d.regionName !== d.city && <span className="text-ink-3">· {d.regionName}</span>}
112 + {cName && (
113 + <>
114 + <span className="text-ink-4">·</span>
115 + {cHref ? (
116 + <Link href={cHref} className="hover:text-ink hover:underline">
117 + {cName}
118 + </Link>
119 + ) : (
120 + <span>{cName}</span>
121 + )}
122 + </>
123 + )}
124 + {d.metro && (
125 + <>
126 + <span className="text-ink-4">·</span>
127 + <Link href={routes.market(d.metro.slug)} className="hover:text-ink hover:underline">
128 + {d.metro.name} market
129 + </Link>
130 + </>
131 + )}
132 + </p>
133 + <p className="mt-1 text-[12px] text-ink-3">Location shown at {PRECISION_LABEL[d.geoPrecision].toLowerCase().replace(" location", "")} — cloud providers publish the metro, not the building.</p>
134 + </header>
135 +
136 + <MetricRow cols={3}>
137 + <MetricCell>
138 + <Metric value={d.availabilityZones ?? "—"} label="Availability zones" note={d.availabilityZones === null ? "not published" : undefined} />
139 + </MetricCell>
140 + <MetricCell>
141 + <Metric value={fmtDate(d.launchedOn)} label="Launched" note={d.launchedOn ? undefined : "not published"} />
142 + </MetricCell>
143 + <MetricCell>
144 + <Metric value={fmtInt(siblings.length)} label={`Other ${d.provider.name} regions`} note={`${fmtInt(facilities.length)} indexed ${facilities.length === 1 ? "facility" : "facilities"} in this market`} />
145 + </MetricCell>
146 + </MetricRow>
147 +
148 + <SectionNav items={nav} className="mt-4" />
149 +
150 + <div className="mt-6 flex flex-col gap-8">
151 + <Section id="location" title="Location" description="Published location of the region; coordinates are city-level and never point at a physical data center." className="scroll-mt-28">
152 + <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
153 + <KeyValueList
154 + items={[
155 + { label: "City", value: d.city },
156 + { label: "Region", value: d.regionName },
157 + { label: "Country", value: cName ? cHref ? <Link href={cHref} className="hover:text-accent hover:underline">{cName}</Link> : cName : null },
158 + { label: "Market", value: d.metro ? <Link href={routes.market(d.metro.slug)} className="hover:text-accent hover:underline">{d.metro.name}</Link> : null },
159 + { label: "Coordinates", value: hasGeo ? <span className="figure">{d.lat!.toFixed(2)}, {d.lng!.toFixed(2)}</span> : null },
160 + { label: "Precision", value: PRECISION_LABEL[d.geoPrecision] },
161 + ]}
162 + />
163 + {hasGeo ? <EntityMap center={{ lat: d.lat!, lng: d.lng! }} precision="city" zoom={9} filters={d.countryIso2 ? { country: d.countryIso2 } : undefined} openLabel="Open market in map" className="h-[220px] md:h-full md:min-h-[260px]" /> : <EmptyState compact title="No published coordinates" description="The provider lists this region without a city we could place." />}
164 + </div>
165 + </Section>
166 +
167 + {siblings.length > 0 && (
168 + <Section id="siblings" title={`Other ${d.provider.name} regions`} description={`${siblings.length} ${siblings.length === 1 ? "region" : "regions"} indexed for this provider`} className="scroll-mt-28" aside={<Link href={providerHref} className="text-[12px] text-ink-3 hover:text-ink">All {d.provider.name} regions</Link>}>
169 + <Table dense>
170 + <THead>
171 + <tr>
172 + <Th>Code</Th>
173 + <Th>Region</Th>
174 + <Th className="hidden sm:table-cell">City</Th>
175 + <Th>Country</Th>
176 + <Th numeric className="hidden md:table-cell">
177 + AZs
178 + </Th>
179 + <Th>Status</Th>
180 + </tr>
181 + </THead>
182 + <TBody>
183 + {siblings.map((s) => (
184 + <Tr key={s.id}>
185 + <Td primary>
186 + <RowLink href={routes.cloudRegion(s.slug)} className="figure text-[12.5px]">
187 + {s.code}
188 + </RowLink>
189 + <div className="text-[11.5px] text-ink-3 sm:hidden">{s.name}</div>
190 + </Td>
191 + <Td label="Region" className="hidden text-ink-2 sm:table-cell">
192 + {s.name}
193 + </Td>
194 + <Td label="City" className="hidden text-ink-3 sm:table-cell">
195 + {s.city ?? "—"}
196 + </Td>
197 + <Td label="Country">
198 + <CountryFlag iso2={s.countryIso2} withName className="max-w-[160px] text-ink-2" />
199 + </Td>
200 + <Td label="AZs" numeric className="hidden md:table-cell">
201 + {s.availabilityZones ?? <span className="text-ink-4">—</span>}
202 + </Td>
203 + <Td label="Status">
204 + <CloudStatusBadge status={s.status} />
205 + </Td>
206 + </Tr>
207 + ))}
208 + </TBody>
209 + </Table>
210 + </Section>
211 + )}
212 +
213 + <Section id="facilities" title="Facilities in this market" description={d.metro ? `Indexed data centers in the ${d.metro.name} market — not necessarily hosting this region.` : "Indexed data centers around the published location — not necessarily hosting this region."} className="scroll-mt-28">
214 + {facilities.length ? <FacilityTable items={facilities} columns={["operator", "country", "status", "mw"]} /> : <EmptyState compact title="No indexed facility in this metro yet" description="Facilities appear as operator, registry and dataset connectors observe them." />}
215 + </Section>
216 +
217 + {ids.length > 0 && (
218 + <Section id="identifiers" title="Identifiers" description="Provider identifiers as published." className="scroll-mt-28">
219 + <KeyValueList items={ids.map(([k, v]) => ({ label: k.replace(/_/g, " "), value: <span className="figure text-[12.5px]">{String(v)}</span> }))} columns={2} />
220 + </Section>
221 + )}
222 +
223 + <Section id="source" title="Source" className="scroll-mt-28">
224 + {d.sourceUrl ? (
225 + <p className="text-[13px] text-ink-2">
226 + Provider documentation: <ExtLink href={d.sourceUrl} />
227 + </p>
228 + ) : (
229 + <p className="text-[13px] text-ink-3">No source URL recorded for this region.</p>
230 + )}
231 + </Section>
232 + </div>
233 +
234 + <SourcesFooter sources={r.sources} updatedAt={d.updatedAt} note="Region facts are taken verbatim from the provider's published region list; availability-zone counts and launch dates are shown only when published." />
235 + </Page>
236 + );
237 +}
added apps/web/src/app/(site)/cloud-regions/cloud-format.tsx +37 −0
@@ -0,0 +1,37 @@
1 +import type { CloudRegionSummary } from "@dci/core";
2 +import { Badge, type BadgeTone } from "@/components/ui/badge";
3 +import { api } from "@/lib/api";
4 +import { routes } from "@/lib/routes";
5 +
6 +export type CloudStatus = CloudRegionSummary["status"];
7 +
8 +export const CLOUD_STATUSES: CloudStatus[] = ["operational", "announced", "restricted", "retired"];
9 +
10 +export const CLOUD_STATUS_LABEL: Record<CloudStatus, string> = {
11 + operational: "Operational",
12 + announced: "Announced",
13 + restricted: "Restricted",
14 + retired: "Retired",
15 +};
16 +
17 +const TONE: Record<CloudStatus, BadgeTone> = { operational: "ok", announced: "info", restricted: "warn", retired: "neutral" };
18 +
19 +export function CloudStatusBadge({ status, size = "xs" }: { status: CloudStatus; size?: "xs" | "sm" }) {
20 + return (
21 + <Badge tone={TONE[status]} size={size} title={`Region status: ${CLOUD_STATUS_LABEL[status]}`}>
22 + {CLOUD_STATUS_LABEL[status]}
23 + </Badge>
24 + );
25 +}
26 +
27 +/** iso2 → canonical country page href (falls back to the facility index filtered by country). */
28 +export async function countryHrefMap(): Promise<(iso2: string | null | undefined) => string | null> {
29 + const c = await api.countries();
30 + const map = new Map<string, string>();
31 + for (const x of c.items) map.set(x.iso2.toUpperCase(), x.slug);
32 + return (iso2) => {
33 + if (!iso2) return null;
34 + const slug = map.get(iso2.toUpperCase());
35 + return slug ? routes.country(slug) : routes.datacenters(`country=${iso2.toUpperCase()}`);
36 + };
37 +}
added apps/web/src/app/(site)/countries/(index)/loading.tsx +19 −0
@@ -0,0 +1,19 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-16" />
8 + <Skeleton className="mb-2 h-7 w-48" />
9 + <Skeleton className="mb-6 h-3.5 w-96 max-w-full" />
10 + <div className="mb-4 flex gap-1.5">
11 + {Array.from({ length: 5 }).map((_, i) => (
12 + <Skeleton key={i} className="h-7 w-20" />
13 + ))}
14 + </div>
15 + <SkeletonRows rows={6} />
16 + <SkeletonRows rows={6} className="mt-8" />
17 + </Page>
18 + );
19 +}
added apps/web/src/app/(site)/countries/(index)/page.tsx +234 −0
@@ -0,0 +1,234 @@
1 +import type { CountrySummary } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import { EstimateMark } from "@/components/ui/badge";
5 +import { Page, PageHeader } from "@/components/ui/card";
6 +import { cn } from "@/components/ui/cn";
7 +import { CountryFlag } from "@/components/ui/country-flag";
8 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
9 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
10 +import { api } from "@/lib/api";
11 +import { fmtInt, fmtMw, fmtPct, pluralize } from "@/lib/format";
12 +import { routes } from "@/lib/routes";
13 +import { compareBy, hrefWith, parseSort, sortTh, spGet, type SearchParams, type SortState } from "@/lib/sort";
14 +
15 +export const revalidate = 120;
16 +
17 +export const metadata: Metadata = {
18 + title: "Countries",
19 + description: "Data center facilities, known and pipeline capacity, operators and cloud regions by country, grouped by world region.",
20 + alternates: { canonical: "/countries" },
21 +};
22 +
23 +const SORTS = ["name", "facilities", "mw", "pipeline", "cloud", "operators", "per_million", "hyperscale"] as const;
24 +type SortKey = (typeof SORTS)[number];
25 +const REGION_ORDER = ["Americas", "Europe", "Asia", "Oceania", "Africa"];
26 +
27 +const pipelineMw = (c: CountrySummary): number | null => (c.constructionMw === null && c.plannedMw === null ? null : (c.constructionMw ?? 0) + (c.plannedMw ?? 0));
28 +const perMillion = (c: CountrySummary): number | null => (c.population && c.facilityCount >= 5 ? c.facilityCount / (c.population / 1_000_000) : null);
29 +
30 +function sorter(s: SortState<SortKey>) {
31 + switch (s.key) {
32 + case "name":
33 + return compareBy<CountrySummary>((c) => c.name, s.order);
34 + case "mw":
35 + return compareBy<CountrySummary>((c) => c.knownMw, s.order);
36 + case "pipeline":
37 + return compareBy<CountrySummary>(pipelineMw, s.order);
38 + case "cloud":
39 + return compareBy<CountrySummary>((c) => c.cloudRegionCount, s.order);
40 + case "operators":
41 + return compareBy<CountrySummary>((c) => c.operatorCount, s.order);
42 + case "per_million":
43 + return compareBy<CountrySummary>(perMillion, s.order);
44 + case "hyperscale":
45 + return compareBy<CountrySummary>((c) => c.hyperscaleCount + c.aiCount, s.order);
46 + default:
47 + return compareBy<CountrySummary>((c) => c.facilityCount, s.order);
48 + }
49 +}
50 +
51 +function CountryTable({ rows, base, sp, sort }: { rows: CountrySummary[]; base: string; sp: SearchParams; sort: SortState<SortKey> }) {
52 + const th = (key: SortKey) => sortTh(base, sp, sort, key);
53 + return (
54 + <Table>
55 + <THead>
56 + <tr>
57 + <Th {...th("name")}>Country</Th>
58 + <Th numeric {...th("facilities")}>
59 + Facilities
60 + </Th>
61 + <Th numeric {...th("mw")}>
62 + Known MW
63 + </Th>
64 + <Th numeric {...th("pipeline")}>
65 + Pipeline
66 + </Th>
67 + <Th numeric {...th("cloud")}>
68 + Cloud regions
69 + </Th>
70 + <Th numeric className="hidden md:table-cell" {...th("operators")}>
71 + Operators
72 + </Th>
73 + <Th numeric className="hidden lg:table-cell" {...th("per_million")}>
74 + Per million
75 + </Th>
76 + <Th numeric className="hidden lg:table-cell" {...th("hyperscale")}>
77 + Hyperscale · AI
78 + </Th>
79 + </tr>
80 + </THead>
81 + <TBody>
82 + {rows.map((c) => {
83 + const pipe = pipelineMw(c);
84 + const pm = perMillion(c);
85 + return (
86 + <Tr key={c.iso2}>
87 + <Td primary>
88 + <RowLink href={routes.country(c.slug)} className="inline-flex items-center gap-2">
89 + <CountryFlag iso2={c.iso2} size={14} />
90 + {c.name}
91 + </RowLink>
92 + <div className="text-[11.5px] text-ink-3 sm:hidden">{c.subregion ?? c.region ?? ""}</div>
93 + </Td>
94 + <Td label="Facilities" numeric>
95 + {fmtInt(c.facilityCount)}
96 + </Td>
97 + <Td label="Known MW" numeric>
98 + {c.knownMw === null ? (
99 + <span className="text-ink-4" title={`No published MW figure · coverage ${fmtPct(c.mwCoverage)}`}>
100 + —
101 + </span>
102 + ) : (
103 + <span>
104 + {fmtMw(c.knownMw)}
105 + <span className="hidden text-[10.5px] text-ink-4 sm:block" title="Share of facilities with a published MW figure">
106 + {fmtPct(c.mwCoverage)} coverage
107 + </span>
108 + </span>
109 + )}
110 + </Td>
111 + <Td label="Pipeline" numeric>
112 + {pipe === null ? (
113 + <span className="text-ink-4">—</span>
114 + ) : (
115 + <span title="Under construction + planned power (lower bound)">
116 + <EstimateMark className="mr-0.5" />
117 + {fmtMw(pipe)}
118 + </span>
119 + )}
120 + </Td>
121 + <Td label="Cloud regions" numeric>
122 + {fmtInt(c.cloudRegionCount)}
123 + </Td>
124 + <Td label="Operators" numeric className="hidden md:table-cell">
125 + {fmtInt(c.operatorCount)}
126 + </Td>
127 + <Td label="Per million" numeric className="hidden lg:table-cell">
128 + {pm === null ? <span className="text-ink-4" title="Needs ≥ 5 facilities and a population figure">—</span> : pm.toFixed(2)}
129 + </Td>
130 + <Td label="Hyperscale · AI" numeric className="hidden lg:table-cell">
131 + {fmtInt(c.hyperscaleCount)} · {fmtInt(c.aiCount)}
132 + </Td>
133 + </Tr>
134 + );
135 + })}
136 + </TBody>
137 + </Table>
138 + );
139 +}
140 +
141 +export default async function CountriesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
142 + const sp = await searchParams;
143 + const base = routes.countries();
144 + const region = spGet(sp, "region");
145 + const hasSort = Boolean(spGet(sp, "sort"));
146 + const sort = parseSort(sp, SORTS, { key: "facilities", order: "desc" });
147 +
148 + const res = await api.countries();
149 + const ok = res.items.length > 0 || (await api.health()).ok;
150 + const all = res.items.filter((c) => c.facilityCount > 0 || c.cloudRegionCount > 0);
151 + const regions = Array.from(new Set(all.map((c) => c.region).filter((x): x is string => Boolean(x)))).sort((a, b) => {
152 + const ia = REGION_ORDER.indexOf(a);
153 + const ib = REGION_ORDER.indexOf(b);
154 + return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.localeCompare(b);
155 + });
156 + const filtered = (region ? all.filter((c) => c.region === region) : all).sort(sorter(sort));
157 + const totals = filtered.reduce((a, c) => ({ f: a.f + c.facilityCount, cr: a.cr + c.cloudRegionCount, mw: a.mw + (c.knownMw ?? 0), cov: a.cov + (c.knownMw !== null ? 1 : 0) }), { f: 0, cr: 0, mw: 0, cov: 0 });
158 +
159 + const chipCls = (active: boolean) => cn("inline-flex h-7 shrink-0 items-center whitespace-nowrap rounded-sm border px-2 text-[12px]", active ? "border-ink bg-ink text-paper" : "border-hair-2 bg-surface text-ink hover:bg-surface-2");
160 +
161 + return (
162 + <Page>
163 + <PageHeader
164 + eyebrow="Index"
165 + title="Countries"
166 + description={
167 + ok
168 + ? `${fmtInt(filtered.length)} ${pluralize(filtered.length, "country", "countries")} with indexed infrastructure${region ? ` in ${region}` : ""} — ${fmtInt(totals.f)} facilities, ${fmtInt(totals.cr)} cloud regions${totals.mw ? `, ${fmtMw(totals.mw)} known operational capacity` : ""}. MW totals are lower bounds from published figures; coverage is shown per country.`
169 + : "Data center facilities, capacity and cloud regions by country."
170 + }
171 + />
172 + {!ok && <OfflineNotice className="mb-3" />}
173 +
174 + <nav aria-label="Region" className="-mx-3 mb-3 flex items-center gap-1.5 overflow-x-auto px-3 no-scrollbar sm:mx-0 sm:flex-wrap sm:px-0">
175 + <Link href={hrefWith(base, sp, { region: undefined })} scroll={false} className={chipCls(!region)} aria-current={!region ? "page" : undefined}>
176 + All regions
177 + </Link>
178 + {regions.map((rg) => (
179 + <Link key={rg} href={hrefWith(base, sp, { region: rg })} scroll={false} className={chipCls(region === rg)} aria-current={region === rg ? "page" : undefined}>
180 + {rg}
181 + <span className="figure ml-1.5 text-[11px] opacity-70">{all.filter((c) => c.region === rg).length}</span>
182 + </Link>
183 + ))}
184 + {hasSort && (
185 + <Link href={hrefWith(base, sp, { sort: undefined, order: undefined })} scroll={false} className="shrink-0 px-1 text-[12px] text-ink-3 hover:text-ink">
186 + Group by region
187 + </Link>
188 + )}
189 + </nav>
190 +
191 + {filtered.length === 0 ? (
192 + <EmptyState title={ok ? "No countries match" : "Countries unavailable"} description={ok ? "Pick another region." : "The index API is not reachable right now."} compact />
193 + ) : hasSort || region ? (
194 + <CountryTable rows={filtered} base={base} sp={sp} sort={sort} />
195 + ) : (
196 + <div className="flex flex-col gap-8">
197 + {regions.map((rg) => {
198 + const rows = filtered.filter((c) => c.region === rg);
199 + if (!rows.length) return null;
200 + const f = rows.reduce((a, c) => a + c.facilityCount, 0);
201 + return (
202 + <section key={rg} aria-labelledby={`region-${rg}`}>
203 + <div className="mb-2 flex items-baseline justify-between gap-3 border-t border-hair pt-4">
204 + <h2 id={`region-${rg}`} className="text-[15px] font-semibold text-ink">
205 + {rg}
206 + </h2>
207 + <span className="figure text-[12px] text-ink-3">
208 + {fmtInt(rows.length)} {pluralize(rows.length, "country", "countries")} · {fmtInt(f)} facilities
209 + </span>
210 + </div>
211 + <CountryTable rows={rows} base={base} sp={sp} sort={sort} />
212 + </section>
213 + );
214 + })}
215 + {filtered.some((c) => !c.region) && (
216 + <section>
217 + <div className="mb-2 border-t border-hair pt-4">
218 + <h2 className="text-[15px] font-semibold text-ink">Other</h2>
219 + </div>
220 + <CountryTable rows={filtered.filter((c) => !c.region)} base={base} sp={sp} sort={sort} />
221 + </section>
222 + )}
223 + </div>
224 + )}
225 + <p className="mt-6 text-[11.5px] text-ink-4">
226 + Per-million uses the latest World Bank population vintage and requires at least 5 facilities. Known MW = IT capacity, or total power when only that is published, over operational facilities. See the{" "}
227 + <Link href={routes.methodology()} className="underline decoration-hair-2 hover:text-ink">
228 + methodology
229 + </Link>
230 + .
231 + </p>
232 + </Page>
233 + );
234 +}
added apps/web/src/app/(site)/countries/[slug]/bars.tsx +9 −0
@@ -0,0 +1,9 @@
1 +"use client";
2 +
3 +import { BarList, type BarListItem } from "@/components/ui/charts";
4 +import { fmtInt, fmtMw } from "@/lib/format";
5 +
6 +/** Server pages cannot pass `format` functions to the client BarList — pick the formatter by name here. */
7 +export function CountBarList({ items, unit = "count", showRank = true, className }: { items: BarListItem[]; unit?: "count" | "mw"; showRank?: boolean; className?: string }) {
8 + return <BarList items={items} format={unit === "mw" ? (v) => fmtMw(v) : (v) => fmtInt(v)} showRank={showRank} className={className} />;
9 +}
added apps/web/src/app/(site)/countries/[slug]/growth-chart.tsx +53 −0
@@ -0,0 +1,53 @@
1 +"use client";
2 +
3 +import type { CountryDetail } from "@dci/core";
4 +import { AreaChart } from "@/components/ui/charts";
5 +import { EmptyState } from "@/components/ui/states";
6 +import { fmtInt, fmtMw } from "@/lib/format";
7 +
8 +/**
9 + * Facilities over time (cumulative count by opening year) and, when published, cumulative known MW.
10 + * Rendered as two single-axis charts (never dual-axis): MW only when at least two years carry a figure.
11 + */
12 +export function GrowthChart({ data }: { data: CountryDetail["growth"] }) {
13 + const rows = [...data].filter((d) => Number.isFinite(d.year)).sort((a, b) => a.year - b.year);
14 + if (rows.length < 2) return <EmptyState compact title="Not enough dated openings" description="Growth charts appear once at least two opening years are published for facilities in this country." />;
15 + // fill the years between the first and last observation so the curve is continuous
16 + const first = rows[0]!.year;
17 + const last = Math.max(rows[rows.length - 1]!.year, new Date().getUTCFullYear());
18 + const byYear = new Map(rows.map((r) => [r.year, r]));
19 + const facilities: Array<{ x: number; y: number }> = [];
20 + const mw: Array<{ x: number; y: number | null }> = [];
21 + let cumMw: number | null = null;
22 + let hasMwYears = 0;
23 + for (let y = first; y <= last; y++) {
24 + const r = byYear.get(y);
25 + // API rows are already cumulative facility counts by opening year
26 + const prevF = facilities.length ? facilities[facilities.length - 1]!.y : 0;
27 + facilities.push({ x: y, y: r ? Math.max(prevF, r.facilities) : prevF });
28 + if (r && r.knownMw !== null) {
29 + cumMw = (cumMw ?? 0) + r.knownMw;
30 + hasMwYears++;
31 + }
32 + mw.push({ x: y, y: cumMw });
33 + }
34 + const span = facilities.length;
35 + const step = span > 40 ? 5 : span > 20 ? 2 : 1;
36 + const thin = <T extends { x: number }>(pts: T[]) => pts.filter((p, i) => i === pts.length - 1 || (p.x - first) % step === 0);
37 + return (
38 + <div className="grid gap-6 md:grid-cols-2">
39 + <div>
40 + <div className="label-caps mb-2">Facilities (cumulative, by opening year)</div>
41 + <AreaChart series={[{ name: "Facilities", data: thin(facilities), color: "var(--ink)" }]} height={180} format={(v) => fmtInt(v)} ariaLabel="Cumulative facilities by opening year" />
42 + </div>
43 + <div>
44 + <div className="label-caps mb-2">Known MW (cumulative, published figures)</div>
45 + {hasMwYears >= 2 ? (
46 + <AreaChart series={[{ name: "Known MW", data: thin(mw), color: "var(--accent)" }]} height={180} format={(v) => fmtMw(v)} ariaLabel="Cumulative known capacity by opening year" />
47 + ) : (
48 + <EmptyState compact title="MW not published by year" description="Operators in this country have not published enough dated capacity figures to chart." />
49 + )}
50 + </div>
51 + </div>
52 + );
53 +}
added apps/web/src/app/(site)/countries/[slug]/layout.tsx +11 −0
@@ -0,0 +1,11 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { api } from "@/lib/api";
4 +
5 +/** Existence gate — real HTTP 404 before the segment's loading.tsx streams a 200 shell (the page re-uses the cached fetch). */
6 +export default async function CountryLayout({ children, params }: { children: ReactNode; params: Promise<{ slug: string }> }) {
7 + const { slug } = await params;
8 + const r = await api.country(slug);
9 + if (!r.ok && r.status === 404) notFound();
10 + return <>{children}</>;
11 +}
added apps/web/src/app/(site)/countries/[slug]/loading.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonBlock, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-4" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-40" />
8 + <Skeleton className="mb-2 h-8 w-72 max-w-full" />
9 + <Skeleton className="mb-6 h-3.5 w-56 max-w-full" />
10 + <div className="grid grid-cols-3 gap-4 border-y border-hair py-3 sm:grid-cols-5 lg:grid-cols-9">
11 + {Array.from({ length: 9 }).map((_, i) => (
12 + <div key={i} className={`flex flex-col gap-2 ${i >= 6 ? "hidden sm:flex" : ""}`}>
13 + <Skeleton className="h-7 w-14" />
14 + <Skeleton className="h-2.5 w-20" />
15 + </div>
16 + ))}
17 + </div>
18 + <SkeletonBlock className="mt-6 h-[220px]" />
19 + <div className="mt-6 grid gap-6 md:grid-cols-2">
20 + <SkeletonBlock className="h-48" />
21 + <SkeletonRows rows={5} />
22 + </div>
23 + <SkeletonRows rows={8} className="mt-6" />
24 + </Page>
25 + );
26 +}
added apps/web/src/app/(site)/countries/[slug]/opengraph-image.tsx +27 −0
@@ -0,0 +1,27 @@
1 +import { api } from "@/lib/api";
2 +import { fmtInt, fmtMw } from "@/lib/format";
3 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
4 +
5 +export const alt = "Data centers by country on DataCenterIndex";
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
11 + const { slug } = await params;
12 + const r = await api.country(slug);
13 + if (!r.ok) return renderOg({ eyebrow: "Country", title: `Data centers in ${slug.replace(/-/g, " ")}`, subtitle: "Facilities, capacity, operators, cloud regions and projects." });
14 + const c = r.data;
15 + const pipe = c.constructionMw === null && c.plannedMw === null ? null : (c.constructionMw ?? 0) + (c.plannedMw ?? 0);
16 + return renderOg({
17 + eyebrow: `Country · ${c.iso2}`,
18 + title: `Data centers in ${c.name}`,
19 + subtitle: [c.region, `${fmtInt(c.operatorCount)} operators`, `${fmtInt(c.hyperscaleCount)} hyperscale`].filter(Boolean).join(" · "),
20 + stats: [
21 + { label: "Facilities", value: fmtInt(c.facilityCount) },
22 + { label: "Operational MW", value: c.knownMw ? fmtMw(c.knownMw) : "—" },
23 + { label: "Pipeline", value: pipe ? `≈ ${fmtMw(pipe)}` : "—" },
24 + { label: "Cloud regions", value: fmtInt(c.cloudRegionCount) },
25 + ],
26 + });
27 +}
added apps/web/src/app/(site)/countries/[slug]/page.tsx +388 −0
@@ -0,0 +1,388 @@
1 +import type { CountryDetail, FacilityType } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import { notFound } from "next/navigation";
5 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
6 +import { EntityMap } from "@/components/entity/entity-map";
7 +import { JsonLd } from "@/components/entity/json-ld";
8 +import { KeyValueList } from "@/components/entity/kv";
9 +import { fPageHref, PagedFacilities, parseFPage } from "@/components/entity/paged-facilities";
10 +import { SectionNav } from "@/components/entity/section-nav";
11 +import { SourcesFooter } from "@/components/entity/sources-footer";
12 +import { EventList } from "@/components/event-list";
13 +import { ProjectList } from "@/components/project-list";
14 +import { Badge, EstimateMark } from "@/components/ui/badge";
15 +import { Page, Section } from "@/components/ui/card";
16 +import { StackedBar } from "@/components/ui/charts";
17 +import { CountBarList } from "./bars";
18 +import { CountryFlag } from "@/components/ui/country-flag";
19 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
20 +import { EmptyState, ErrorState, OfflineNotice } from "@/components/ui/states";
21 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
22 +import { api } from "@/lib/api";
23 +import type { DetailFacilitiesMeta } from "@/lib/dto";
24 +import { fmtCompact, fmtDate, fmtInt, fmtMw, fmtPct, fmtUsd, pluralize, splitMw } from "@/lib/format";
25 +import { OPERATOR_KIND_LABEL, TYPE_LABEL } from "@/lib/labels";
26 +import { routes } from "@/lib/routes";
27 +import { compactJsonLd, NOT_FOUND_METADATA, pageMetadata } from "@/lib/seo";
28 +import type { SearchParams } from "@/lib/sort";
29 +import { GrowthChart } from "./growth-chart";
30 +
31 +export const revalidate = 300;
32 +
33 +type Props = { params: Promise<{ slug: string }>; searchParams: Promise<SearchParams> };
34 +
35 +const HUGE = new Set(["US", "CA", "RU", "CN", "AU", "BR"]);
36 +const SMALL = new Set(["SG", "HK", "LU", "MT", "BH", "QA", "AE", "IL", "NL", "BE", "CH", "IE", "DK", "TW", "KW", "CY", "EE", "LV", "LT", "SI", "PR", "IS"]);
37 +// ≥ 5 so the API returns grid clusters instead of one country-level bubble
38 +const zoomFor = (iso2: string) => (HUGE.has(iso2) ? 5 : SMALL.has(iso2) ? 7 : 5.5);
39 +
40 +function describe(c: CountryDetail): string {
41 + const bits = [`${fmtInt(c.facilityCount)} data center ${pluralize(c.facilityCount, "facility", "facilities")}`];
42 + if (c.knownMw) bits.push(`${fmtMw(c.knownMw)} known operational capacity`);
43 + const pipe = (c.constructionMw ?? 0) + (c.plannedMw ?? 0);
44 + if (pipe) bits.push(`≈ ${fmtMw(pipe)} in the pipeline`);
45 + bits.push(`${fmtInt(c.operatorCount)} ${pluralize(c.operatorCount, "operator")}`);
46 + bits.push(`${fmtInt(c.cloudRegionCount)} cloud ${pluralize(c.cloudRegionCount, "region")}`);
47 + if (c.hyperscaleCount) bits.push(`${fmtInt(c.hyperscaleCount)} hyperscale`);
48 + return `Data centers in ${c.name}: ${bits.join(", ")}. Markets, operators, cloud regions, projects and the change history — every figure sourced.`;
49 +}
50 +
51 +export async function generateMetadata({ params }: Props): Promise<Metadata> {
52 + const { slug } = await params;
53 + const r = await api.country(slug);
54 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Country", alternates: { canonical: routes.country(slug) } };
55 + return pageMetadata({ title: `Data Centers in ${r.data.name}`, description: describe(r.data), path: routes.country(r.data.slug) });
56 +}
57 +
58 +function Mw({ mw, estimate }: { mw: number | null | undefined; estimate?: boolean }) {
59 + const s = splitMw(mw);
60 + if (!s) return <span className="text-ink-4">—</span>;
61 + return (
62 + <>
63 + {estimate && <EstimateMark className="mr-0.5 text-[0.6em]" />}
64 + {s.value}
65 + </>
66 + );
67 +}
68 +const unitOf = (mw: number | null | undefined) => (splitMw(mw)?.unit ?? undefined);
69 +
70 +export default async function CountryPage({ params, searchParams }: Props) {
71 + const [{ slug }, sp] = await Promise.all([params, searchParams]);
72 + const fPage = parseFPage(sp.fPage);
73 + const r = await api.country(slug, fPage > 1 ? { fPage } : undefined);
74 + if (!r.ok && r.status === 404) notFound();
75 + if (!r.ok) {
76 + return (
77 + <Page className="py-6">
78 + <OfflineNotice detail={r.error} className="mb-4" />
79 + <ErrorState />
80 + </Page>
81 + );
82 + }
83 + const c = r.data;
84 + const meta = (r.meta ?? {}) as DetailFacilitiesMeta;
85 + const facTotal = meta.facilitiesTotal ?? c.facilityCount;
86 + const facPer = meta.facilitiesPerPage ?? 50;
87 + const base = routes.country(c.slug);
88 + const coverage = fmtPct(c.mwCoverage);
89 + const statusTotal = Object.values(c.statusBreakdown).reduce((a, b) => a + (b ?? 0), 0);
90 + const types = (Object.entries(c.typeBreakdown) as Array<[FacilityType, number]>).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]);
91 + const metros = [...c.metros].sort((a, b) => b.facilityCount - a.facilityCount);
92 + const topOps = [...c.topOperators].sort((a, b) => b.facilityCount - a.facilityCount).slice(0, 10);
93 + const cloud = [...c.cloudRegions].sort((a, b) => a.provider.name.localeCompare(b.provider.name) || a.code.localeCompare(b.code));
94 + const hasElectricity = c.renewableShare !== null || c.electricityTwh !== null;
95 +
96 + const jsonLd = compactJsonLd({
97 + "@context": "https://schema.org",
98 + "@type": "Country",
99 + name: c.name,
100 + identifier: c.iso2,
101 + url: `${routes.country(c.slug)}`,
102 + description: describe(c),
103 + geo: c.lat !== null && c.lng !== null ? { "@type": "GeoCoordinates", latitude: c.lat, longitude: c.lng } : undefined,
104 + });
105 +
106 + const sections = [
107 + { id: "map", label: "Map", count: c.facilityCount },
108 + { id: "growth", label: "Growth", count: c.growth.length },
109 + { id: "breakdown", label: "Breakdown", count: statusTotal },
110 + { id: "operators", label: "Operators", count: topOps.length },
111 + { id: "markets", label: "Markets", count: metros.length },
112 + { id: "cloud", label: "Cloud regions", count: cloud.length },
113 + { id: "projects", label: "Projects", count: c.recentProjects.length },
114 + { id: "changes", label: "Changes", count: c.recentEvents.length },
115 + { id: "electricity", label: "Electricity", count: hasElectricity ? 1 : 0 },
116 + { id: "facilities", label: "Facilities", count: facTotal },
117 + ];
118 +
119 + return (
120 + <Page>
121 + <JsonLd data={jsonLd} />
122 + <div className="pt-3">
123 + <Breadcrumbs items={[{ name: "Countries", href: routes.countries() }, { name: c.name, href: base }]} />
124 + </div>
125 +
126 + <header className="py-5 sm:py-7">
127 + <div className="label-caps">Country · {c.iso2}</div>
128 + <h1 className="mt-1.5 flex items-center gap-2.5 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[30px]">
129 + <CountryFlag iso2={c.iso2} size={26} />
130 + <span>Data centers in {c.name}</span>
131 + </h1>
132 + <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] text-ink-2">
133 + {(c.region || c.subregion) && <span>{[c.region, c.subregion].filter((v, i, a): v is string => Boolean(v) && a.indexOf(v) === i).join(" · ")}</span>}
134 + {c.population !== null && (
135 + <span className="figure text-ink-3" title="Population, latest World Bank vintage">
136 + {fmtCompact(c.population)} residents
137 + </span>
138 + )}
139 + {c.gdpUsd !== null && (
140 + <span className="figure text-ink-3" title="GDP, current US$, World Bank">
141 + {fmtUsd(c.gdpUsd)} GDP
142 + </span>
143 + )}
144 + {(c.population !== null || c.gdpUsd !== null) && <span className="text-[11px] text-ink-4">World Bank</span>}
145 + </div>
146 + </header>
147 +
148 + <MetricRow cols={5}>
149 + <MetricCell>
150 + <Metric value={fmtInt(c.facilityCount)} label="Facilities" note={`${fmtInt(c.operationalCount)} operational`} />
151 + </MetricCell>
152 + <MetricCell>
153 + <Metric value={<Mw mw={c.knownMw} />} unit={unitOf(c.knownMw)} label="Operational MW" note={c.knownMw ? `${coverage} of facilities publish MW` : `no published figure · coverage ${coverage}`} />
154 + </MetricCell>
155 + <MetricCell>
156 + <Metric value={<Mw mw={c.constructionMw} estimate />} unit={unitOf(c.constructionMw)} label="Under construction" note={`${fmtInt(c.constructionCount)} ${pluralize(c.constructionCount, "site")}`} />
157 + </MetricCell>
158 + <MetricCell>
159 + <Metric value={<Mw mw={c.plannedMw} estimate />} unit={unitOf(c.plannedMw)} label="Planned MW" note={`${fmtInt(c.plannedCount)} planned ${pluralize(c.plannedCount, "site")}`} />
160 + </MetricCell>
161 + <MetricCell>
162 + <Metric value={c.announcedInvestmentUsd ? fmtUsd(c.announcedInvestmentUsd) : "—"} label="Announced investment" note={c.announcedInvestmentUsd ? "projects, published" : "not published"} />
163 + </MetricCell>
164 + </MetricRow>
165 + <MetricRow cols={4} className="border-t-0 [&>div]:border-t-0">
166 + <MetricCell>
167 + <Metric size="sm" value={fmtInt(c.operatorCount)} label="Operators" />
168 + </MetricCell>
169 + <MetricCell>
170 + <Metric size="sm" value={fmtInt(c.cloudRegionCount)} label="Cloud regions" />
171 + </MetricCell>
172 + <MetricCell>
173 + <Metric size="sm" value={fmtInt(c.hyperscaleCount)} label="Hyperscale" />
174 + </MetricCell>
175 + <MetricCell>
176 + <Metric size="sm" value={fmtInt(c.aiCount)} label="AI facilities" />
177 + </MetricCell>
178 + </MetricRow>
179 +
180 + {c.rankings.length > 0 && (
181 + <div className="-mx-3 mt-3 flex items-center gap-1.5 overflow-x-auto px-3 no-scrollbar sm:mx-0 sm:flex-wrap sm:px-0" aria-label="Ranking positions">
182 + <span className="label-caps shrink-0">Rankings</span>
183 + {c.rankings.map((rk) => (
184 + <Link key={rk.key} href={routes.ranking(rk.key)} className="inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-sm border border-hair bg-surface px-2 text-[12px] text-ink hover:bg-surface-2">
185 + <span className="figure font-medium">
186 + #{rk.rank}
187 + <span className="text-ink-4">/{rk.total}</span>
188 + </span>
189 + <span className="text-ink-2">{rk.label}</span>
190 + </Link>
191 + ))}
192 + </div>
193 + )}
194 +
195 + <SectionNav items={sections} className="mt-4" />
196 +
197 + <div className="flex flex-col gap-8 pt-6">
198 + <Section id="map" title="Map" description="Facilities in the country; hollow rings are city-level locations. Cloud regions are listed below." className="scroll-mt-28">
199 + <EntityMap center={c.lat !== null && c.lng !== null ? { lat: c.lat, lng: c.lng } : null} zoom={zoomFor(c.iso2)} filters={{ country: c.iso2 }} openLabel={`Open ${c.name} in map`} className="h-[240px] sm:h-[380px]" />
200 + </Section>
201 +
202 + <Section id="growth" title="Growth" description="By opening year of indexed facilities. Facilities without a published opening date are not charted." className="scroll-mt-28">
203 + <GrowthChart data={c.growth} />
204 + </Section>
205 +
206 + <Section id="breakdown" title="Breakdown" description="Status and facility type of indexed records." className="scroll-mt-28">
207 + <div className="grid gap-6 md:grid-cols-2">
208 + <div>
209 + <div className="label-caps mb-2">Status</div>
210 + {statusTotal ? <StackedBar data={c.statusBreakdown} height={12} /> : <EmptyState compact title="No status data" />}
211 + </div>
212 + <div>
213 + <div className="label-caps mb-2">Type</div>
214 + {types.length ? <CountBarList items={types.map(([t, n]) => ({ label: TYPE_LABEL[t], value: n, href: routes.datacenters(`country=${c.iso2}&type=${t}`) }))} showRank={false} /> : <EmptyState compact title="No type data" />}
215 + </div>
216 + </div>
217 + </Section>
218 +
219 + {topOps.length > 0 && (
220 + <Section id="operators" title="Top operators" description={`${fmtInt(c.operatorCount)} ${pluralize(c.operatorCount, "operator")} with facilities in ${c.name}`} className="scroll-mt-28" aside={<Link href={routes.datacenters(`country=${c.iso2}`)} className="text-[12.5px] text-ink-3 hover:text-ink">All facilities →</Link>}>
221 + <CountBarList
222 + items={topOps.map((o) => ({
223 + label: o.name,
224 + value: o.facilityCount,
225 + href: routes.operator(o.slug),
226 + secondary: o.kind ? OPERATOR_KIND_LABEL[o.kind] : undefined,
227 + }))}
228 + />
229 + </Section>
230 + )}
231 +
232 + {metros.length > 0 && (
233 + <Section id="markets" title="Markets" description={`${fmtInt(metros.length)} ${pluralize(metros.length, "metro")} with indexed infrastructure`} className="scroll-mt-28">
234 + <Table dense>
235 + <THead>
236 + <tr>
237 + <Th>Market</Th>
238 + <Th numeric>Facilities</Th>
239 + <Th numeric>Known MW</Th>
240 + <Th numeric>Pipeline</Th>
241 + <Th numeric className="hidden sm:table-cell">
242 + Cloud regions
243 + </Th>
244 + <Th numeric className="hidden md:table-cell">
245 + IXPs
246 + </Th>
247 + <Th numeric className="hidden md:table-cell">
248 + Operators
249 + </Th>
250 + </tr>
251 + </THead>
252 + <TBody>
253 + {metros.map((m) => {
254 + const pipe = m.constructionMw === null && m.plannedMw === null ? null : (m.constructionMw ?? 0) + (m.plannedMw ?? 0);
255 + return (
256 + <Tr key={m.id}>
257 + <Td primary>
258 + <RowLink href={routes.market(m.slug)}>{m.name}</RowLink>
259 + {m.regionName && m.regionName !== m.name && <span className="ml-1.5 text-[11.5px] text-ink-3">{m.regionName}</span>}
260 + </Td>
261 + <Td label="Facilities" numeric>
262 + {fmtInt(m.facilityCount)}
263 + </Td>
264 + <Td label="Known MW" numeric>
265 + {m.knownMw === null ? <span className="text-ink-4">—</span> : fmtMw(m.knownMw)}
266 + </Td>
267 + <Td label="Pipeline" numeric>
268 + {pipe === null ? (
269 + <span className="text-ink-4">—</span>
270 + ) : (
271 + <>
272 + <EstimateMark className="mr-0.5" />
273 + {fmtMw(pipe)}
274 + </>
275 + )}
276 + </Td>
277 + <Td label="Cloud regions" numeric className="hidden sm:table-cell">
278 + {fmtInt(m.cloudRegionCount)}
279 + </Td>
280 + <Td label="IXPs" numeric className="hidden md:table-cell">
281 + {fmtInt(m.ixpCount)}
282 + </Td>
283 + <Td label="Operators" numeric className="hidden md:table-cell">
284 + {fmtInt(m.operatorCount)}
285 + </Td>
286 + </Tr>
287 + );
288 + })}
289 + </TBody>
290 + </Table>
291 + </Section>
292 + )}
293 +
294 + {cloud.length > 0 && (
295 + <Section id="cloud" title="Cloud regions" description={`${fmtInt(cloud.length)} public cloud ${pluralize(cloud.length, "region")} located in ${c.name}`} className="scroll-mt-28" aside={<Link href={`${routes.cloudRegions()}?country=${c.iso2}`} className="text-[12.5px] text-ink-3 hover:text-ink">All cloud regions →</Link>}>
296 + <Table dense>
297 + <THead>
298 + <tr>
299 + <Th>Provider</Th>
300 + <Th>Code</Th>
301 + <Th className="hidden sm:table-cell">Name</Th>
302 + <Th className="hidden md:table-cell">City</Th>
303 + <Th numeric>AZs</Th>
304 + <Th>Status</Th>
305 + <Th className="hidden lg:table-cell">Launched</Th>
306 + </tr>
307 + </THead>
308 + <TBody>
309 + {cloud.map((cr) => (
310 + <Tr key={cr.id}>
311 + <Td primary>
312 + <Link href={routes.operator(cr.provider.slug)} className="font-medium text-ink hover:text-accent hover:underline">
313 + {cr.provider.name}
314 + </Link>
315 + <span className="ml-1.5 text-[11.5px] text-ink-3 sm:hidden">{cr.name}</span>
316 + </Td>
317 + <Td label="Code">
318 + <RowLink href={routes.cloudRegion(cr.slug)} className="figure text-[12.5px] font-normal">
319 + {cr.code}
320 + </RowLink>
321 + </Td>
322 + <Td label="Name" className="hidden sm:table-cell">
323 + {cr.name}
324 + </Td>
325 + <Td label="City" className="hidden md:table-cell" muted>
326 + {cr.city ?? "—"}
327 + </Td>
328 + <Td label="AZs" numeric>
329 + {cr.availabilityZones === null ? <span className="text-ink-4">—</span> : fmtInt(cr.availabilityZones)}
330 + </Td>
331 + <Td label="Status">
332 + <span className="inline-flex flex-wrap gap-1">
333 + <Badge tone={cr.status === "operational" ? "ok" : cr.status === "announced" ? "info" : cr.status === "retired" ? "neutral" : "warn"} size="xs">
334 + {cr.status}
335 + </Badge>
336 + {cr.isSovereign && (
337 + <Badge tone="outline" size="xs" title="Sovereign cloud region">
338 + Sovereign
339 + </Badge>
340 + )}
341 + </span>
342 + </Td>
343 + <Td label="Launched" className="figure hidden text-[12px] text-ink-3 lg:table-cell">
344 + {cr.launchedOn ? fmtDate(cr.launchedOn) : "—"}
345 + </Td>
346 + </Tr>
347 + ))}
348 + </TBody>
349 + </Table>
350 + </Section>
351 + )}
352 +
353 + {c.recentProjects.length > 0 && (
354 + <Section id="projects" title="Recent projects" description="Announced and under-construction developments." className="scroll-mt-28" aside={<Link href={routes.projects(`country=${c.iso2}`)} className="text-[12.5px] text-ink-3 hover:text-ink">All projects →</Link>}>
355 + <ProjectList items={c.recentProjects} />
356 + </Section>
357 + )}
358 +
359 + {c.recentEvents.length > 0 && (
360 + <Section id="changes" title="Recent changes" description="Latest detections for facilities and projects in the country." className="scroll-mt-28" aside={<Link href={routes.live(`country=${c.iso2}`)} className="text-[12.5px] text-ink-3 hover:text-ink">Live feed →</Link>}>
361 + <EventList events={c.recentEvents.slice(0, 12)} dense />
362 + </Section>
363 + )}
364 +
365 + {hasElectricity && (
366 + <Section id="electricity" title="Electricity context" description="National indicators for reading capacity figures against the grid." className="scroll-mt-28">
367 + <KeyValueList
368 + items={[
369 + { label: "Renewable share", value: c.renewableShare !== null ? <span className="figure">{fmtPct(c.renewableShare / 100, 1)} of electricity output</span> : null },
370 + { label: "Electricity", value: c.electricityTwh !== null ? <span className="figure">{fmtInt(c.electricityTwh)} TWh / year</span> : null },
371 + { label: "Known DC load", value: c.knownMw && c.electricityTwh ? <span className="figure" title="Known operational MW × 8 760 h ÷ national electricity output (upper bound: assumes full utilisation)">≤ {fmtPct((c.knownMw * 8.76) / 1000 / c.electricityTwh, 2)} of output at full load</span> : null },
372 + ]}
373 + />
374 + <p className="mt-2 text-[11.5px] text-ink-4">
375 + Source: World Bank, World Development Indicators (CC BY 4.0) — latest available vintage.
376 + </p>
377 + </Section>
378 + )}
379 +
380 + <Section id="facilities" title="Facilities" description={`${fmtInt(facTotal)} indexed · sorted by known capacity`} className="scroll-mt-28" aside={<Link href={routes.datacenters(`country=${c.iso2}`)} className="text-[12.5px] text-ink-3 hover:text-ink">Filter & export →</Link>}>
381 + <PagedFacilities items={c.facilities} total={facTotal} page={fPage} perPage={facPer} hrefFor={(p) => fPageHref(base, sp, p)} columns={["operator", "market", "status", "mw", "type", "confidence"]} />
382 + </Section>
383 + </div>
384 +
385 + <SourcesFooter sources={r.sources} note="Country totals are lower bounds computed from published or filed values only; coverage shows the share of facilities with a capacity figure. Population, GDP and electricity: World Bank (CC BY 4.0)." />
386 + </Page>
387 + );
388 +}
added apps/web/src/app/(site)/datacenters/(index)/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-24" />
8 + <Skeleton className="mb-6 h-7 w-64" />
9 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
10 + {Array.from({ length: 4 }).map((_, i) => (
11 + <div key={i} className="flex flex-col gap-2">
12 + <Skeleton className="h-7 w-20" />
13 + <Skeleton className="h-2.5 w-24" />
14 + </div>
15 + ))}
16 + </div>
17 + <SkeletonRows rows={8} className="mt-6" />
18 + </Page>
19 + );
20 +}
renamed apps/web/src/app/(site)/datacenters/page.tsx → apps/web/src/app/(site)/datacenters/(index)/page.tsx +0 −0
added apps/web/src/app/(site)/datacenters/[slug]/data.ts +11 −0
@@ -0,0 +1,11 @@
1 +import type { FacilityDetail } from "@dci/core";
2 +import { cache } from "react";
3 +import { api, type ApiResult } from "@/lib/api";
4 +
5 +/** Per-request memoised facility loader shared by layout (existence check), metadata, page and OG image. */
6 +export const getFacility = cache(async (slug: string): Promise<ApiResult<FacilityDetail>> => api.facility(slug));
7 +
8 +/** The API returns `mwIsEstimate` (not yet in the shared contract). */
9 +export function mwIsEstimate(f: FacilityDetail): boolean {
10 + return Boolean((f as FacilityDetail & { mwIsEstimate?: boolean | null }).mwIsEstimate);
11 +}
added apps/web/src/app/(site)/datacenters/[slug]/layout.tsx +15 −0
@@ -0,0 +1,15 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { getFacility } from "./data";
4 +
5 +/**
6 + * Existence gate for /datacenters/[slug]: the API answers 404 for unknown (or merged-away) slugs, so the segment
7 + * layout resolves the record before any loading boundary of this segment renders — a real 404, not a soft one.
8 + * Other API failures fall through to the page, which renders an offline state.
9 + */
10 +export default async function FacilityLayout({ children, params }: { children: ReactNode; params: Promise<{ slug: string }> }) {
11 + const { slug } = await params;
12 + const r = await getFacility(slug);
13 + if (!r.ok && r.status === 404) notFound();
14 + return <>{children}</>;
15 +}
added apps/web/src/app/(site)/datacenters/[slug]/loading.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonBlock, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-4" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-40" />
8 + <Skeleton className="mb-2 h-3 w-24" />
9 + <Skeleton className="mb-3 h-7 w-3/4 max-w-[480px]" />
10 + <div className="mb-5 flex gap-2">
11 + <Skeleton className="h-5 w-24" />
12 + <Skeleton className="h-5 w-20" />
13 + <Skeleton className="h-5 w-16" />
14 + </div>
15 + <div className="grid grid-cols-3 gap-4 border-y border-hair py-3 sm:grid-cols-6">
16 + {Array.from({ length: 6 }).map((_, i) => (
17 + <div key={i} className="flex flex-col gap-2">
18 + <Skeleton className="h-7 w-16" />
19 + <Skeleton className="h-2.5 w-20" />
20 + </div>
21 + ))}
22 + </div>
23 + <div className="mt-6 grid gap-4 md:grid-cols-2">
24 + <SkeletonRows rows={5} />
25 + <SkeletonBlock className="h-[220px]" />
26 + </div>
27 + <SkeletonRows rows={6} className="mt-8" />
28 + </Page>
29 + );
30 +}
added apps/web/src/app/(site)/datacenters/[slug]/opengraph-image.tsx +29 −0
@@ -0,0 +1,29 @@
1 +import { bestMw, fmtMw, placeLine } from "@/lib/format";
2 +import { STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";
3 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
4 +import { getFacility, mwIsEstimate } from "./data";
5 +
6 +export const alt = "Data center profile on DataCenterIndex";
7 +export const size = OG_SIZE;
8 +export const contentType = OG_CONTENT_TYPE;
9 +export const revalidate = 3600;
10 +
11 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
12 + const { slug } = await params;
13 + const r = await getFacility(slug);
14 + if (!r.ok) return renderOg({ eyebrow: "Data center", title: "DataCenterIndex", subtitle: "The global index of data center infrastructure." });
15 + const f = r.data;
16 + const { mw, kind } = bestMw(f);
17 + const mwLabel = kind === "it" ? "IT capacity" : kind === "total" ? "Total power" : kind === "planned" ? "Planned power" : "Capacity";
18 + return renderOg({
19 + eyebrow: "Data center",
20 + title: f.name,
21 + subtitle: [f.operator?.name, placeLine(f)].filter((s) => s && s !== "—").join(" · "),
22 + stats: [
23 + { label: mwLabel, value: mw === null ? "—" : `${mwIsEstimate(f) || kind === "planned" ? "≈ " : ""}${fmtMw(mw)}` },
24 + { label: "Status", value: STATUS_LABEL[f.status] },
25 + { label: "Type", value: TYPE_LABEL[f.facilityType] },
26 + { label: "Country", value: f.countryName ?? f.countryIso2 ?? "—" },
27 + ],
28 + });
29 +}
added apps/web/src/app/(site)/datacenters/[slug]/page.tsx +685 −0
@@ -0,0 +1,685 @@
1 +import type { FacilityDetail, FacilitySummary } from "@dci/core";
2 +import { ExternalLink } from "lucide-react";
3 +import type { Metadata } from "next";
4 +import Link from "next/link";
5 +import { Breadcrumbs, type Crumb } from "@/components/entity/breadcrumbs";
6 +import { EntityMap } from "@/components/entity/entity-map";
7 +import { JsonLd } from "@/components/entity/json-ld";
8 +import { ExtLink, KeyValueList, NotPublished } from "@/components/entity/kv";
9 +import { ProvenanceTable, WithProvenance, provFor } from "@/components/entity/provenance";
10 +import { SectionNav } from "@/components/entity/section-nav";
11 +import { SourcesFooter } from "@/components/entity/sources-footer";
12 +import { EventList } from "@/components/event-list";
13 +import { ProjectList } from "@/components/project-list";
14 +import { Badge, ConfidenceBadge, EstimateMark, FlagBadges, PrecisionBadge, StatusBadge, TypeBadge } from "@/components/ui/badge";
15 +import { Page, Section } from "@/components/ui/card";
16 +import { CountryFlag } from "@/components/ui/country-flag";
17 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
18 +import { ProvenancePopover } from "@/components/ui/popover";
19 +import { ErrorState, OfflineNotice } from "@/components/ui/states";
20 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
21 +import { SourceHistory } from "@/components/ui/timeline";
22 +import { countryHref } from "@/lib/countries";
23 +import { bestMw, fmtDate, fmtInt, fmtMw, placeLine } from "@/lib/format";
24 +import { CONFIDENCE_LABEL, PRECISION_LABEL, STATUS_LABEL, TYPE_LABEL, isImprecise } from "@/lib/labels";
25 +import { routes } from "@/lib/routes";
26 +import { NOT_FOUND_METADATA, absoluteUrl, compactJsonLd, pageMetadata } from "@/lib/seo";
27 +import { getFacility, mwIsEstimate } from "./data";
28 +
29 +export const revalidate = 300;
30 +
31 +type Params = { params: Promise<{ slug: string }> };
32 +
33 +function describe(f: FacilityDetail): string {
34 + const { mw, kind } = bestMw(f);
35 + const parts = [`${f.name} — ${TYPE_LABEL[f.facilityType].toLowerCase()} data center`];
36 + if (f.operator) parts[0] += ` operated by ${f.operator.name}`;
37 + const place = placeLine(f);
38 + if (place !== "—") parts[0] += ` in ${place}`;
39 + parts.push(`Status: ${STATUS_LABEL[f.status].toLowerCase()}.`);
40 + if (mw !== null) parts.push(`${kind === "it" ? "IT capacity" : kind === "total" ? "Total power" : "Planned power"}: ${mwIsEstimate(f) ? "≈ " : ""}${fmtMw(mw)}.`);
41 + if (f.openedOn) parts.push(`Opened ${fmtDate(f.openedOn)}.`);
42 + parts.push("Sourced, versioned and tracked on DataCenterIndex.");
43 + return parts.join(" ");
44 +}
45 +
46 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
47 + const { slug } = await params;
48 + const r = await getFacility(slug);
49 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Data center", robots: { index: false } };
50 + const f = r.data;
51 + const suffix = f.city ? ` (${f.city}${f.countryIso2 ? `, ${f.countryIso2}` : ""})` : "";
52 + return pageMetadata({ title: `${f.name}${suffix}`, description: describe(f), path: routes.facility(f.slug) });
53 +}
54 +
55 +/* ------------------------------------------------------------------ helpers */
56 +
57 +function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
58 + const R = 6371;
59 + const dLat = ((b.lat - a.lat) * Math.PI) / 180;
60 + const dLng = ((b.lng - a.lng) * Math.PI) / 180;
61 + const s = Math.sin(dLat / 2) ** 2 + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
62 + return 2 * R * Math.asin(Math.sqrt(s));
63 +}
64 +
65 +const EXTERNAL_ID_LINKS: Record<string, { label: string; href: (v: string) => string }> = {
66 + wikidata: { label: "Wikidata", href: (v) => `https://www.wikidata.org/wiki/${v}` },
67 + wikipedia_en: { label: "Wikipedia", href: (v) => (v.startsWith("http") ? v : `https://en.wikipedia.org/wiki/${v}`) },
68 + peeringdb_fac: { label: "PeeringDB facility", href: (v) => `https://www.peeringdb.com/fac/${v}` },
69 + peeringdb: { label: "PeeringDB", href: (v) => `https://www.peeringdb.com/fac/${v}` },
70 + osm: { label: "OpenStreetMap", href: (v) => (v.startsWith("http") ? v : `https://www.openstreetmap.org/${v.replace(/^([nwr])(\d+)$/, (_, t: string, id: string) => `${t === "n" ? "node" : t === "w" ? "way" : "relation"}/${id}`)}`) },
71 + osm_way: { label: "OpenStreetMap way", href: (v) => `https://www.openstreetmap.org/way/${v}` },
72 + osm_node: { label: "OpenStreetMap node", href: (v) => `https://www.openstreetmap.org/node/${v}` },
73 +};
74 +
75 +function externalIdRows(ids: Record<string, string | number>) {
76 + return Object.entries(ids)
77 + .filter(([, v]) => v !== null && v !== undefined && v !== "")
78 + .map(([k, v]) => {
79 + const spec = EXTERNAL_ID_LINKS[k];
80 + const value = String(v);
81 + const label = spec?.label ?? k.replace(/_/g, " ");
82 + return { label, value: spec ? <ExtLink href={spec.href(value)}>{value.startsWith("http") ? decodeURIComponent(value.split("/").filter(Boolean).pop() ?? value).replace(/_/g, " ") : value}</ExtLink> : <span className="figure text-[12.5px]">{value}</span> };
83 + });
84 +}
85 +
86 +function completenessTone(c: number): string {
87 + return c >= 70 ? "bg-ok" : c >= 40 ? "bg-ink-3" : "bg-warn";
88 +}
89 +
90 +/* ------------------------------------------------------------------ page */
91 +
92 +export default async function FacilityPage({ params }: Params) {
93 + const { slug } = await params;
94 + const r = await getFacility(slug);
95 +
96 + if (!r.ok) {
97 + return (
98 + <Page className="py-6">
99 + <Breadcrumbs items={[{ name: "Data centers", href: routes.datacenters() }, { name: slug, href: routes.facility(slug) }]} />
100 + <OfflineNotice detail={r.error} className="mt-4" />
101 + <ErrorState className="mt-4" detail={r.error} />
102 + </Page>
103 + );
104 + }
105 +
106 + const f = r.data;
107 + const prov = f.provenance ?? [];
108 + const est = mwIsEstimate(f);
109 + const hasGeo = f.lat !== null && f.lng !== null;
110 + const imprecise = isImprecise(f.geoPrecision);
111 + const anyMw = f.itCapacityMw !== null || f.totalPowerMw !== null || f.plannedPowerMw !== null;
112 + const cHref = await countryHref(f.countryIso2, f.countryName);
113 + const place = placeLine(f);
114 +
115 + const crumbs: Crumb[] = [{ name: "Data centers", href: routes.datacenters() }];
116 + if (f.metro) crumbs.push({ name: f.metro.name, href: routes.market(f.metro.slug) });
117 + else if (cHref && f.countryName) crumbs.push({ name: f.countryName, href: cHref });
118 + crumbs.push({ name: f.name, href: routes.facility(f.slug) });
119 +
120 + const nearby = (f.nearby ?? []).map((n: FacilitySummary) => ({ ...n, km: hasGeo && n.lat !== null && n.lng !== null ? haversineKm({ lat: f.lat!, lng: f.lng! }, { lat: n.lat, lng: n.lng }) : null }));
121 + const connectivityEmpty = !f.carriers.length && !f.cloudProviders.length && !f.ixps.length && !f.carriersCount && !f.ixpCount && !f.networksCount;
122 +
123 + const sections = [
124 + { id: "overview", label: "Overview" },
125 + { id: "location", label: "Location" },
126 + { id: "connectivity", label: "Connectivity" },
127 + { id: "cloud", label: "Cloud regions", count: f.cloudRegions.length },
128 + { id: "projects", label: "Projects", count: f.projects.length },
129 + { id: "nearby", label: "Nearby", count: nearby.length },
130 + { id: "provenance", label: "Provenance", count: prov.length },
131 + { id: "history", label: "Source history", count: f.sourceHistory.length },
132 + { id: "changes", label: "Changes", count: f.events.length },
133 + ];
134 +
135 + const jsonLd = compactJsonLd({
136 + "@context": "https://schema.org",
137 + "@type": "Place",
138 + "@id": `${absoluteUrl(routes.facility(f.slug))}#place`,
139 + name: f.name,
140 + alternateName: f.aliases.length ? f.aliases : undefined,
141 + description: f.description ?? undefined,
142 + url: absoluteUrl(routes.facility(f.slug)),
143 + sameAs: [f.website, ...Object.entries(f.externalIds).map(([k, v]) => (EXTERNAL_ID_LINKS[k] ? EXTERNAL_ID_LINKS[k].href(String(v)) : null))].filter(Boolean),
144 + address: compactJsonLd({
145 + "@type": "PostalAddress",
146 + streetAddress: f.address ?? undefined,
147 + addressLocality: f.city ?? undefined,
148 + addressRegion: f.regionName ?? undefined,
149 + postalCode: f.postalCode ?? undefined,
150 + addressCountry: f.countryIso2 ?? undefined,
151 + }),
152 + geo: hasGeo && f.geoPrecision !== "unknown" ? { "@type": "GeoCoordinates", latitude: f.lat, longitude: f.lng } : undefined,
153 + containedInPlace: f.metro ? { "@type": "Place", name: f.metro.name, url: absoluteUrl(routes.market(f.metro.slug)) } : undefined,
154 + additionalProperty: [
155 + { "@type": "PropertyValue", name: "status", value: STATUS_LABEL[f.status] },
156 + { "@type": "PropertyValue", name: "facilityType", value: TYPE_LABEL[f.facilityType] },
157 + f.itCapacityMw !== null ? { "@type": "PropertyValue", name: "IT capacity", value: f.itCapacityMw, unitText: "MW" } : null,
158 + f.totalPowerMw !== null ? { "@type": "PropertyValue", name: "Total power", value: f.totalPowerMw, unitText: "MW" } : null,
159 + { "@type": "PropertyValue", name: "geoPrecision", value: PRECISION_LABEL[f.geoPrecision] },
160 + ].filter(Boolean),
161 + });
162 +
163 + return (
164 + <Page className="pb-8">
165 + <JsonLd data={jsonLd} />
166 + <div className="pt-3">
167 + <Breadcrumbs items={crumbs} />
168 + </div>
169 +
170 + {/* ---------------------------------------------------------------- header */}
171 + <header className="py-4 sm:py-6">
172 + <div className="label-caps mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
173 + <span>Data center</span>
174 + {f.operator && (
175 + <>
176 + <span aria-hidden className="text-ink-4">
177 + ·
178 + </span>
179 + <Link href={routes.operator(f.operator.slug)} className="normal-case tracking-normal text-[12.5px] font-medium text-ink-2 hover:text-accent hover:underline">
180 + {f.operator.name}
181 + </Link>
182 + </>
183 + )}
184 + {f.owner && f.owner.id !== f.operator?.id && (
185 + <>
186 + <span aria-hidden className="text-ink-4">
187 + ·
188 + </span>
189 + <span className="normal-case tracking-normal text-[12px] text-ink-3">
190 + owned by{" "}
191 + <Link href={routes.operator(f.owner.slug)} className="text-ink-2 hover:text-accent hover:underline">
192 + {f.owner.name}
193 + </Link>
194 + </span>
195 + </>
196 + )}
197 + </div>
198 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[30px]">
199 + <WithProvenance items={prov} fields="name">
200 + {f.name}
201 + </WithProvenance>
202 + </h1>
203 + {place !== "—" && (
204 + <p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-[13.5px] text-ink-2">
205 + <CountryFlag iso2={f.countryIso2} size={13} />
206 + <span>{f.city}</span>
207 + {f.metro && (
208 + <>
209 + <span aria-hidden className="text-ink-4">
210 + ·
211 + </span>
212 + <Link href={routes.market(f.metro.slug)} className="hover:text-accent hover:underline">
213 + {f.metro.name} market
214 + </Link>
215 + </>
216 + )}
217 + {f.countryName && (
218 + <>
219 + <span aria-hidden className="text-ink-4">
220 + ·
221 + </span>
222 + {cHref ? (
223 + <Link href={cHref} className="hover:text-accent hover:underline">
224 + {f.countryName}
225 + </Link>
226 + ) : (
227 + <span>{f.countryName}</span>
228 + )}
229 + </>
230 + )}
231 + </p>
232 + )}
233 + <div className="mt-3 flex flex-wrap items-center gap-1.5">
234 + <WithProvenance items={prov} fields="status">
235 + <StatusBadge status={f.status} />
236 + </WithProvenance>
237 + <WithProvenance items={prov} fields={["facilityType", "facility_type"]}>
238 + <TypeBadge type={f.facilityType} />
239 + </WithProvenance>
240 + <FlagBadges isAi={f.isAi} isHyperscale={f.isHyperscale} size="sm" />
241 + <ConfidenceBadge level={f.confidence} short={false} />
242 + <span className="inline-flex h-[22px] items-center gap-1.5 rounded-xs border border-hair-2 px-2 text-[11.5px] text-ink-2" title={`Completeness ${f.completeness}/100 — share of tracked fields with a sourced value`}>
243 + <span className="inline-block h-[4px] w-10 overflow-hidden rounded-[1px] bg-surface-3" aria-hidden>
244 + <span className={`block h-full ${completenessTone(f.completeness)}`} style={{ width: `${Math.max(2, Math.min(100, f.completeness))}%` }} />
245 + </span>
246 + <span className="figure">{f.completeness}%</span>
247 + <span className="text-ink-3">complete</span>
248 + </span>
249 + <WithProvenance items={prov} fields={["geoPrecision", "geo_precision", "lat", "lng", "geo", "coordinates"]}>
250 + <PrecisionBadge precision={f.geoPrecision} short={false} />
251 + </WithProvenance>
252 + </div>
253 + {f.aliases.length > 0 && (
254 + <p className="mt-2 text-[12px] text-ink-3">
255 + Also known as <span className="text-ink-2">{f.aliases.slice(0, 6).join(", ")}</span>
256 + {f.aliases.length > 6 && ` and ${f.aliases.length - 6} more`}
257 + </p>
258 + )}
259 + </header>
260 +
261 + {/* ---------------------------------------------------------------- metrics */}
262 + <MetricRow cols={anyMw ? 4 + [f.buildingSqm, f.rackCount, f.pue].filter((v) => v !== null).length : 2 + [f.buildingSqm, f.rackCount, f.pue].filter((v) => v !== null).length}>
263 + {anyMw ? (
264 + <>
265 + <MetricCell>
266 + <Metric
267 + label={
268 + <WithProvenance items={prov} fields={["itCapacityMw", "it_capacity_mw"]}>
269 + IT capacity
270 + </WithProvenance>
271 + }
272 + value={f.itCapacityMw === null ? "—" : `${est ? "≈ " : ""}${fmtMw(f.itCapacityMw).replace(/ (MW|GW)$/, "")}`}
273 + unit={f.itCapacityMw === null ? undefined : fmtMw(f.itCapacityMw).endsWith("GW") ? "GW" : "MW"}
274 + note={f.itCapacityMw === null ? "not published" : est ? "estimate" : undefined}
275 + />
276 + </MetricCell>
277 + <MetricCell>
278 + <Metric
279 + label={
280 + <WithProvenance items={prov} fields={["totalPowerMw", "total_power_mw"]}>
281 + Total power
282 + </WithProvenance>
283 + }
284 + value={f.totalPowerMw === null ? "—" : `${est ? "≈ " : ""}${fmtMw(f.totalPowerMw).replace(/ (MW|GW)$/, "")}`}
285 + unit={f.totalPowerMw === null ? undefined : fmtMw(f.totalPowerMw).endsWith("GW") ? "GW" : "MW"}
286 + note={f.totalPowerMw === null ? "not published" : est ? "estimate" : undefined}
287 + />
288 + </MetricCell>
289 + <MetricCell>
290 + <Metric
291 + label={
292 + <WithProvenance items={prov} fields={["plannedPowerMw", "planned_power_mw"]}>
293 + Planned power
294 + </WithProvenance>
295 + }
296 + value={
297 + f.plannedPowerMw === null ? (
298 + "—"
299 + ) : (
300 + <>
301 + <EstimateMark className="mr-1 text-[0.7em]" />
302 + {fmtMw(f.plannedPowerMw).replace(/ (MW|GW)$/, "")}
303 + </>
304 + )
305 + }
306 + unit={f.plannedPowerMw === null ? undefined : fmtMw(f.plannedPowerMw).endsWith("GW") ? "GW" : "MW"}
307 + note={f.plannedPowerMw === null ? "not published" : "planned figure"}
308 + />
309 + </MetricCell>
310 + </>
311 + ) : (
312 + <MetricCell>
313 + <Metric label="Capacity" value="—" note="MW not published by any source" />
314 + </MetricCell>
315 + )}
316 + {f.buildingSqm !== null && (
317 + <MetricCell>
318 + <Metric
319 + label={
320 + <WithProvenance items={prov} fields={["buildingSqm", "building_sqm"]}>
321 + Building
322 + </WithProvenance>
323 + }
324 + value={fmtInt(f.buildingSqm)}
325 + unit="m²"
326 + />
327 + </MetricCell>
328 + )}
329 + {f.rackCount !== null && (
330 + <MetricCell>
331 + <Metric
332 + label={
333 + <WithProvenance items={prov} fields={["rackCount", "rack_count"]}>
334 + Racks
335 + </WithProvenance>
336 + }
337 + value={fmtInt(f.rackCount)}
338 + />
339 + </MetricCell>
340 + )}
341 + {f.pue !== null && (
342 + <MetricCell>
343 + <Metric
344 + label={
345 + <WithProvenance items={prov} fields="pue">
346 + PUE
347 + </WithProvenance>
348 + }
349 + value={f.pue.toFixed(2)}
350 + />
351 + </MetricCell>
352 + )}
353 + <MetricCell>
354 + <Metric
355 + label={
356 + <WithProvenance items={prov} fields={["openedOn", "opened_on"]}>
357 + Opened
358 + </WithProvenance>
359 + }
360 + value={f.openedOn ? fmtDate(f.openedOn) : "—"}
361 + size="sm"
362 + note={f.openedOn ? undefined : f.status === "operational" ? "date not published" : STATUS_LABEL[f.status].toLowerCase()}
363 + />
364 + </MetricCell>
365 + </MetricRow>
366 +
367 + <div className="mt-4">
368 + <SectionNav items={sections} />
369 + </div>
370 +
371 + <div className="flex flex-col gap-8 pt-6">
372 + {/* ------------------------------------------------------------ overview */}
373 + <Section id="overview" title="Overview" className="scroll-mt-28">
374 + <div className="grid gap-6 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
375 + <div className="min-w-0">
376 + {f.description ? (
377 + <p className="text-[14px] leading-relaxed text-ink-2">
378 + <WithProvenance items={prov} fields="description">
379 + <span>{f.description}</span>
380 + </WithProvenance>
381 + </p>
382 + ) : (
383 + <NotPublished what="Description" />
384 + )}
385 + {f.certifications.length > 0 && (
386 + <div className="mt-4">
387 + <div className="label-caps mb-1.5 flex items-center gap-1">
388 + Certifications <ProvenancePopover items={provFor(prov, "certifications")} size={12} />
389 + </div>
390 + <div className="flex flex-wrap gap-1.5">
391 + {f.certifications.map((c) => (
392 + <Badge key={c} tone="outline">
393 + {c}
394 + </Badge>
395 + ))}
396 + </div>
397 + </div>
398 + )}
399 + </div>
400 + <KeyValueList
401 + items={[
402 + { label: "Operator", value: f.operator ? <Link href={routes.operator(f.operator.slug)} className="hover:text-accent hover:underline">{f.operator.name}</Link> : null },
403 + { label: "Owner", value: f.owner && f.owner.id !== f.operator?.id ? <Link href={routes.operator(f.owner.slug)} className="hover:text-accent hover:underline">{f.owner.name}</Link> : null },
404 + { label: "Campus", value: f.campus ? <Link href={routes.facility(f.campus.slug)} className="hover:text-accent hover:underline">{f.campus.name}</Link> : null },
405 + { label: "Tier", value: f.tier ? <WithProvenance items={prov} fields="tier">{f.tier}</WithProvenance> : null },
406 + { label: "Cooling", value: f.coolingType ? <WithProvenance items={prov} fields={["coolingType", "cooling_type"]}>{f.coolingType}</WithProvenance> : null },
407 + { label: "Renewable", value: f.renewableClaim ? <WithProvenance items={prov} fields={["renewableClaim", "renewable_claim"]}>{f.renewableClaim}</WithProvenance> : null },
408 + { label: "Site area", value: f.siteAreaHa !== null ? <WithProvenance items={prov} fields={["siteAreaHa", "site_area_ha"]}><span className="figure">{fmtInt(f.siteAreaHa)} ha</span></WithProvenance> : null },
409 + { label: "Announced", value: f.announcedOn ? <WithProvenance items={prov} fields={["announcedOn", "announced_on"]}><span className="figure">{fmtDate(f.announcedOn)}</span></WithProvenance> : null },
410 + { label: "Construction", value: f.constructionStartedOn ? <WithProvenance items={prov} fields={["constructionStartedOn", "construction_started_on"]}><span className="figure">started {fmtDate(f.constructionStartedOn)}</span></WithProvenance> : null },
411 + { label: "Website", value: f.website ? <WithProvenance items={prov} fields="website"><ExtLink href={f.website} /></WithProvenance> : null },
412 + ...externalIdRows(f.externalIds),
413 + { label: "First indexed", value: <span className="figure text-[12.5px] text-ink-2">{fmtDate(f.firstSeen)}</span> },
414 + { label: "Last verified", value: f.lastVerified ? <span className="figure text-[12.5px] text-ink-2">{fmtDate(f.lastVerified)}</span> : <span className="text-[12.5px] text-ink-3">no primary-source verification yet</span>, keepEmpty: true },
415 + ]}
416 + />
417 + </div>
418 + </Section>
419 +
420 + {/* ------------------------------------------------------------ location */}
421 + <Section id="location" title="Location" className="scroll-mt-28" aside={<PrecisionBadge precision={f.geoPrecision} short={false} size="xs" />}>
422 + <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
423 + <div className="min-w-0">
424 + <KeyValueList
425 + items={[
426 + { label: "Address", value: f.address ? <WithProvenance items={prov} fields="address">{f.address}</WithProvenance> : null },
427 + { label: "Postal code", value: f.postalCode ? <span className="figure">{f.postalCode}</span> : null },
428 + { label: "City", value: f.city ? <WithProvenance items={prov} fields="city">{f.city}</WithProvenance> : null },
429 + { label: "Region", value: f.regionName ? <WithProvenance items={prov} fields={["regionName", "region_name"]}>{f.regionName}</WithProvenance> : null },
430 + { label: "Market", value: f.metro ? <Link href={routes.market(f.metro.slug)} className="hover:text-accent hover:underline">{f.metro.name}</Link> : null },
431 + {
432 + label: "Country",
433 + value: f.countryIso2 ? (
434 + <WithProvenance items={prov} fields={["countryIso2", "country_iso2"]}>
435 + <span className="inline-flex items-center gap-1.5">
436 + <CountryFlag iso2={f.countryIso2} size={13} />
437 + {cHref ? <Link href={cHref} className="hover:text-accent hover:underline">{f.countryName ?? f.countryIso2}</Link> : <span>{f.countryName ?? f.countryIso2}</span>}
438 + </span>
439 + </WithProvenance>
440 + ) : null,
441 + },
442 + {
443 + label: "Coordinates",
444 + value: hasGeo ? (
445 + <WithProvenance items={prov} fields={["lat", "lng", "geo", "coordinates", "geoPrecision"]}>
446 + <span className="figure text-[12.5px]">
447 + {f.lat!.toFixed(4)}, {f.lng!.toFixed(4)}
448 + </span>
449 + </WithProvenance>
450 + ) : null,
451 + },
452 + { label: "Geo source", value: f.geoSource ? <span className="figure text-[12px] text-ink-3">{f.geoSource}</span> : null },
453 + ]}
454 + />
455 + {!f.address && !hasGeo && <NotPublished what="Address and coordinates" className="mt-2" />}
456 + {hasGeo && imprecise && (
457 + <p className="mt-3 rounded-sm border border-dashed border-hair-2 px-3 py-2 text-[12.5px] text-ink-3">
458 + <span className="font-medium text-ink-2">Location shown at {PRECISION_LABEL[f.geoPrecision].toLowerCase().replace(" location", "")}.</span> The exact site has not been published by the sources indexed; the ring marks the area, not the building.
459 + </p>
460 + )}
461 + </div>
462 + {hasGeo ? (
463 + <EntityMap center={{ lat: f.lat!, lng: f.lng! }} precision={f.geoPrecision} filters={f.countryIso2 ? { country: f.countryIso2 } : undefined} className="h-[220px] sm:h-[300px]" openLabel="Open in map" />
464 + ) : (
465 + <div className="flex min-h-[160px] items-center justify-center rounded-sm border border-dashed border-hair-2 text-[12.5px] text-ink-3">No coordinates published — not drawn on the map.</div>
466 + )}
467 + </div>
468 + </Section>
469 +
470 + {/* ------------------------------------------------------------ connectivity */}
471 + <Section id="connectivity" title="Connectivity" className="scroll-mt-28" description="Carriers, cloud on-ramps and internet exchanges present at this site, as published by the operator or PeeringDB.">
472 + {connectivityEmpty ? (
473 + <NotPublished what="Connectivity (carriers, cloud on-ramps, IXPs)" />
474 + ) : (
475 + <div className="grid gap-4 sm:grid-cols-3">
476 + <div>
477 + <div className="label-caps mb-1.5">
478 + Carriers {f.carriersCount !== null && f.carriersCount !== undefined && <span className="figure text-ink-3">{fmtInt(f.carriersCount)}</span>}
479 + {f.networksCount !== null && f.networksCount !== undefined && <span className="ml-2 normal-case tracking-normal text-ink-4">{fmtInt(f.networksCount)} networks</span>}
480 + </div>
481 + {f.carriers.length ? (
482 + <ul className="divide-y divide-hair text-[13px]">
483 + {f.carriers.map((c) => (
484 + <li key={c.id} className="flex items-baseline justify-between gap-2 py-1.5">
485 + <Link href={routes.operator(c.slug)} className="truncate text-ink hover:text-accent hover:underline">
486 + {c.name}
487 + </Link>
488 + {c.asn && <span className="figure shrink-0 text-[11.5px] text-ink-3">AS{c.asn}</span>}
489 + </li>
490 + ))}
491 + </ul>
492 + ) : (
493 + <p className="text-[12.5px] text-ink-3">{f.carriersCount ? "Names not published; count from PeeringDB." : "None listed."}</p>
494 + )}
495 + </div>
496 + <div>
497 + <div className="label-caps mb-1.5">Cloud providers</div>
498 + {f.cloudProviders.length ? (
499 + <ul className="divide-y divide-hair text-[13px]">
500 + {f.cloudProviders.map((c) => (
501 + <li key={c.id} className="py-1.5">
502 + <Link href={routes.operator(c.slug)} className="text-ink hover:text-accent hover:underline">
503 + {c.name}
504 + </Link>
505 + </li>
506 + ))}
507 + </ul>
508 + ) : (
509 + <p className="text-[12.5px] text-ink-3">None listed.</p>
510 + )}
511 + </div>
512 + <div>
513 + <div className="label-caps mb-1.5">
514 + Internet exchanges {f.ixpCount !== null && f.ixpCount !== undefined && <span className="figure text-ink-3">{fmtInt(f.ixpCount)}</span>}
515 + </div>
516 + {f.ixps.length ? (
517 + <ul className="divide-y divide-hair text-[13px]">
518 + {f.ixps.map((x) => (
519 + <li key={x.id} className="py-1.5">
520 + <Link href={routes.ixp(x.slug)} className="text-ink hover:text-accent hover:underline">
521 + {x.name}
522 + </Link>
523 + </li>
524 + ))}
525 + </ul>
526 + ) : (
527 + <p className="text-[12.5px] text-ink-3">{f.ixpCount ? "Names not published; count from PeeringDB." : "None listed."}</p>
528 + )}
529 + </div>
530 + </div>
531 + )}
532 + </Section>
533 +
534 + {/* ------------------------------------------------------------ cloud regions */}
535 + {f.cloudRegions.length > 0 && (
536 + <Section id="cloud" title={`Cloud regions in ${f.metro ? `the ${f.metro.name} market` : "this market"}`} className="scroll-mt-28" description="Public cloud regions published for the same metro. Cloud providers publish the metro, not the building — presence here does not imply this facility hosts them." aside={<Link href={routes.cloudRegions()} className="text-[12.5px] text-ink-3 hover:text-ink">All cloud regions →</Link>}>
537 + <Table dense>
538 + <THead>
539 + <tr>
540 + <Th>Provider</Th>
541 + <Th>Code</Th>
542 + <Th className="hidden sm:table-cell">Name</Th>
543 + <Th numeric className="hidden md:table-cell">
544 + AZs
545 + </Th>
546 + <Th className="hidden md:table-cell">Launched</Th>
547 + <Th>Status</Th>
548 + </tr>
549 + </THead>
550 + <TBody>
551 + {f.cloudRegions.map((cr) => (
552 + <Tr key={cr.id}>
553 + <Td primary>
554 + <Link href={routes.operator(cr.provider.slug)} className="font-medium text-ink hover:text-accent hover:underline">
555 + {cr.provider.name}
556 + </Link>
557 + <span className="ml-2 text-[12px] text-ink-3 sm:hidden">{cr.name}</span>
558 + </Td>
559 + <Td label="Code" className="figure text-[12.5px]">
560 + <RowLink href={routes.cloudRegion(cr.slug)} className="font-normal">
561 + {cr.code}
562 + </RowLink>
563 + </Td>
564 + <Td label="Name" className="hidden text-ink-2 sm:table-cell">
565 + {cr.name}
566 + </Td>
567 + <Td label="AZs" numeric className="hidden md:table-cell">
568 + {cr.availabilityZones ?? "—"}
569 + </Td>
570 + <Td label="Launched" className="figure hidden text-[12.5px] text-ink-3 md:table-cell">
571 + {fmtDate(cr.launchedOn)}
572 + </Td>
573 + <Td label="Status">
574 + <Badge tone={cr.status === "operational" ? "ok" : cr.status === "announced" ? "info" : cr.status === "restricted" ? "warn" : "neutral"} size="xs">
575 + {cr.status}
576 + </Badge>
577 + {cr.isSovereign && (
578 + <Badge tone="accent" size="xs" className="ml-1">
579 + Sovereign
580 + </Badge>
581 + )}
582 + </Td>
583 + </Tr>
584 + ))}
585 + </TBody>
586 + </Table>
587 + </Section>
588 + )}
589 +
590 + {/* ------------------------------------------------------------ projects */}
591 + {f.projects.length > 0 && (
592 + <Section id="projects" title="Projects" className="scroll-mt-28" description="Announced or under-construction projects linked to this facility or to the same operator in this market.">
593 + <ProjectList items={f.projects} />
594 + </Section>
595 + )}
596 +
597 + {/* ------------------------------------------------------------ nearby */}
598 + {nearby.length > 0 && (
599 + <Section id="nearby" title="Nearby facilities" className="scroll-mt-28" description="Indexed facilities within 25 km (great-circle distance between published coordinates).">
600 + <ul className="divide-y divide-hair">
601 + {nearby.map((n) => {
602 + const { mw, kind } = bestMw(n);
603 + return (
604 + <li key={n.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 py-2.5">
605 + <div className="min-w-0">
606 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
607 + <Link href={routes.facility(n.slug)} className="truncate text-[13.5px] font-medium text-ink hover:text-accent hover:underline">
608 + {n.name}
609 + </Link>
610 + <StatusBadge status={n.status} short size="xs" />
611 + <FlagBadges isAi={n.isAi} isHyperscale={n.isHyperscale} />
612 + </div>
613 + <div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-[12px] text-ink-3">
614 + {n.operator ? (
615 + <Link href={routes.operator(n.operator.slug)} className="text-ink-2 hover:text-ink">
616 + {n.operator.name}
617 + </Link>
618 + ) : (
619 + <span>Operator unknown</span>
620 + )}
621 + {n.city && (
622 + <>
623 + <span aria-hidden>·</span>
624 + <span className="truncate">{n.city}</span>
625 + </>
626 + )}
627 + {isImprecise(n.geoPrecision) && (
628 + <>
629 + <span aria-hidden>·</span>
630 + <span>{PRECISION_LABEL[n.geoPrecision].toLowerCase()}</span>
631 + </>
632 + )}
633 + </div>
634 + </div>
635 + <div className="flex flex-col items-end text-right">
636 + <span className="figure text-[13.5px] font-medium text-ink">
637 + {mw === null ? (
638 + <span className="text-ink-4">—</span>
639 + ) : (
640 + <>
641 + {kind === "planned" && <EstimateMark className="mr-0.5 text-[11px]" />}
642 + {fmtMw(mw)}
643 + </>
644 + )}
645 + </span>
646 + <span className="figure text-[11px] text-ink-3">{n.km === null ? "distance n/a" : n.km < 1 ? `${Math.round(n.km * 1000)} m` : `${n.km.toFixed(n.km < 10 ? 1 : 0)} km`}</span>
647 + </div>
648 + </li>
649 + );
650 + })}
651 + </ul>
652 + </Section>
653 + )}
654 +
655 + {/* ------------------------------------------------------------ provenance */}
656 + <Section id="provenance" title="Provenance" className="scroll-mt-28" description="Every field with the source it was observed on, when, at what confidence and by which method." aside={<span className="figure text-[12px] text-ink-3">{fmtInt(prov.length)} rows</span>}>
657 + <ProvenanceTable items={prov} />
658 + <p className="mt-2 text-[11.5px] text-ink-4">
659 + {CONFIDENCE_LABEL[f.confidence]} overall — computed from the best source kind and the number of independent primary sources.{" "}
660 + <Link href={`${routes.methodology()}#confidence`} className="underline decoration-hair-2 hover:text-ink">
661 + How confidence is computed
662 + </Link>
663 + </p>
664 + </Section>
665 +
666 + {/* ------------------------------------------------------------ source history */}
667 + {f.sourceHistory.length > 0 && (
668 + <Section id="history" title="Source history" className="scroll-mt-28" description="Observations, verifications and detected changes across sources, newest first.">
669 + <SourceHistory items={[...f.sourceHistory].sort((a, b) => (a.date < b.date ? 1 : -1)).slice(0, 60)} />
670 + {f.sourceHistory.length > 60 && <p className="mt-2 text-[11.5px] text-ink-4">Showing the 60 most recent of {fmtInt(f.sourceHistory.length)} entries.</p>}
671 + </Section>
672 + )}
673 +
674 + {/* ------------------------------------------------------------ changes */}
675 + {f.events.length > 0 && (
676 + <Section id="changes" title="Changes" className="scroll-mt-28" description="Detected changes to this record — status, capacity, dates, operator." aside={<Link href={routes.live()} className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-ink">Live feed <ExternalLink className="size-3" /></Link>}>
677 + <EventList events={f.events} dense />
678 + </Section>
679 + )}
680 + </div>
681 +
682 + <SourcesFooter sources={r.sources} updatedAt={f.updatedAt} />
683 + </Page>
684 + );
685 +}
added apps/web/src/app/(site)/datacenters/markets/(index)/loading.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-24" />
8 + <Skeleton className="mb-6 h-7 w-64" />
9 + <Skeleton className="mb-4 h-9 w-full" />
10 + <SkeletonRows rows={12} />
11 + </Page>
12 + );
13 +}
added apps/web/src/app/(site)/datacenters/markets/(index)/page.tsx +195 −0
@@ -0,0 +1,195 @@
1 +import type { MetroSummary } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
5 +import { EstimateMark } from "@/components/ui/badge";
6 +import { Button } from "@/components/ui/button";
7 +import { Page, PageHeader } from "@/components/ui/card";
8 +import { CountryFlag } from "@/components/ui/country-flag";
9 +import { FilterChips, type Chip } from "@/components/ui/filter-chips";
10 +import { Pagination } from "@/components/ui/pagination";
11 +import { EmptyState } from "@/components/ui/states";
12 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
13 +import { api } from "@/lib/api";
14 +import { fmtInt, fmtMw } from "@/lib/format";
15 +import { routes } from "@/lib/routes";
16 +import { pageMetadata } from "@/lib/seo";
17 +import { compareBy, hrefWith, paginate, parseSort, sortTh, spGet, spInt, type SearchParams } from "@/lib/sort";
18 +import { countryHref, countrySlugMap, pipelineMw } from "../country-links";
19 +
20 +export const revalidate = 120;
21 +
22 +export const metadata: Metadata = pageMetadata({
23 + title: "Data center markets",
24 + description: "Metro markets ranked by facilities, known capacity and pipeline — operators, cloud regions and internet exchanges per market.",
25 + path: routes.markets(),
26 +});
27 +
28 +const SORT_KEYS = ["facilities", "mw", "pipeline", "operators", "cloud", "name"] as const;
29 +type SortKey = (typeof SORT_KEYS)[number];
30 +const PER_PAGE = 50;
31 +const BASE = routes.markets();
32 +
33 +const getter: Record<SortKey, (m: MetroSummary) => number | string | null> = {
34 + facilities: (m) => m.facilityCount,
35 + mw: (m) => m.knownMw,
36 + pipeline: (m) => pipelineMw(m),
37 + operators: (m) => m.operatorCount,
38 + cloud: (m) => m.cloudRegionCount,
39 + name: (m) => m.name,
40 +};
41 +
42 +export default async function MarketsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
43 + const sp = await searchParams;
44 + const q = spGet(sp, "q");
45 + const country = spGet(sp, "country")?.toUpperCase();
46 + const sort = parseSort<SortKey>(sp, SORT_KEYS, { key: "facilities", order: "desc" });
47 + const [res, { map, countries }] = await Promise.all([api.metros({ q, country }), countrySlugMap()]);
48 + const ok = res.items.length > 0;
49 +
50 + const sorted = [...res.items].sort((a, b) => compareBy(getter[sort.key], sort.order)(a, b) || a.name.localeCompare(b.name));
51 + const { items, page } = paginate(sorted, spInt(sp, "page"), PER_PAGE);
52 + const th = (key: SortKey) => sortTh(BASE, sp, sort, key);
53 +
54 + const chips: Chip[] = [];
55 + if (q) chips.push({ key: "q", label: "Search", value: q, removeHref: hrefWith(BASE, sp, { q: undefined }) });
56 + if (country) chips.push({ key: "country", label: "Country", value: countries.find((c) => c.iso2 === country)?.name ?? country, removeHref: hrefWith(BASE, sp, { country: undefined }) });
57 +
58 + return (
59 + <Page>
60 + <Breadcrumbs items={[{ name: "Data centers", href: routes.datacenters() }, { name: "Markets", href: BASE }]} className="pt-3" />
61 + <PageHeader
62 + eyebrow="Index"
63 + title="Data center markets"
64 + description={ok ? `${fmtInt(sorted.length)} metro markets. Ranked by facilities, known capacity and pipeline; MW figures are lower bounds from published values only.` : "Metro markets ranked by facilities, known capacity and pipeline."}
65 + aside={
66 + <Button href={routes.map()} size="sm">
67 + Open map
68 + </Button>
69 + }
70 + />
71 + <form method="get" action={BASE} className="flex flex-col gap-2 border-y border-hair py-3 sm:flex-row sm:items-center">
72 + <input type="search" name="q" defaultValue={q ?? ""} placeholder="Search markets or sub-markets" aria-label="Search markets" className="h-9 min-w-0 flex-1 rounded-sm border border-hair-2 bg-surface px-3 text-[13px] text-ink outline-none placeholder:text-ink-4 focus-visible:ring-2 focus-visible:ring-accent" />
73 + <div className="flex gap-2">
74 + <select name="country" defaultValue={country ?? ""} aria-label="Country" className="h-9 min-w-0 flex-1 rounded-sm border border-hair-2 bg-surface px-2 text-[13px] text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent sm:w-[200px] sm:flex-none">
75 + <option value="">All countries</option>
76 + {countries.map((c) => (
77 + <option key={c.iso2} value={c.iso2}>
78 + {c.name}
79 + </option>
80 + ))}
81 + </select>
82 + {sort.key !== "facilities" && <input type="hidden" name="sort" value={sort.key} />}
83 + {sort.key !== "facilities" && <input type="hidden" name="order" value={sort.order} />}
84 + <Button type="submit" variant="primary" size="md">
85 + Filter
86 + </Button>
87 + </div>
88 + </form>
89 + <FilterChips chips={chips} resetHref={BASE} className="py-2" />
90 +
91 + <div className="pt-1">
92 + {items.length === 0 ? (
93 + <EmptyState compact title={ok ? "No markets match" : "Markets unavailable"} description={ok ? "Try a different search or country." : "The index API is not reachable right now."} />
94 + ) : (
95 + <Table>
96 + <THead>
97 + <tr>
98 + <Th {...th("name")}>Market</Th>
99 + <Th className="hidden sm:table-cell">Country</Th>
100 + <Th numeric {...th("facilities")}>
101 + Facilities
102 + </Th>
103 + <Th className="hidden lg:table-cell" title="operational · construction · planned">
104 + Op · Con · Plan
105 + </Th>
106 + <Th numeric {...th("mw")}>
107 + Known MW
108 + </Th>
109 + <Th numeric {...th("pipeline")}>
110 + Pipeline
111 + </Th>
112 + <Th numeric className="hidden md:table-cell" {...th("operators")}>
113 + Operators
114 + </Th>
115 + <Th numeric className="hidden md:table-cell" {...th("cloud")}>
116 + Cloud regions
117 + </Th>
118 + <Th numeric className="hidden lg:table-cell">
119 + IXPs
120 + </Th>
121 + <Th numeric className="hidden lg:table-cell">
122 + Projects
123 + </Th>
124 + </tr>
125 + </THead>
126 + <TBody>
127 + {items.map((m) => {
128 + const pipe = pipelineMw(m);
129 + const cHref = countryHref(map, m.countryIso2);
130 + return (
131 + <Tr key={m.id}>
132 + <Td primary>
133 + <RowLink href={routes.market(m.slug)}>{m.name}</RowLink>
134 + <div className="text-[11.5px] text-ink-3 sm:hidden">
135 + {m.countryName ?? m.countryIso2}
136 + {m.regionName && m.regionName !== m.name ? ` · ${m.regionName}` : ""}
137 + </div>
138 + </Td>
139 + <Td label="Country" className="hidden sm:table-cell">
140 + {cHref ? (
141 + <Link href={cHref} className="text-ink-2 hover:text-ink hover:underline">
142 + <CountryFlag iso2={m.countryIso2} withName className="max-w-[180px]" />
143 + </Link>
144 + ) : (
145 + <CountryFlag iso2={m.countryIso2} withName />
146 + )}
147 + </Td>
148 + <Td label="Facilities" numeric>
149 + {fmtInt(m.facilityCount)}
150 + </Td>
151 + <Td label="Op · Con · Plan" className="figure hidden text-[12px] text-ink-3 lg:table-cell" title="operational · under construction · planned">
152 + {fmtInt(m.operationalCount)} · {fmtInt(m.constructionCount)} · {fmtInt(m.plannedCount)}
153 + </Td>
154 + <Td label="Known MW" numeric>
155 + {m.knownMw === null ? <span className="text-ink-4">—</span> : fmtMw(m.knownMw)}
156 + </Td>
157 + <Td label="Pipeline" numeric>
158 + {pipe === null ? (
159 + <span className="text-ink-4">—</span>
160 + ) : (
161 + <span title="Under construction + planned power (published figures only)">
162 + <EstimateMark className="mr-0.5" />
163 + {fmtMw(pipe)}
164 + </span>
165 + )}
166 + </Td>
167 + <Td label="Operators" numeric className="hidden md:table-cell">
168 + {fmtInt(m.operatorCount)}
169 + </Td>
170 + <Td label="Cloud regions" numeric className="hidden md:table-cell">
171 + {m.cloudRegionCount ? fmtInt(m.cloudRegionCount) : <span className="text-ink-4">—</span>}
172 + </Td>
173 + <Td label="IXPs" numeric className="hidden lg:table-cell">
174 + {m.ixpCount ? fmtInt(m.ixpCount) : <span className="text-ink-4">—</span>}
175 + </Td>
176 + <Td label="Projects" numeric className="hidden lg:table-cell">
177 + {m.projectCount ? fmtInt(m.projectCount) : <span className="text-ink-4">—</span>}
178 + </Td>
179 + </Tr>
180 + );
181 + })}
182 + </TBody>
183 + </Table>
184 + )}
185 + </div>
186 + <Pagination page={page} perPage={PER_PAGE} total={sorted.length} hrefFor={(p) => hrefWith(BASE, sp, { page: p > 1 ? p : undefined })} label="markets" className="mt-3" />
187 + <p className="mt-6 text-[11.5px] text-ink-4">
188 + Facilities are assigned to a market when they lie within its radius or their city matches one of its sub-markets; markets never span borders.{" "}
189 + <Link href={routes.methodology()} className="underline decoration-hair-2 hover:text-ink">
190 + Methodology
191 + </Link>
192 + </p>
193 + </Page>
194 + );
195 +}
added apps/web/src/app/(site)/datacenters/markets/[slug]/data.ts +6 −0
@@ -0,0 +1,6 @@
1 +import type { MetroDetail } from "@dci/core";
2 +import { cache } from "react";
3 +import { api, type ApiResult } from "@/lib/api";
4 +
5 +/** Per-request memoised metro loader shared by layout (existence gate), metadata, page and OG image. */
6 +export const getMetro = cache(async (slug: string, fPage = 1): Promise<ApiResult<MetroDetail>> => api.metro(slug, fPage > 1 ? { fPage } : undefined));
added apps/web/src/app/(site)/datacenters/markets/[slug]/growth-chart.tsx +29 −0
@@ -0,0 +1,29 @@
1 +"use client";
2 +
3 +import { AreaChart } from "@/components/ui/charts";
4 +import { EmptyState } from "@/components/ui/states";
5 +import { fmtCompact, fmtMw } from "@/lib/format";
6 +
7 +/**
8 + * Facilities by opening year (cumulative). Known MW is charted separately (own axis) only when the
9 + * market publishes any MW figure — never on a dual axis.
10 + */
11 +export function GrowthChart({ data }: { data: Array<{ year: number; facilities: number; knownMw: number | null }> }) {
12 + const pts = [...data].filter((d) => Number.isFinite(d.year)).sort((a, b) => a.year - b.year);
13 + if (pts.length < 2) return <EmptyState compact title="Not enough dated history" description="Growth appears once several facilities with a published opening year are indexed in this market." />;
14 + const hasMw = pts.some((d) => d.knownMw !== null && d.knownMw > 0);
15 + return (
16 + <div className={hasMw ? "grid gap-6 md:grid-cols-2" : undefined}>
17 + <div>
18 + <div className="label-caps mb-2">Facilities (cumulative)</div>
19 + <AreaChart series={[{ name: "Facilities", data: pts.map((d) => ({ x: d.year, y: d.facilities })), color: "var(--ink)" }]} height={170} format={(v) => fmtCompact(v, 0)} ariaLabel="Facilities by opening year" />
20 + </div>
21 + {hasMw && (
22 + <div>
23 + <div className="label-caps mb-2">Known MW</div>
24 + <AreaChart series={[{ name: "Known MW", data: pts.map((d) => ({ x: d.year, y: d.knownMw })), color: "var(--accent)" }]} height={170} format={(v) => fmtMw(v)} ariaLabel="Known operational MW by opening year" />
25 + </div>
26 + )}
27 + </div>
28 + );
29 +}
added apps/web/src/app/(site)/datacenters/markets/[slug]/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { getMetro } from "./data";
4 +
5 +/**
6 + * Existence gate for /datacenters/markets/[slug]: resolves the metro before this segment's loading boundary
7 + * streams, so an unknown slug is a real 404 (not a soft one). Other API failures fall through to the page.
8 + */
9 +export default async function MarketLayout({ children, params }: { children: ReactNode; params: Promise<{ slug: string }> }) {
10 + const { slug } = await params;
11 + const r = await getMetro(slug);
12 + if (!r.ok && r.status === 404) notFound();
13 + return <>{children}</>;
14 +}
added apps/web/src/app/(site)/datacenters/markets/[slug]/loading.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonBlock, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-4" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-40" />
8 + <Skeleton className="mb-2 h-3 w-16" />
9 + <Skeleton className="mb-2 h-7 w-72" />
10 + <Skeleton className="mb-6 h-3 w-48" />
11 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
12 + {Array.from({ length: 8 }).map((_, i) => (
13 + <div key={i} className="flex flex-col gap-2">
14 + <Skeleton className="h-6 w-16" />
15 + <Skeleton className="h-2.5 w-20" />
16 + </div>
17 + ))}
18 + </div>
19 + <Skeleton className="mt-4 h-10 w-full" />
20 + <SkeletonBlock className="mt-6 h-[240px] w-full" />
21 + <SkeletonRows rows={8} className="mt-8" />
22 + </Page>
23 + );
24 +}
added apps/web/src/app/(site)/datacenters/markets/[slug]/opengraph-image.tsx +27 −0
@@ -0,0 +1,27 @@
1 +import { fmtInt, fmtMw } from "@/lib/format";
2 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
3 +import { getMetro } from "./data";
4 +
5 +export const alt = "Data center market — DataCenterIndex";
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
11 + const { slug } = await params;
12 + const r = await getMetro(slug);
13 + if (!r.ok) return renderOg({ eyebrow: "Market", title: "Data center market", subtitle: "DataCenterIndex — facilities, capacity and pipeline by metro market." });
14 + const m = r.data;
15 + const pipe = m.constructionMw === null && m.plannedMw === null ? null : (m.constructionMw ?? 0) + (m.plannedMw ?? 0);
16 + return renderOg({
17 + eyebrow: "Market",
18 + title: `Data centers in ${m.name}`,
19 + subtitle: [m.countryName ?? m.countryIso2, m.regionName && m.regionName !== m.name ? m.regionName : null].filter(Boolean).join(" · "),
20 + stats: [
21 + { label: "Facilities", value: fmtInt(m.facilityCount) },
22 + { label: "Known MW", value: m.knownMw === null ? "—" : fmtMw(m.knownMw) },
23 + { label: "Pipeline", value: pipe === null ? "—" : `≈ ${fmtMw(pipe)}` },
24 + { label: "Operators", value: fmtInt(m.operatorCount) },
25 + ],
26 + });
27 +}
added apps/web/src/app/(site)/datacenters/markets/[slug]/page.tsx +371 −0
@@ -0,0 +1,371 @@
1 +import type { FacilityStatus, MetroDetail } from "@dci/core";
2 +import { ExternalLink } from "lucide-react";
3 +import type { Metadata } from "next";
4 +import Link from "next/link";
5 +import { notFound } from "next/navigation";
6 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
7 +import { EntityMap } from "@/components/entity/entity-map";
8 +import { JsonLd } from "@/components/entity/json-ld";
9 +import { fPageHref, PagedFacilities, parseFPage } from "@/components/entity/paged-facilities";
10 +import { SectionNav } from "@/components/entity/section-nav";
11 +import { SourcesFooter } from "@/components/entity/sources-footer";
12 +import { EventList } from "@/components/event-list";
13 +import { ProjectList } from "@/components/project-list";
14 +import { Badge, EstimateMark } from "@/components/ui/badge";
15 +import { Page, Section } from "@/components/ui/card";
16 +import { BarList, StackedBar } from "@/components/ui/charts";
17 +import { CountryFlag } from "@/components/ui/country-flag";
18 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
19 +import { ErrorState, OfflineNotice } from "@/components/ui/states";
20 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
21 +import type { DetailFacilitiesMeta } from "@/lib/dto";
22 +import { fmtDate, fmtInt, fmtMw } from "@/lib/format";
23 +import { routes } from "@/lib/routes";
24 +import { compactJsonLd, NOT_FOUND_METADATA, pageMetadata } from "@/lib/seo";
25 +import type { SearchParams } from "@/lib/sort";
26 +import { countryHref, countrySlugMap, pipelineMw } from "../country-links";
27 +import { getMetro } from "./data";
28 +import { GrowthChart } from "./growth-chart";
29 +
30 +export const revalidate = 300;
31 +
32 +type Props = { params: Promise<{ slug: string }>; searchParams: Promise<SearchParams> };
33 +
34 +function describe(m: MetroDetail): string {
35 + const parts = [`${fmtInt(m.facilityCount)} data center ${m.facilityCount === 1 ? "facility" : "facilities"} in ${m.name}${m.countryName ? `, ${m.countryName}` : ""}`];
36 + if (m.knownMw !== null) parts.push(`${fmtMw(m.knownMw)} known operational capacity`);
37 + const pipe = pipelineMw(m);
38 + if (pipe !== null) parts.push(`${fmtMw(pipe)} in the pipeline`);
39 + parts.push(`${fmtInt(m.operatorCount)} operators`);
40 + if (m.cloudRegionCount) parts.push(`${fmtInt(m.cloudRegionCount)} cloud regions`);
41 + return `${parts.join(", ")}. Sourced facilities, operators, cloud regions, IXPs and projects for the ${m.name} market.`;
42 +}
43 +
44 +export async function generateMetadata({ params }: Props): Promise<Metadata> {
45 + const { slug } = await params;
46 + const r = await getMetro(slug);
47 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Market" };
48 + return pageMetadata({ title: `Data Centers in ${r.data.name}`, description: describe(r.data), path: routes.market(r.data.slug) });
49 +}
50 +
51 +const CONSTRAINT_LABEL: Record<MetroDetail["constraints"][number]["kind"], string> = { power: "Power", land: "Land", water: "Water", moratorium: "Moratorium", grid: "Grid" };
52 +
53 +export default async function MarketPage({ params, searchParams }: Props) {
54 + const [{ slug }, sp] = await Promise.all([params, searchParams]);
55 + const fPage = parseFPage(sp.fPage);
56 + const [r, { map }] = await Promise.all([getMetro(slug, fPage), countrySlugMap()]);
57 + if (!r.ok && r.status === 404) notFound();
58 + if (!r.ok) {
59 + return (
60 + <Page className="py-6">
61 + <OfflineNotice detail={r.error} className="mb-4" />
62 + <ErrorState />
63 + </Page>
64 + );
65 + }
66 + const m = r.data;
67 + const meta = (r.meta ?? {}) as DetailFacilitiesMeta;
68 + const facTotal = meta.facilitiesTotal ?? m.facilityCount;
69 + const facPage = meta.facilitiesPage ?? fPage;
70 + const facPer = meta.facilitiesPerPage ?? 50;
71 + const pipe = pipelineMw(m);
72 + const cHref = countryHref(map, m.countryIso2);
73 + const base = routes.market(m.slug);
74 +
75 + const statusData: Partial<Record<FacilityStatus, number>> = {};
76 + if (m.operationalCount) statusData.operational = m.operationalCount;
77 + if (m.constructionCount) statusData.under_construction = m.constructionCount;
78 + if (m.plannedCount) statusData.announced = m.plannedCount;
79 + const other = m.facilityCount - m.operationalCount - m.constructionCount - m.plannedCount;
80 + if (other > 0) statusData.unknown = other;
81 +
82 + const hasCloud = m.cloudProviders.length > 0 || m.cloudRegions.length > 0;
83 + const sections = [
84 + { id: "map", label: "Map" },
85 + { id: "status", label: "Status" },
86 + { id: "operators", label: "Operators", count: m.operators.length },
87 + { id: "cloud", label: "Cloud", count: hasCloud ? m.cloudRegions.length || m.cloudProviders.length : 0 },
88 + { id: "ixps", label: "Internet exchanges", count: m.ixps.length },
89 + { id: "constraints", label: "Constraints", count: m.constraints.length },
90 + { id: "growth", label: "Growth" },
91 + { id: "projects", label: "Projects", count: m.projects.length },
92 + { id: "changes", label: "Changes", count: m.recentEvents.length },
93 + { id: "facilities", label: "Facilities", count: facTotal },
94 + ];
95 +
96 + const jsonLd = compactJsonLd({
97 + "@context": "https://schema.org",
98 + "@type": "Place",
99 + name: `${m.name} data center market`,
100 + url: `${routes.market(m.slug)}`,
101 + address: { "@type": "PostalAddress", addressLocality: m.name, addressRegion: m.regionName ?? undefined, addressCountry: m.countryIso2 },
102 + geo: { "@type": "GeoCoordinates", latitude: m.lat, longitude: m.lng },
103 + description: describe(m),
104 + });
105 +
106 + return (
107 + <Page>
108 + <Breadcrumbs items={[{ name: "Data centers", href: routes.datacenters() }, { name: "Markets", href: routes.markets() }, { name: m.name, href: base }]} className="pt-3" />
109 + <JsonLd data={jsonLd} />
110 +
111 + <header className="py-5 sm:py-7">
112 + <div className="label-caps mb-1.5">Market</div>
113 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">Data centers in {m.name}</h1>
114 + <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-ink-2">
115 + <CountryFlag iso2={m.countryIso2} size={13} />
116 + {cHref ? (
117 + <Link href={cHref} className="text-ink hover:text-accent hover:underline">
118 + {m.countryName ?? m.countryIso2}
119 + </Link>
120 + ) : (
121 + <span>{m.countryName ?? m.countryIso2}</span>
122 + )}
123 + {m.regionName && m.regionName !== m.name && (
124 + <>
125 + <span aria-hidden className="text-ink-4">
126 + ·
127 + </span>
128 + <span className="text-ink-3">{m.regionName}</span>
129 + </>
130 + )}
131 + <span aria-hidden className="text-ink-4">
132 + ·
133 + </span>
134 + <span className="figure text-[12px] text-ink-3">
135 + {m.lat.toFixed(2)}, {m.lng.toFixed(2)}
136 + </span>
137 + </div>
138 + {m.description && <p className="mt-2 max-w-2xl text-[13.5px] text-ink-2">{m.description}</p>}
139 + {m.aliases.length > 0 && (
140 + <div className="mt-3">
141 + <div className="label-caps mb-1.5">Sub-markets</div>
142 + <div className="flex flex-wrap gap-1">
143 + {m.aliases.slice(0, 8).map((a) => (
144 + <Badge key={a} tone="outline" size="xs">
145 + {a}
146 + </Badge>
147 + ))}
148 + {m.aliases.length > 8 && (
149 + <details className="inline">
150 + <summary className="inline-flex h-[18px] cursor-pointer list-none items-center rounded-xs border border-hair-2 px-1.5 text-[10.5px] font-medium text-ink-3 hover:text-ink [&::-webkit-details-marker]:hidden">+{m.aliases.length - 8} more</summary>
151 + <span className="mt-1 flex flex-wrap gap-1">
152 + {m.aliases.slice(8).map((a) => (
153 + <Badge key={a} tone="outline" size="xs">
154 + {a}
155 + </Badge>
156 + ))}
157 + </span>
158 + </details>
159 + )}
160 + </div>
161 + </div>
162 + )}
163 + </header>
164 +
165 + <MetricRow cols={4}>
166 + <MetricCell>
167 + <Metric value={fmtInt(m.facilityCount)} label="Facilities" size="sm" />
168 + </MetricCell>
169 + <MetricCell>
170 + <Metric value={m.knownMw === null ? "—" : fmtMw(m.knownMw).split(" ")[0]} unit={m.knownMw === null ? undefined : fmtMw(m.knownMw).split(" ")[1]} label="Known MW" note={m.knownMw === null ? "no published figure" : "operational, published"} size="sm" />
171 + </MetricCell>
172 + <MetricCell>
173 + <Metric value={m.constructionMw === null ? "—" : fmtMw(m.constructionMw).split(" ")[0]} unit={m.constructionMw === null ? undefined : fmtMw(m.constructionMw).split(" ")[1]} label="Under construction" note={m.constructionMw === null ? `${fmtInt(m.constructionCount)} ${m.constructionCount === 1 ? "site" : "sites"}, MW not published` : `${fmtInt(m.constructionCount)} ${m.constructionCount === 1 ? "site" : "sites"}`} size="sm" />
174 + </MetricCell>
175 + <MetricCell>
176 + <Metric
177 + value={
178 + m.plannedMw === null ? (
179 + "—"
180 + ) : (
181 + <>
182 + <EstimateMark className="mr-0.5 text-[0.6em]" />
183 + {fmtMw(m.plannedMw).split(" ")[0]}
184 + </>
185 + )
186 + }
187 + unit={m.plannedMw === null ? undefined : fmtMw(m.plannedMw).split(" ")[1]}
188 + label="Planned"
189 + note={`${fmtInt(m.plannedCount)} ${m.plannedCount === 1 ? "site" : "sites"}${m.plannedMw === null ? ", MW not published" : ""}`}
190 + size="sm"
191 + />
192 + </MetricCell>
193 + <MetricCell>
194 + <Metric value={fmtInt(m.operatorCount)} label="Operators" size="sm" />
195 + </MetricCell>
196 + <MetricCell>
197 + <Metric value={fmtInt(m.cloudRegionCount)} label="Cloud regions" size="sm" />
198 + </MetricCell>
199 + <MetricCell>
200 + <Metric value={fmtInt(m.ixpCount)} label="IXPs" size="sm" />
201 + </MetricCell>
202 + <MetricCell>
203 + <Metric value={fmtInt(m.projectCount)} label="Projects" size="sm" />
204 + </MetricCell>
205 + </MetricRow>
206 +
207 + {m.rankings.length > 0 && (
208 + <div className="-mx-3 flex gap-1.5 overflow-x-auto px-3 py-3 no-scrollbar sm:mx-0 sm:flex-wrap sm:px-0" aria-label="Ranking positions">
209 + {m.rankings.map((rk) => (
210 + <Link key={rk.key} href={routes.ranking(rk.key)} className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-xs border border-hair bg-surface px-2 text-[12px] text-ink-2 hover:border-hair-2 hover:text-ink">
211 + <span className="figure font-medium text-ink">#{rk.rank}</span>
212 + <span className="figure text-ink-4">/ {rk.total}</span>
213 + <span>{rk.label}</span>
214 + </Link>
215 + ))}
216 + </div>
217 + )}
218 +
219 + <SectionNav items={sections} className="mt-2" />
220 +
221 + <div className="flex flex-col gap-8 pt-6">
222 + <Section id="map" title="Map" description="Facilities in and around the market; the view opens the interactive map filtered to the country." className="scroll-mt-28">
223 + <EntityMap center={{ lat: m.lat, lng: m.lng }} zoom={9} filters={{ country: m.countryIso2 }} openLabel={`Open ${m.name} in map`} className="h-[240px] sm:h-[360px]" />
224 + </Section>
225 +
226 + <Section id="status" title="Status" description="Facilities by lifecycle status." className="scroll-mt-28">
227 + <StackedBar data={statusData} total={m.facilityCount} />
228 + </Section>
229 +
230 + {m.operators.length > 0 && (
231 + <Section id="operators" title="Operators" description="By facilities operated in this market." className="scroll-mt-28" aside={<span className="figure text-[12px] text-ink-3">{fmtInt(m.operatorCount)} total</span>}>
232 + <BarList items={m.operators.slice(0, 15).map((o) => ({ label: o.name, value: o.facilityCount, href: routes.operator(o.slug), secondary: o.knownMw !== null ? fmtMw(o.knownMw) : undefined }))} />
233 + </Section>
234 + )}
235 +
236 + {hasCloud && (
237 + <Section id="cloud" title="Cloud" description="Public cloud providers with a region in this market." className="scroll-mt-28">
238 + {m.cloudProviders.length > 0 && (
239 + <ul className="mb-3 flex flex-wrap gap-1.5">
240 + {m.cloudProviders.map((p) => (
241 + <li key={p.id}>
242 + <Link href={routes.operator(p.slug)} className="inline-flex h-7 items-center gap-1.5 rounded-xs border border-hair bg-surface px-2 text-[12.5px] text-ink hover:border-hair-2 hover:text-accent">
243 + {p.name}
244 + <span className="figure text-[11px] text-ink-3">
245 + {p.regionCount} {p.regionCount === 1 ? "region" : "regions"}
246 + </span>
247 + </Link>
248 + </li>
249 + ))}
250 + </ul>
251 + )}
252 + {m.cloudRegions.length > 0 && (
253 + <Table dense>
254 + <THead>
255 + <tr>
256 + <Th>Region</Th>
257 + <Th className="hidden sm:table-cell">Provider</Th>
258 + <Th className="hidden md:table-cell">Code</Th>
259 + <Th numeric>AZs</Th>
260 + <Th>Status</Th>
261 + <Th className="hidden lg:table-cell">Launched</Th>
262 + </tr>
263 + </THead>
264 + <TBody>
265 + {m.cloudRegions.map((cr) => (
266 + <Tr key={cr.id}>
267 + <Td primary>
268 + <RowLink href={routes.cloudRegion(cr.slug)}>{cr.name}</RowLink>
269 + <div className="text-[11.5px] text-ink-3 sm:hidden">
270 + {cr.provider.name} · <span className="figure">{cr.code}</span>
271 + </div>
272 + </Td>
273 + <Td label="Provider" className="hidden sm:table-cell">
274 + <Link href={routes.operator(cr.provider.slug)} className="text-ink-2 hover:text-ink hover:underline">
275 + {cr.provider.name}
276 + </Link>
277 + </Td>
278 + <Td label="Code" className="figure hidden text-[12px] md:table-cell">
279 + {cr.code}
280 + </Td>
281 + <Td label="AZs" numeric>
282 + {cr.availabilityZones ?? <span className="text-ink-4">—</span>}
283 + </Td>
284 + <Td label="Status">
285 + <span className="inline-flex flex-wrap gap-1">
286 + <Badge tone={cr.status === "operational" ? "ok" : cr.status === "announced" ? "info" : "neutral"} size="xs">
287 + {cr.status}
288 + </Badge>
289 + {cr.isSovereign && (
290 + <Badge tone="outline" size="xs" title="Sovereign cloud region">
291 + Sovereign
292 + </Badge>
293 + )}
294 + </span>
295 + </Td>
296 + <Td label="Launched" className="figure hidden text-[12px] text-ink-3 lg:table-cell">
297 + {fmtDate(cr.launchedOn)}
298 + </Td>
299 + </Tr>
300 + ))}
301 + </TBody>
302 + </Table>
303 + )}
304 + </Section>
305 + )}
306 +
307 + {m.ixps.length > 0 && (
308 + <Section id="ixps" title="Internet exchanges" description="IXPs with a presence in this market (PeeringDB)." className="scroll-mt-28">
309 + <ul className="divide-y divide-hair">
310 + {m.ixps.map((x) => (
311 + <li key={x.id} className="flex items-baseline justify-between gap-3 py-2">
312 + <Link href={routes.ixp(x.slug)} className="text-[13.5px] font-medium text-ink hover:text-accent hover:underline">
313 + {x.name}
314 + </Link>
315 + <span className="figure shrink-0 text-[12px] text-ink-3">{x.networkCount === null ? "networks —" : `${fmtInt(x.networkCount)} networks`}</span>
316 + </li>
317 + ))}
318 + </ul>
319 + </Section>
320 + )}
321 +
322 + {m.constraints.length > 0 && (
323 + <Section id="constraints" title="Constraints" description="Power, land, water, grid and moratorium mentions detected in indexed sources for this market. Each item links to its source." className="scroll-mt-28">
324 + <ul className="divide-y divide-hair">
325 + {m.constraints.map((c, i) => (
326 + <li key={i} className="py-2.5">
327 + <div className="flex flex-wrap items-center gap-2">
328 + <Badge tone={c.kind === "moratorium" || c.kind === "power" ? "warn" : "outline"} size="xs">
329 + {CONSTRAINT_LABEL[c.kind]}
330 + </Badge>
331 + {c.date && (
332 + <time className="figure text-[11.5px] text-ink-3" dateTime={c.date}>
333 + {fmtDate(c.date)}
334 + </time>
335 + )}
336 + </div>
337 + <p className="mt-1 text-[13px] text-ink-2">{c.summary}</p>
338 + <a href={c.url} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-[11.5px] text-ink-3 hover:text-accent">
339 + {c.sourceName} <ExternalLink className="size-3" />
340 + </a>
341 + </li>
342 + ))}
343 + </ul>
344 + </Section>
345 + )}
346 +
347 + <Section id="growth" title="Growth" description="Cumulative facilities by published opening year. Facilities without an opening date are not charted." className="scroll-mt-28">
348 + <GrowthChart data={m.growth} />
349 + </Section>
350 +
351 + {m.projects.length > 0 && (
352 + <Section id="projects" title="Pipeline & projects" description="Announced, permitted and under-construction projects linked to this market." className="scroll-mt-28" aside={<Link href={routes.projects(`metro=${m.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">All projects →</Link>}>
353 + <ProjectList items={m.projects} />
354 + </Section>
355 + )}
356 +
357 + {m.recentEvents.length > 0 && (
358 + <Section id="changes" title="Recent changes" description="Detected changes on facilities and projects in this market." className="scroll-mt-28" aside={<Link href={routes.live(`country=${m.countryIso2}`)} className="text-[12px] text-ink-3 hover:text-ink">Live feed →</Link>}>
359 + <EventList events={m.recentEvents} dense />
360 + </Section>
361 + )}
362 +
363 + <Section id="facilities" title="Facilities" description="Sorted by best known capacity (IT, total or ≈ planned)." className="scroll-mt-28" aside={<span className="figure text-[12px] text-ink-3">{fmtInt(facTotal)}</span>}>
364 + <PagedFacilities items={m.facilities} total={facTotal} page={facPage} perPage={facPer} hrefFor={(p) => fPageHref(base, sp, p)} columns={["operator", "status", "mw", "type", "confidence"]} emptyTitle="No facilities indexed in this market yet" />
365 + </Section>
366 + </div>
367 +
368 + <SourcesFooter sources={r.sources} note={pipe === null && m.knownMw === null ? "No MW figure has been published by the sources indexed for this market; counts are exact, capacities are shown only when a source publishes them." : undefined} />
369 + </Page>
370 + );
371 +}
added apps/web/src/app/(site)/datacenters/markets/country-links.ts +22 −0
@@ -0,0 +1,22 @@
1 +import type { CountrySummary } from "@dci/core";
2 +import { api } from "@/lib/api";
3 +import { routes } from "@/lib/routes";
4 +
5 +/** iso2 → canonical country slug (falls back to the iso2, which the API also accepts). */
6 +export async function countrySlugMap(): Promise<{ map: Map<string, string>; countries: CountrySummary[] }> {
7 + const r = await api.countries();
8 + const map = new Map<string, string>();
9 + for (const c of r.items) map.set(c.iso2.toUpperCase(), c.slug);
10 + return { map, countries: r.items };
11 +}
12 +
13 +export function countryHref(map: Map<string, string>, iso2: string | null | undefined): string | null {
14 + if (!iso2) return null;
15 + return routes.country(map.get(iso2.toUpperCase()) ?? iso2.toLowerCase());
16 +}
17 +
18 +/** construction + planned MW when at least one is published; null otherwise (never 0 for unknown). */
19 +export function pipelineMw(m: { constructionMw: number | null; plannedMw: number | null }): number | null {
20 + if (m.constructionMw === null && m.plannedMw === null) return null;
21 + return (m.constructionMw ?? 0) + (m.plannedMw ?? 0);
22 +}
added apps/web/src/app/(site)/ixps/(index)/loading.tsx +18 −0
@@ -0,0 +1,18 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-16" />
8 + <Skeleton className="mb-3 h-7 w-56" />
9 + <Skeleton className="mb-5 h-4 w-full max-w-xl" />
10 + <div className="flex gap-2">
11 + <Skeleton className="h-9 w-64" />
12 + <Skeleton className="h-9 w-40" />
13 + <Skeleton className="h-9 w-20" />
14 + </div>
15 + <SkeletonRows rows={8} className="mt-6" />
16 + </Page>
17 + );
18 +}
added apps/web/src/app/(site)/ixps/(index)/page.tsx +147 −0
@@ -0,0 +1,147 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { ExtLink } from "@/components/entity/kv";
4 +import { Button } from "@/components/ui/button";
5 +import { Page, PageHeader } from "@/components/ui/card";
6 +import { CountryFlag } from "@/components/ui/country-flag";
7 +import { FilterChips, type Chip } from "@/components/ui/filter-chips";
8 +import { Pagination } from "@/components/ui/pagination";
9 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
10 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
11 +import { api } from "@/lib/api";
12 +import { countryName, fmtInt } from "@/lib/format";
13 +import { routes } from "@/lib/routes";
14 +import { pageMetadata } from "@/lib/seo";
15 +import { hrefWith, spGet, spInt, type SearchParams } from "@/lib/sort";
16 +
17 +export const revalidate = 120;
18 +
19 +export const metadata: Metadata = pageMetadata({
20 + title: "Internet exchanges",
21 + description: "Internet exchange points (IXPs) with connected networks and the data centers where they are present.",
22 + path: routes.ixps(),
23 +});
24 +
25 +const BASE = routes.ixps();
26 +const PER_PAGE = 100;
27 +const input = "h-9 rounded-sm border border-hair-2 bg-surface px-2 text-[13px] text-ink placeholder:text-ink-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent";
28 +
29 +export default async function IxpsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
30 + const sp = await searchParams;
31 + const q = spGet(sp, "q");
32 + const country = spGet(sp, "country")?.toUpperCase();
33 + const page = spInt(sp, "page", 1);
34 + const [res, dash, countries] = await Promise.all([api.ixps({ q, country, page, per_page: PER_PAGE }), api.dashboard(), api.countries()]);
35 + const ok = res.total > 0 || res.items.length > 0 || (!q && !country && dash.ok);
36 + const total = res.total;
37 + const statIxps = dash.ok ? dash.data.stats.ixps : null;
38 + const countryOptions = countries.items.filter((c) => c.facilityCount > 0).sort((a, b) => a.name.localeCompare(b.name));
39 +
40 + const chips: Chip[] = [];
41 + if (q) chips.push({ key: "q", label: "Search", value: q, removeHref: hrefWith(BASE, sp, { q: undefined }) });
42 + if (country) chips.push({ key: "country", label: "Country", value: countryName(country), removeHref: hrefWith(BASE, sp, { country: undefined }) });
43 +
44 + return (
45 + <Page>
46 + <PageHeader
47 + eyebrow="Index"
48 + title="Internet exchanges"
49 + description={total > 0 ? `${fmtInt(total)} internet exchange points indexed with their connected networks and the facilities where they are present.` : "Internet exchange points (IXPs), the networks they connect and the data centers where they are present."}
50 + aside={
51 + <Button href={routes.datacenters()} size="sm">
52 + Data centers
53 + </Button>
54 + }
55 + />
56 + {!ok && <OfflineNotice className="mb-3" />}
57 +
58 + <form method="get" action={BASE} className="flex flex-wrap items-center gap-2">
59 + <label className="sr-only" htmlFor="ixp-q">
60 + Search
61 + </label>
62 + <input id="ixp-q" name="q" type="search" defaultValue={q ?? ""} placeholder="Name, e.g. DE-CIX" className={`${input} w-full sm:w-64`} />
63 + <label className="sr-only" htmlFor="ixp-country">
64 + Country
65 + </label>
66 + <select id="ixp-country" name="country" defaultValue={country ?? ""} className={`${input} max-w-[60vw]`}>
67 + <option value="">All countries</option>
68 + {countryOptions.map((c) => (
69 + <option key={c.iso2} value={c.iso2}>
70 + {c.name}
71 + </option>
72 + ))}
73 + </select>
74 + <Button type="submit" size="sm" variant="primary">
75 + Apply
76 + </Button>
77 + </form>
78 + <FilterChips chips={chips} resetHref={BASE} className="mt-2" />
79 +
80 + <div className="mt-4">
81 + {res.items.length === 0 ? (
82 + q || country ? (
83 + <EmptyState title="No internet exchange matches these filters" description="Loosen a filter or clear them all." />
84 + ) : (
85 + <EmptyState
86 + title="No internet exchange indexed yet"
87 + description={
88 + <>
89 + PeeringDB IXP data is being ingested; facilities&apos; IXP presence appears on facility pages as soon as it is observed.
90 + {statIxps !== null && statIxps > 0 && <> The index currently counts {fmtInt(statIxps)} exchanges.</>}
91 + </>
92 + }
93 + action={
94 + <Link href={routes.datacenters()} className="text-[13px] text-ink underline decoration-hair-2 hover:text-accent">
95 + Browse data centers
96 + </Link>
97 + }
98 + />
99 + )
100 + ) : (
101 + <>
102 + <Table dense>
103 + <THead>
104 + <tr>
105 + <Th>Exchange</Th>
106 + <Th className="hidden sm:table-cell">City</Th>
107 + <Th>Country</Th>
108 + <Th numeric>Networks</Th>
109 + <Th numeric>Facilities</Th>
110 + <Th className="hidden md:table-cell">Website</Th>
111 + </tr>
112 + </THead>
113 + <TBody>
114 + {res.items.map((x) => (
115 + <Tr key={x.id}>
116 + <Td primary>
117 + <RowLink href={routes.ixp(x.slug)}>{x.name}</RowLink>
118 + {x.nameLong && x.nameLong !== x.name && <div className="text-[11.5px] text-ink-3">{x.nameLong}</div>}
119 + {x.city && <div className="text-[11.5px] text-ink-3 sm:hidden">{x.city}</div>}
120 + </Td>
121 + <Td label="City" className="hidden text-ink-3 sm:table-cell">
122 + {x.city ?? "—"}
123 + </Td>
124 + <Td label="Country">
125 + <CountryFlag iso2={x.countryIso2} withName className="max-w-[160px] text-ink-2" />
126 + </Td>
127 + <Td label="Networks" numeric>
128 + {x.networkCount === null ? <span className="text-ink-4">—</span> : fmtInt(x.networkCount)}
129 + </Td>
130 + <Td label="Facilities" numeric>
131 + {fmtInt(x.facilityCount)}
132 + </Td>
133 + <Td label="Website" className="hidden text-[12.5px] md:table-cell">
134 + {x.website ? <ExtLink href={x.website} className="text-ink-2 no-underline hover:underline" /> : <span className="text-ink-4">—</span>}
135 + </Td>
136 + </Tr>
137 + ))}
138 + </TBody>
139 + </Table>
140 + <Pagination page={page} perPage={PER_PAGE} total={total} hrefFor={(p) => hrefWith(BASE, sp, { page: p > 1 ? p : undefined })} label="exchanges" className="mt-3" />
141 + </>
142 + )}
143 + </div>
144 + <p className="mt-8 text-[12px] text-ink-4">Exchange and network data: PeeringDB (attribution requested) and exchange operators. Network counts are as published by the source at the last observation.</p>
145 + </Page>
146 + );
147 +}
added apps/web/src/app/(site)/ixps/[slug]/layout.tsx +11 −0
@@ -0,0 +1,11 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { api } from "@/lib/api";
4 +
5 +/** Existence check outside the loading boundary so unknown slugs return a real 404 (the fetch is cached for the page). */
6 +export default async function IxpLayout({ children, params }: { children: ReactNode; params: Promise<{ slug: string }> }) {
7 + const { slug } = await params;
8 + const r = await api.ixp(slug);
9 + if (!r.ok && r.status === 404) notFound();
10 + return children;
11 +}
added apps/web/src/app/(site)/ixps/[slug]/loading.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-48" />
8 + <Skeleton className="mb-2 h-3 w-28" />
9 + <Skeleton className="mb-3 h-7 w-56" />
10 + <Skeleton className="mb-5 h-4 w-72" />
11 + <div className="grid grid-cols-3 gap-4 border-y border-hair py-3">
12 + {Array.from({ length: 3 }).map((_, i) => (
13 + <div key={i} className="flex flex-col gap-2">
14 + <Skeleton className="h-7 w-16" />
15 + <Skeleton className="h-2.5 w-24" />
16 + </div>
17 + ))}
18 + </div>
19 + <SkeletonRows rows={6} className="mt-8" />
20 + </Page>
21 + );
22 +}
added apps/web/src/app/(site)/ixps/[slug]/opengraph-image.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { api } from "@/lib/api";
2 +import { countryName, fmtInt } from "@/lib/format";
3 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
4 +
5 +export const alt = "DataCenterIndex internet exchange";
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
11 + const { slug } = await params;
12 + const r = await api.ixp(slug);
13 + if (!r.ok) return renderOg({ eyebrow: "Internet exchange", title: "DataCenterIndex internet exchanges", subtitle: "Exchange points, connected networks and the facilities where they are present." });
14 + const d = r.data;
15 + const country = d.countryName ?? (d.countryIso2 ? countryName(d.countryIso2) : null);
16 + return renderOg({
17 + eyebrow: "Internet exchange",
18 + title: d.name,
19 + subtitle: [d.nameLong && d.nameLong !== d.name ? d.nameLong : null, [d.city, country].filter(Boolean).join(", ")].filter(Boolean).join(" · ") || undefined,
20 + stats: [
21 + { label: "Networks", value: d.networkCount === null ? "—" : fmtInt(d.networkCount) },
22 + { label: "Facilities", value: fmtInt(d.facilityCount) },
23 + { label: "Country", value: country ?? "—" },
24 + ],
25 + });
26 +}
added apps/web/src/app/(site)/ixps/[slug]/page.tsx +153 −0
@@ -0,0 +1,153 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
5 +import { JsonLd } from "@/components/entity/json-ld";
6 +import { ExtLink, KeyValueList } from "@/components/entity/kv";
7 +import { SectionNav } from "@/components/entity/section-nav";
8 +import { SourcesFooter } from "@/components/entity/sources-footer";
9 +import { FacilityTable } from "@/components/facility-table";
10 +import { Page, Section } from "@/components/ui/card";
11 +import { CountryFlag } from "@/components/ui/country-flag";
12 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
13 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
14 +import { api } from "@/lib/api";
15 +import { countryName, fmtInt } from "@/lib/format";
16 +import { routes } from "@/lib/routes";
17 +import { NOT_FOUND_METADATA, absoluteUrl, compactJsonLd, pageMetadata } from "@/lib/seo";
18 +import { countryHrefMap } from "../../cloud-regions/cloud-format";
19 +
20 +export const revalidate = 300;
21 +
22 +type Params = Promise<{ slug: string }>;
23 +
24 +function describe(d: { name: string; nameLong: string | null; city: string | null; countryIso2: string | null; networkCount: number | null; facilityCount: number }): string {
25 + const place = [d.city, d.countryIso2 ? countryName(d.countryIso2) : null].filter(Boolean).join(", ");
26 + const parts = [`${d.name}${d.nameLong && d.nameLong !== d.name ? ` (${d.nameLong})` : ""} — internet exchange point${place ? ` in ${place}` : ""}`];
27 + if (d.networkCount !== null) parts.push(`${fmtInt(d.networkCount)} connected networks`);
28 + parts.push(`present in ${fmtInt(d.facilityCount)} indexed ${d.facilityCount === 1 ? "facility" : "facilities"}`);
29 + return `${parts.join(", ")}.`;
30 +}
31 +
32 +/** External identifier → public URL when the registry is known. */
33 +function idHref(key: string, value: string | number): string | null {
34 + const k = key.toLowerCase();
35 + if (k.includes("peeringdb")) return `https://www.peeringdb.com/ix/${value}`;
36 + if (k === "wikidata") return `https://www.wikidata.org/wiki/${value}`;
37 + if (typeof value === "string" && /^https?:\/\//.test(value)) return value;
38 + return null;
39 +}
40 +
41 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
42 + const { slug } = await params;
43 + const r = await api.ixp(slug);
44 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Internet exchange" };
45 + return pageMetadata({ title: r.data.name, description: describe(r.data), path: routes.ixp(r.data.slug) });
46 +}
47 +
48 +export default async function IxpPage({ params }: { params: Params }) {
49 + const { slug } = await params;
50 + const [r, countryHref] = await Promise.all([api.ixp(slug), countryHrefMap()]);
51 + if (!r.ok && r.status === 404) notFound();
52 + if (!r.ok) {
53 + return (
54 + <Page className="py-6">
55 + <Breadcrumbs items={[{ name: "Internet exchanges", href: routes.ixps() }, { name: slug, href: routes.ixp(slug) }]} className="mb-4" />
56 + <OfflineNotice detail={r.error} />
57 + </Page>
58 + );
59 + }
60 + const d = r.data;
61 + const cName = d.countryName ?? (d.countryIso2 ? countryName(d.countryIso2) : null);
62 + const cHref = countryHref(d.countryIso2);
63 + const ids = Object.entries(d.externalIds ?? {});
64 + const facilities = d.facilities ?? [];
65 +
66 + const jsonLd = compactJsonLd({
67 + "@context": "https://schema.org",
68 + "@type": "Organization",
69 + name: d.name,
70 + alternateName: d.nameLong && d.nameLong !== d.name ? d.nameLong : undefined,
71 + url: absoluteUrl(routes.ixp(d.slug)),
72 + sameAs: [d.website, ...ids.map(([k, v]) => idHref(k, v))].filter((x): x is string => !!x),
73 + description: describe(d),
74 + address: d.city || cName ? compactJsonLd({ "@type": "PostalAddress", addressLocality: d.city, addressCountry: d.countryIso2 }) : undefined,
75 + });
76 +
77 + return (
78 + <Page>
79 + <JsonLd data={jsonLd} />
80 + <Breadcrumbs items={[{ name: "Internet exchanges", href: routes.ixps() }, { name: d.name, href: routes.ixp(d.slug) }]} className="pt-4" />
81 +
82 + <header className="py-4 sm:py-6">
83 + <div className="label-caps mb-1.5">Internet exchange</div>
84 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{d.name}</h1>
85 + {d.nameLong && d.nameLong !== d.name && <p className="mt-1 text-[14px] text-ink-2">{d.nameLong}</p>}
86 + <p className="mt-2 flex flex-wrap items-center gap-x-1.5 text-[13px] text-ink-2">
87 + {d.countryIso2 && <CountryFlag iso2={d.countryIso2} size={13} />}
88 + {d.city && <span>{d.city}</span>}
89 + {cName && (
90 + <>
91 + {d.city && <span className="text-ink-4">·</span>}
92 + {cHref ? (
93 + <Link href={cHref} className="hover:text-ink hover:underline">
94 + {cName}
95 + </Link>
96 + ) : (
97 + <span>{cName}</span>
98 + )}
99 + </>
100 + )}
101 + {d.metro && (
102 + <>
103 + <span className="text-ink-4">·</span>
104 + <Link href={routes.market(d.metro.slug)} className="hover:text-ink hover:underline">
105 + {d.metro.name} market
106 + </Link>
107 + </>
108 + )}
109 + {d.website && (
110 + <>
111 + <span className="text-ink-4">·</span>
112 + <ExtLink href={d.website} className="text-ink-2" />
113 + </>
114 + )}
115 + </p>
116 + </header>
117 +
118 + <MetricRow cols={3}>
119 + <MetricCell>
120 + <Metric value={d.networkCount === null ? "—" : fmtInt(d.networkCount)} label="Connected networks" note={d.networkCount === null ? "not published" : "as published by the source"} />
121 + </MetricCell>
122 + <MetricCell>
123 + <Metric value={fmtInt(d.facilityCount)} label="Facilities" note="indexed data centers with this exchange present" />
124 + </MetricCell>
125 + <MetricCell>
126 + <Metric value={d.regionContinent ?? cName ?? "—"} label={d.regionContinent ? "Region" : "Country"} />
127 + </MetricCell>
128 + </MetricRow>
129 +
130 + <SectionNav items={[{ id: "facilities", label: "Facilities" }, { id: "identifiers", label: "Identifiers", count: ids.length }]} className="mt-4" />
131 +
132 + <div className="mt-6 flex flex-col gap-8">
133 + <Section id="facilities" title="Facilities where this exchange is present" description={facilities.length ? `${fmtInt(facilities.length)} indexed ${facilities.length === 1 ? "facility" : "facilities"}` : undefined} className="scroll-mt-28">
134 + {facilities.length ? <FacilityTable items={facilities} columns={["operator", "market", "country", "status", "mw"]} /> : <EmptyState compact title="No facility linked yet" description="Facility presence comes from the exchange's published facility list or from facility pages; it appears as soon as it is observed." />}
135 + </Section>
136 +
137 + {ids.length > 0 && (
138 + <Section id="identifiers" title="Identifiers" description="External registry identifiers as observed." className="scroll-mt-28">
139 + <KeyValueList
140 + items={ids.map(([k, v]) => {
141 + const href = idHref(k, v);
142 + return { label: k.replace(/_/g, " "), value: href ? <ExtLink href={href}>{String(v)}</ExtLink> : <span className="figure text-[12.5px]">{String(v)}</span> };
143 + })}
144 + columns={2}
145 + />
146 + </Section>
147 + )}
148 + </div>
149 +
150 + <SourcesFooter sources={r.sources} updatedAt={d.updatedAt} note="Exchange facts are shown as published by PeeringDB and the exchange operator; network counts are not estimated." />
151 + </Page>
152 + );
153 +}
added apps/web/src/app/(site)/live/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-24" />
8 + <Skeleton className="mb-6 h-7 w-64" />
9 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
10 + {Array.from({ length: 4 }).map((_, i) => (
11 + <div key={i} className="flex flex-col gap-2">
12 + <Skeleton className="h-7 w-20" />
13 + <Skeleton className="h-2.5 w-24" />
14 + </div>
15 + ))}
16 + </div>
17 + <SkeletonRows rows={8} className="mt-6" />
18 + </Page>
19 + );
20 +}
added apps/web/src/app/(site)/methodology/page.tsx +337 −0
@@ -0,0 +1,337 @@
1 +import type { ConfidenceLevel, GeoPrecision } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import type { ReactNode } from "react";
5 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
6 +import { SectionNav } from "@/components/entity/section-nav";
7 +import { ConfidenceBadge, EstimateMark, PrecisionBadge } from "@/components/ui/badge";
8 +import { Page, PageHeader, Section } from "@/components/ui/card";
9 +import { TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
10 +import { routes } from "@/lib/routes";
11 +import { pageMetadata } from "@/lib/seo";
12 +
13 +export const metadata: Metadata = pageMetadata({
14 + title: "Methodology",
15 + description: "How DataCenterIndex builds its graph: pipeline, provenance, confidence levels, location precision, capacity and estimate rules, reconciliation, change detection, rankings and the legal crawling policy.",
16 + path: routes.methodology(),
17 +});
18 +
19 +const NAV = [
20 + { id: "principles", label: "Principles" },
21 + { id: "pipeline", label: "Pipeline" },
22 + { id: "confidence", label: "Confidence" },
23 + { id: "precision", label: "Precision" },
24 + { id: "capacity", label: "Capacity" },
25 + { id: "reconciliation", label: "Reconciliation" },
26 + { id: "changes", label: "Changes" },
27 + { id: "rankings", label: "Rankings" },
28 + { id: "crawling", label: "Crawling" },
29 + { id: "limitations", label: "Limitations" },
30 +];
31 +
32 +const CONFIDENCE: Array<{ level: ConfidenceLevel; rule: string; example: string }> = [
33 + { level: "verified", rule: "At least two independent primary source kinds agree on the record (for example the operator's page and a planning filing, or a utility and a government registry).", example: "Operator facility page + permit filing both give 48 MW IT." },
34 + { level: "high", rule: "One primary source: operator, government, filing, utility, cloud provider or registry.", example: "Capacity read from the operator's own spec sheet." },
35 + { level: "moderate", rule: "A dataset or community source (Wikidata, OpenStreetMap, PeeringDB) or a primary source that only covers part of the fields.", example: "Location and opening year from Wikidata, no capacity." },
36 + { level: "estimated", rule: "Every observation behind the record is flagged as an estimate by its source. Figures carry the ≈ mark.", example: "Analyst note giving “approximately 30 MW”." },
37 + { level: "unverified", rule: "Only secondary or news sources so far, or the record is a possible duplicate awaiting review (score 0.60–0.92, see reconciliation).", example: "A newly created facility that matched an existing one at 0.7." },
38 +];
39 +
40 +const PRECISION: Array<{ level: GeoPrecision; rule: string; radius: string }> = [
41 + { level: "exact", rule: "Coordinates of the building itself, from the operator, a filing, OpenStreetMap or a surveyed address.", radius: "≈ 250 m" },
42 + { level: "parcel", rule: "Coordinates of the land parcel or campus, not a specific hall.", radius: "≈ 500 m" },
43 + { level: "street", rule: "Geocoded street address; the point sits on the street segment.", radius: "≈ 1 km" },
44 + { level: "approximate", rule: "Hand-entered or interpolated point without a documented address.", radius: "≈ 5 km" },
45 + { level: "city", rule: "Only the city is known (typical for cloud regions and press releases). Drawn as a hollow ring on the map, never labelled exact.", radius: "≈ 15 km" },
46 + { level: "metro", rule: "Only the metro market is known (for example “Northern Virginia”).", radius: "≈ 40 km" },
47 + { level: "unknown", rule: "No usable location; the facility is listed but not mapped.", radius: "—" },
48 +];
49 +
50 +const SCORE: Array<{ signal: string; weight: string; value: string }> = [
51 + { signal: "Name", weight: "0.40", value: "1 for equal normalized names (noise words removed), equal code-preserving names or an alias hit; otherwise the best of token Jaccard and 0.85 × containment." },
52 + { signal: "Facility codes (DC12, FR5, LD8)", weight: "0.15", value: "1 when both names carry codes and one matches, 0 when they conflict; skipped when either side has no code." },
53 + { signal: "Operator", weight: "0.20", value: "1 same operator, 0.5 unknown on either side, 0 different." },
54 + { signal: "Distance", weight: "0.15", value: "1 − 0.4·d/r inside the match radius r derived from both precisions (exact 250 m … metro 40 km), decaying to 0 at 4r; 0.6 for the same city without coordinates." },
55 + { signal: "Address", weight: "0.10", value: "Normalized address equality → 1; same house number → ≥ 0.75; different house numbers → ≤ 0.4; otherwise token Jaccard." },
56 +];
57 +
58 +const COUNTRY_RANKINGS: Array<[string, string, string, string]> = [
59 + ["countries_facilities", "Facility count", "facilities", "Number of facilities located in the country. Secondary: known MW."],
60 + ["countries_known_mw", "Known operational MW", "MW", "Known MW over operational facilities. Coverage gate applies. Secondary: operational facilities."],
61 + ["countries_planned_mw", "Planned MW", "MW", "Pipeline MW over planning statuses. Gate applies."],
62 + ["countries_construction_mw", "MW under construction", "MW", "Pipeline MW over facilities under construction. Gate applies."],
63 + ["countries_hyperscale", "Hyperscale facilities", "facilities", "Facilities flagged hyperscale (self-described, or operated by AWS, Microsoft, Google, Meta, Oracle, Alibaba Cloud, Tencent Cloud, Apple, IBM or Huawei Cloud)."],
64 + ["countries_ai", "AI facilities", "facilities", "Facilities flagged as AI / accelerated-computing sites by their operator or a primary source."],
65 + ["countries_cloud_regions", "Cloud regions", "regions", "Public cloud regions (announced or operational; retired excluded) across indexed providers."],
66 + ["countries_facilities_per_million", "Facilities per million residents", "per million", "facilities ÷ (population / 1 000 000). Requires ≥ 5 facilities and a population figure."],
67 + ["countries_mw_per_capita", "Known MW per capita", "kW per 1 000 residents", "known MW × 1 000 000 ÷ population. Gate applies."],
68 + ["countries_mw_per_gdp", "Known MW per $bn GDP", "MW per $bn", "known MW ÷ (GDP in current US$ / 10⁹). Gate applies."],
69 + ["countries_growth", "Facilities opened in the last 3 years", "facilities", "Facilities whose (possibly partial) opening date is in the current year − 3 or later. Facilities without an opening date are not counted, so growth is understated where operators do not publish dates."],
70 +];
71 +const METRO_RANKINGS: Array<[string, string, string, string]> = [
72 + ["metros_facilities", "Facility count", "facilities", "Facilities assigned to the metro (within its 40–80 km radius, or by listed sub-market when no coordinates). Metros never span borders."],
73 + ["metros_known_mw", "Known operational MW", "MW", "Known MW. Gate applies."],
74 + ["metros_pipeline_mw", "Pipeline MW", "MW", "Planned MW + construction MW. Gate applies."],
75 + ["metros_operators", "Operator count", "operators", "Distinct operators with ≥ 1 facility in the metro."],
76 +];
77 +const OPERATOR_RANKINGS: Array<[string, string, string, string]> = [
78 + ["operators_facilities", "Facility count", "facilities", "Facilities operated (operator_id; owners and tenants are not counted). Secondary: countries."],
79 + ["operators_countries", "Country footprint", "countries", "Distinct countries with ≥ 1 operated facility."],
80 + ["operators_metros", "Metro footprint", "metros", "Distinct metros with ≥ 1 operated facility (facilities outside any defined metro are not counted)."],
81 + ["operators_known_mw", "Known operational MW", "MW", "Known MW. Gate applies."],
82 + ["operators_pipeline_mw", "Pipeline MW", "MW", "Planned MW + construction MW. Gate applies."],
83 +];
84 +
85 +function P({ children }: { children: ReactNode }) {
86 + return <p className="mt-3 first:mt-0">{children}</p>;
87 +}
88 +
89 +function RankingTable({ rows }: { rows: Array<[string, string, string, string]> }) {
90 + return (
91 + <Table dense>
92 + <THead>
93 + <tr>
94 + <Th>Ranking</Th>
95 + <Th className="hidden sm:table-cell">Unit</Th>
96 + <Th>Method</Th>
97 + </tr>
98 + </THead>
99 + <TBody>
100 + {rows.map(([key, label, unit, method]) => (
101 + <Tr key={key}>
102 + <Td primary>
103 + <Link href={routes.ranking(key)} className="font-medium text-ink hover:text-accent hover:underline">
104 + {label}
105 + </Link>
106 + <div className="figure text-[11px] text-ink-4">{key}</div>
107 + </Td>
108 + <Td label="Unit" className="hidden text-[12.5px] text-ink-3 sm:table-cell">
109 + {unit}
110 + </Td>
111 + <Td label="Method" className="text-[12.5px] text-ink-2">
112 + {method}
113 + </Td>
114 + </Tr>
115 + ))}
116 + </TBody>
117 + </Table>
118 + );
119 +}
120 +
121 +const linkCls = "text-ink underline decoration-hair-2 hover:text-accent";
122 +
123 +export default function MethodologyPage() {
124 + return (
125 + <Page className="max-w-[880px]">
126 + <Breadcrumbs items={[{ name: "Methodology", href: routes.methodology() }]} className="pt-3" />
127 + <PageHeader eyebrow="Methodology" title="How the index is built" description="Every figure in DataCenterIndex is observed, never imputed. This page explains the pipeline, the confidence and precision scales, how capacity is counted, how records from different sources are reconciled, how changes are detected, how rankings are computed and how we crawl." className="pt-3" />
128 + <SectionNav items={NAV} />
129 +
130 + <div className="mt-6 flex flex-col gap-10 text-[14.5px] leading-relaxed text-ink-2">
131 + <Section id="principles" className="scroll-mt-28" title="1 · Principles">
132 + <P>
133 + <strong className="text-ink">Nothing is invented.</strong> There are no synthetic facilities, capacities, coordinates or dates. When a source does not publish a figure the page shows “—”, never zero, and totals are <em>lower bounds</em> computed from known values only. A figure a source itself calls an estimate is marked <EstimateMark /> and stored with an <code className="figure text-[12.5px]">isEstimate</code> flag.
134 + </P>
135 + <P>
136 + <strong className="text-ink">Provenance on every field.</strong> Each value — a name, a status, an IT capacity, an opening year, a coordinate — is backed by a provenance row: the source, the URL it was read from, when it was first and last observed, when it was retrieved, the extraction method and a confidence level. Facility and project pages expose this table in full, and every figure carries a provenance popover.
137 + </P>
138 + <P>
139 + <strong className="text-ink">Location honesty.</strong> A coordinate that only identifies a city is stored with precision <em>city</em> and drawn as a hollow ring on the map. It is never upgraded to <em>exact</em> and never overwritten by a less precise point.
140 + </P>
141 + <P>
142 + <strong className="text-ink">The product is the graph and its history</strong>, not the crawler. Records are versioned; changes are detected, deduplicated and published to the <Link href={routes.live()} className={linkCls}>live feed</Link> with their significance.
143 + </P>
144 + </Section>
145 +
146 + <Section id="pipeline" className="scroll-mt-28" title="2 · Pipeline">
147 + <P>Every source goes through the same fixed sequence; there is no shortcut from a scraped page to the site.</P>
148 + <ol className="mt-3 flex flex-col gap-2.5 border-l border-hair pl-4">
149 + {[
150 + ["Discover", "URLs are found through sitemaps, RSS feeds, index pages and link following, scoped to the source's domain and page types (facility pages, newsroom, region lists, filings)."],
151 + ["Fetch", "Pages are retrieved politely (robots.txt, per-host rate limits, conditional requests). Four escalation levels exist: L1 direct HTTP with the bot identity, L2 direct with a browser identity, L3 a rendering service (Firecrawl), L4 a rendered fetch with anti-bot handling (Scrapfly). We escalate only when a level is blocked or returns a JavaScript shell, and premium levels have daily budgets."],
152 + ["Archive", "Every fetched body is content-hashed and stored compressed in object storage with its headers, so any value can be traced back to the exact document version it came from."],
153 + ["Extract", "Structured data first (JSON-LD, embedded JSON, tables), then declarative selectors, then source-specific parsers for PDFs and irregular pages. Extraction is validated against live pages before a connector ships."],
154 + ["Normalize", "Units and formats are unified: MW from kW/MVA, partial dates (2027, 2027-06, 2027-Q2), statuses and facility types to a fixed vocabulary, money to USD with the original currency kept."],
155 + ["Reconcile", "The normalized record is matched against existing facilities, operators, projects, cloud regions and internet exchanges (see section 6). Conflicting values are merged by source authority."],
156 + ["Validate", "Range and consistency checks (a 5 GW single building is rejected; an opening date in the future for an operational facility is flagged). Invalid records are counted, not silently dropped."],
157 + ["Version", "Each field change creates a new provenance observation; the previous value remains queryable."],
158 + ["Detect changes", "Tracked fields are compared with the current record; a change becomes an event with a significance score, deduplicated per day (section 7)."],
159 + ["Publish", "The API and the site read only reconciled, validated records with their provenance and events."],
160 + ].map(([t, d]) => (
161 + <li key={t} className="relative">
162 + <span aria-hidden className="absolute -left-[20.5px] top-[7px] size-2 rounded-full bg-ink-4 ring-2 ring-paper" />
163 + <span className="font-medium text-ink">{t}.</span> {d}
164 + </li>
165 + ))}
166 + </ol>
167 + </Section>
168 +
169 + <Section id="confidence" className="scroll-mt-28" title="3 · Confidence levels">
170 + <P>Confidence is computed per record from the kind of its best source and the number of distinct primary source kinds that corroborate it. Primary kinds are operator, government, filing, utility, cloud provider and registry; dataset and community sources are secondary; news is tertiary.</P>
171 + <div className="mt-4">
172 + <Table dense>
173 + <THead>
174 + <tr>
175 + <Th>Level</Th>
176 + <Th>Rule</Th>
177 + <Th className="hidden md:table-cell">Example</Th>
178 + </tr>
179 + </THead>
180 + <TBody>
181 + {CONFIDENCE.map((c) => (
182 + <Tr key={c.level}>
183 + <Td primary>
184 + <ConfidenceBadge level={c.level} short={false} />
185 + </Td>
186 + <Td label="Rule" className="text-[13px]">
187 + {c.rule}
188 + </Td>
189 + <Td label="Example" className="hidden text-[12.5px] text-ink-3 md:table-cell">
190 + {c.example}
191 + </Td>
192 + </Tr>
193 + ))}
194 + </TBody>
195 + </Table>
196 + </div>
197 + <P>
198 + <strong className="text-ink">Completeness</strong> (0–100) is a separate, purely mechanical score of how many fields are filled, recomputed after every write: location 15 (8 when city/metro/approximate), operator 10, address 10, status 10, capacity 20 (14 when estimated; 12 or 8 when only a planned figure exists), type 5, opening date 10, website 5, description 5, tenants and internet exchanges 10. It says how much we know, not how sure we are.
199 + </P>
200 + </Section>
201 +
202 + <Section id="precision" className="scroll-mt-28" title="4 · Location precision">
203 + <P>Each coordinate carries a precision level. The map draws exact, parcel and street points as filled dots and everything less precise as hollow rings; the facility page states the level in words. Reconciliation never replaces a point with a less precise one (rank: exact › parcel › street › approximate › city › metro › unknown); at equal precision the newest observation wins.</P>
204 + <div className="mt-4">
205 + <Table dense>
206 + <THead>
207 + <tr>
208 + <Th>Level</Th>
209 + <Th>Meaning</Th>
210 + <Th className="hidden sm:table-cell">Match radius</Th>
211 + </tr>
212 + </THead>
213 + <TBody>
214 + {PRECISION.map((p) => (
215 + <Tr key={p.level}>
216 + <Td primary>
217 + <PrecisionBadge precision={p.level} short={false} />
218 + </Td>
219 + <Td label="Meaning" className="text-[13px]">
220 + {p.rule}
221 + </Td>
222 + <Td label="Match radius" className="figure hidden text-[12.5px] text-ink-3 sm:table-cell">
223 + {p.radius}
224 + </Td>
225 + </Tr>
226 + ))}
227 + </TBody>
228 + </Table>
229 + </div>
230 + </Section>
231 +
232 + <Section id="capacity" className="scroll-mt-28" title="5 · Capacity & estimates">
233 + <P>Three capacity figures are stored separately and never mixed on a facility page: <strong className="text-ink">IT capacity</strong> (critical load available to equipment), <strong className="text-ink">total power</strong> (grid connection or facility power, when the operator publishes only that) and <strong className="text-ink">planned power</strong> (announced for a future phase). Lists and rankings use the best available figure, <code className="figure text-[12.5px]">mw = COALESCE(it_capacity_mw, total_power_mw, planned_power_mw)</code>, and say which one it is.</P>
234 + <P>
235 + Aggregates are grouped by status: <em>known MW</em> sums operational, partially operational and expansion facilities; <em>construction MW</em> sums facilities under construction; <em>planned MW</em> sums rumored, proposed, announced, permitting, approved and delayed facilities (planned figure first). Each aggregate exposes its <em>coverage</em> — the share of facilities that carry any MW figure — because a country where 20 % of facilities publish capacity cannot be compared with one where 80 % do.
236 + </P>
237 + <P>
238 + A facility is flagged <code className="figure text-[12.5px]">mw_is_estimate</code> only when <em>every</em> current capacity observation is an estimate; a single measured figure clears the flag. Datasets, community, secondary and news sources never overwrite a capacity that came from an operator, government, filing, utility, cloud-provider or registry source. Nothing is imputed to facilities without a published or filed figure.
239 + </P>
240 + </Section>
241 +
242 + <Section id="reconciliation" className="scroll-mt-28" title="6 · Reconciliation">
243 + <P>An incoming facility record is resolved in a fixed order: (1) its connector key (for example <code className="figure text-[12.5px]">peeringdb:fac:12</code>) already mapped to a facility always wins; (2) any shared external identifier (PeeringDB, OpenStreetMap, Wikidata…) on a live facility; (3) otherwise candidates within a 40 km box, with the same normalized name or alias in the same country, or belonging to the same operator in the same country are scored.</P>
244 + <div className="mt-4">
245 + <Table dense>
246 + <THead>
247 + <tr>
248 + <Th>Signal</Th>
249 + <Th numeric>Weight</Th>
250 + <Th>Value</Th>
251 + </tr>
252 + </THead>
253 + <TBody>
254 + {SCORE.map((s) => (
255 + <Tr key={s.signal}>
256 + <Td primary>{s.signal}</Td>
257 + <Td label="Weight" numeric>
258 + {s.weight}
259 + </Td>
260 + <Td label="Value" className="text-[12.5px]">
261 + {s.value}
262 + </Td>
263 + </Tr>
264 + ))}
265 + </TBody>
266 + </Table>
267 + </div>
268 + <P>Weights are redistributed over the signals available on both sides. Extra rules: same operator with a matching facility code and compatible geography raises the name signal to 0.95 (this is what folds “DC12” into “Equinix DC12”); conflicting codes cap it at 0.45; different countries cap the score at 0.30; precise points more than four radii apart cap it at 0.50; two different known operators never merge unless both the name and the address are exact matches (an operator change on the same building).</P>
269 + <P>
270 + <strong className="text-ink">Decisions.</strong> A score of <strong className="text-ink">0.92 or more</strong> merges automatically (audited). Between <strong className="text-ink">0.60 and 0.92</strong> the record is created as a separate facility with confidence <em>unverified</em> and queued for human review — nothing is hidden and nothing is lost; approving the match later folds the two records (aliases, provenance, tenants, events and keys move to the survivor). Below 0.60 a new facility is created.
271 + </P>
272 + <P>
273 + <strong className="text-ink">Field merge.</strong> An empty value is filled; a source re-observing its own value refreshes it; a measured value beats an estimate whatever the source; otherwise the higher authority wins — operator, government and filings (4), utility and cloud provider (3.5), registry (3), dataset (2.5), community (2), secondary (1.5), news (1), estimates −1.5 — and ties go to the most recent observation. Flags such as AI and hyperscale only ever turn on; certifications, aliases and identifiers accumulate; a replaced name becomes an alias; <em>unknown</em> never overwrites a known status or type.
274 + </P>
275 + <P>
276 + <strong className="text-ink">Operators</strong> resolve through a curated table of about 175 brands and their subsidiaries (Interxion, Telx and DuPont Fabros fold into Digital Realty; RagingWire, e-shelter, Gyron and NetMagic into NTT Global Data Centers), then exact normalized name, alias, and finally trigram similarity ≥ 0.92 combined with the same website domain. News text can only link to a known operator, never create one. <strong className="text-ink">Projects</strong> resolve by key, external ids, identical source URL, then same country + same or unknown operator + name similarity ≥ 0.85; timeline rows are deduplicated on date, type and description.
277 + </P>
278 + </Section>
279 +
280 + <Section id="changes" className="scroll-mt-28" title="7 · Change detection & events">
281 + <P>Tracked facility fields are status, IT / total / planned MW, opening, announcement and construction-start dates, operator, owner, facility type and name. Each change becomes an event with a significance from 0 to 100: status 90, operator 85, owner 70, capacity change of 20 % or more 80 (otherwise 50), dates 60, other fields 20. First-time values are capped at 50, and enrichment of a name, type or operator that was previously unknown is not an event.</P>
282 + <P>New records emit <em>facility discovered</em> (65 when the facility has ≥ 50 MW or is in the pipeline, else 40) and <em>project announced</em> (75 when ≥ 100 MW planned). News articles emit one event per article when they carry a recognised event type with significance ≥ 40. Events are deduplicated by a fingerprint of entity, type, new value and day, so the same change seen on several pages the same day is one event. Every event links to the page it was detected on and shows the old and new value; events can be reviewed and rejected by an editor, in which case they leave the feed.</P>
283 + </Section>
284 +
285 + <Section id="rankings" className="scroll-mt-28" title="8 · Rankings">
286 + <P>Rankings are recomputed by the worker and are only as good as the coverage of the index; every MW-based row therefore shows its coverage. Definitions shared by all rankings:</P>
287 + <ul className="mt-3 list-disc space-y-1.5 pl-5">
288 + <li><strong className="text-ink">Facility</strong> — a live record, any status; duplicates folded by reconciliation are excluded.</li>
289 + <li><strong className="text-ink">Operational</strong> — operational, partially operational or expansion. <strong className="text-ink">Under construction</strong> — under_construction. <strong className="text-ink">Planned</strong> — rumored, proposed, announced, permitting, approved or delayed.</li>
290 + <li><strong className="text-ink">Known MW</strong> — Σ over operational facilities of IT capacity, or total power when only that is published. Estimates count only when no measured figure exists and are flagged. No MW is imputed.</li>
291 + <li><strong className="text-ink">Pipeline MW</strong> — Σ over not-yet-operational facilities of planned power (or IT / total power when no planned figure exists), split into planned MW and construction MW.</li>
292 + <li><strong className="text-ink">Coverage</strong> — facilities with any MW figure ÷ facilities.</li>
293 + <li><strong className="text-ink">Coverage gate</strong> — MW-based and per-capita rankings exclude entities with fewer than <strong className="text-ink">5 facilities</strong> or coverage below <strong className="text-ink">35 %</strong>, so thin data cannot produce spurious leaders. Count-based rankings have no gate. Excluded entities are simply absent — they are not estimated.</li>
294 + <li><strong className="text-ink">Delta</strong> — rank change versus the previous snapshot of the same ranking (positive = moved up).</li>
295 + </ul>
296 + <P>Population and GDP come from the latest World Bank vintage stored on the country; the vintage is shown on country pages.</P>
297 + <h3 className="mt-6 text-[14px] font-semibold text-ink">Countries</h3>
298 + <div className="mt-2">
299 + <RankingTable rows={COUNTRY_RANKINGS} />
300 + </div>
301 + <h3 className="mt-6 text-[14px] font-semibold text-ink">Markets (metros)</h3>
302 + <div className="mt-2">
303 + <RankingTable rows={METRO_RANKINGS} />
304 + </div>
305 + <h3 className="mt-6 text-[14px] font-semibold text-ink">Operators</h3>
306 + <div className="mt-2">
307 + <RankingTable rows={OPERATOR_RANKINGS} />
308 + </div>
309 + <P>
310 + Known limitations: known MW mixes IT capacity and total power when operators publish only one of them (the facility page shows which); primary-source disclosure is much higher in North America and Europe than elsewhere, so cross-region MW comparisons must be read with the coverage column; per-capita and per-GDP rankings depend on the World Bank vintage. See all <Link href={routes.rankings()} className={linkCls}>rankings</Link>.
311 + </P>
312 + </Section>
313 +
314 + <Section id="crawling" className="scroll-mt-28" title="9 · Legal crawling policy">
315 + <ul className="list-disc space-y-1.5 pl-5">
316 + <li><strong className="text-ink">robots.txt is respected</strong> for every host, including <code className="figure text-[12.5px]">Crawl-delay</code>; rules are re-read at least daily. The few exceptions are public query APIs governed by their own published limits (for example the Wikidata SPARQL endpoint), and they are documented on the <Link href={routes.sources()} className={linkCls}>source page</Link>.</li>
317 + <li><strong className="text-ink">Rate limits per host</strong> — by default 30 requests per minute with at most 2 concurrent connections, lower where the source asks for it.</li>
318 + <li><strong className="text-ink">Conditional requests and content hashing</strong> — pages are fetched with <code className="figure text-[12.5px]">If-None-Match</code> / <code className="figure text-[12.5px]">If-Modified-Since</code>, and an unchanged body (same hash) is never reprocessed; premium rendering credits are never spent on unchanged pages.</li>
319 + <li><strong className="text-ink">No circumvention</strong> — no login, paywall or CAPTCHA is bypassed; only publicly reachable pages are read.</li>
320 + <li><strong className="text-ink">Escalation only when blocked</strong> — a rendering service is used only when the direct fetch returns a JavaScript shell or a block page, within daily budgets.</li>
321 + <li><strong className="text-ink">Attribution and licence</strong> are recorded for every source and displayed wherever its values appear. We extract facts (a capacity, a date, an address), not copyrighted prose; descriptions are short and attributed.</li>
322 + </ul>
323 + <P>
324 + The crawler identifies itself as <Link href={routes.bot()} className={linkCls}>DataCenterIndexBot</Link>; the bot page explains how to opt out or request a correction.
325 + </P>
326 + </Section>
327 +
328 + <Section id="limitations" className="scroll-mt-28" title="10 · Limitations & corrections">
329 + <P>Coverage is uneven: it follows what operators, regulators and registries publish, which is far denser in North America and Europe than in other regions, and denser for colocation and hyperscale sites than for enterprise or government facilities. Capacity figures are lower bounds; the absence of a figure means it was not published, not that it is zero. Cloud-region coordinates are city-level by construction. Rankings are recomputed as coverage grows and their history is kept.</P>
330 + <P>
331 + Corrections, additional sources and removal requests: <a href="mailto:contact@spboucher.ai" className={linkCls}>contact@spboucher.ai</a>. Every value on the site can be traced to a document version, so a correction is applied at the source and propagates through the same pipeline as any other observation.
332 + </P>
333 + </Section>
334 + </div>
335 + </Page>
336 + );
337 +}
added apps/web/src/app/(site)/operators/(index)/loading.tsx +18 −0
@@ -0,0 +1,18 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-16" />
8 + <Skeleton className="mb-2 h-7 w-48" />
9 + <Skeleton className="mb-6 h-3.5 w-80 max-w-full" />
10 + <div className="mb-4 flex gap-2">
11 + <Skeleton className="h-9 flex-1" />
12 + <Skeleton className="h-9 w-32" />
13 + <Skeleton className="h-9 w-20" />
14 + </div>
15 + <SkeletonRows rows={12} />
16 + </Page>
17 + );
18 +}
added apps/web/src/app/(site)/operators/(index)/page.tsx +169 −0
@@ -0,0 +1,169 @@
1 +import type { OperatorKind } from "@dci/core";
2 +import { OPERATOR_KINDS } from "@dci/core";
3 +import type { Metadata } from "next";
4 +import { Search } from "lucide-react";
5 +import { Badge, EstimateMark } from "@/components/ui/badge";
6 +import { Button } from "@/components/ui/button";
7 +import { Page, PageHeader } from "@/components/ui/card";
8 +import { CountryFlag } from "@/components/ui/country-flag";
9 +import { FilterChips, type Chip } from "@/components/ui/filter-chips";
10 +import { Pagination } from "@/components/ui/pagination";
11 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
12 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
13 +import { api } from "@/lib/api";
14 +import { countryName, fmtInt, fmtMw } from "@/lib/format";
15 +import { OPERATOR_KIND_LABEL } from "@/lib/labels";
16 +import { routes } from "@/lib/routes";
17 +import { hrefWith, parseSort, sortTh, spGet, spInt, type SearchParams } from "@/lib/sort";
18 +
19 +export const revalidate = 120;
20 +
21 +export const metadata: Metadata = {
22 + title: "Operators",
23 + description: "Data center operators, hyperscalers, carriers and developers indexed worldwide: facilities, country footprint, known and pipeline capacity.",
24 + alternates: { canonical: "/operators" },
25 +};
26 +
27 +const PER_PAGE = 50;
28 +const SORTS = ["facilities", "name", "mw"] as const;
29 +const KIND_SET = new Set<string>(OPERATOR_KINDS);
30 +
31 +const selectCls = "h-9 min-w-0 rounded-sm border border-hair-2 bg-surface px-2 text-[13px] text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent";
32 +
33 +export default async function OperatorsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
34 + const sp = await searchParams;
35 + const base = routes.operators();
36 + const q = spGet(sp, "q");
37 + const kindRaw = spGet(sp, "kind");
38 + const kind = kindRaw && KIND_SET.has(kindRaw) ? (kindRaw as OperatorKind) : undefined;
39 + const country = spGet(sp, "country")?.toUpperCase();
40 + const page = spInt(sp, "page", 1);
41 + const sort = parseSort(sp, SORTS, { key: "facilities", order: "desc" });
42 +
43 + const [res, countriesRes] = await Promise.all([
44 + api.operators({ q, kind, country, sort: sort.key, order: sort.order, page, per_page: PER_PAGE }),
45 + api.countries(),
46 + ]);
47 + const ok = res.total > 0 || res.items.length > 0 || (await api.health()).ok;
48 + const countries = countriesRes.items.filter((c) => c.facilityCount > 0).sort((a, b) => a.name.localeCompare(b.name));
49 +
50 + const chips: Chip[] = [];
51 + if (q) chips.push({ key: "q", label: "Search", value: q, removeHref: hrefWith(base, sp, { q: undefined }) });
52 + if (kind) chips.push({ key: "kind", label: "Kind", value: OPERATOR_KIND_LABEL[kind], removeHref: hrefWith(base, sp, { kind: undefined }) });
53 + if (country) chips.push({ key: "country", label: "HQ", value: countryName(country), removeHref: hrefWith(base, sp, { country: undefined }) });
54 +
55 + const th = (key: (typeof SORTS)[number]) => sortTh(base, sp, sort, key);
56 +
57 + return (
58 + <Page>
59 + <PageHeader eyebrow="Index" title="Operators" description={ok ? `${fmtInt(res.total)} operators indexed — colocation providers, hyperscalers, carriers, developers and enterprises. Capacity figures are lower bounds from published values.` : "Operators, their facilities, country footprint and known capacity."} />
60 + {!ok && <OfflineNotice className="mb-3" />}
61 +
62 + <form method="get" action={base} className="mb-3 flex flex-wrap items-center gap-2" role="search">
63 + <label className="relative min-w-0 flex-1 basis-[200px]">
64 + <span className="sr-only">Search operators</span>
65 + <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-ink-4" strokeWidth={1.75} />
66 + <input type="search" name="q" defaultValue={q ?? ""} placeholder="Search operators…" className={`${selectCls} w-full pl-8`} />
67 + </label>
68 + <select name="kind" defaultValue={kind ?? ""} aria-label="Kind" className={`${selectCls} basis-[140px]`}>
69 + <option value="">All kinds</option>
70 + {OPERATOR_KINDS.map((k) => (
71 + <option key={k} value={k}>
72 + {OPERATOR_KIND_LABEL[k]}
73 + </option>
74 + ))}
75 + </select>
76 + <select name="country" defaultValue={country ?? ""} aria-label="Headquarters country" className={`${selectCls} basis-[160px]`}>
77 + <option value="">Any HQ country</option>
78 + {countries.map((c) => (
79 + <option key={c.iso2} value={c.iso2}>
80 + {c.name}
81 + </option>
82 + ))}
83 + </select>
84 + {sort.key !== "facilities" && <input type="hidden" name="sort" value={sort.key} />}
85 + {sort.key !== "facilities" && <input type="hidden" name="order" value={sort.order} />}
86 + <Button type="submit" size="md" variant="primary">
87 + Filter
88 + </Button>
89 + </form>
90 + <FilterChips chips={chips} resetHref={base} className="mb-2" />
91 +
92 + {res.items.length === 0 ? (
93 + <EmptyState title={ok ? "No operators match" : "Operators unavailable"} description={ok ? "Loosen a filter or clear them all." : "The index API is not reachable right now."} compact />
94 + ) : (
95 + <Table>
96 + <THead>
97 + <tr>
98 + <Th {...th("name")}>Operator</Th>
99 + <Th className="hidden sm:table-cell">Kind</Th>
100 + <Th>HQ</Th>
101 + <Th numeric {...th("facilities")}>
102 + Facilities
103 + </Th>
104 + <Th numeric>Countries</Th>
105 + <Th numeric className="hidden md:table-cell">
106 + Metros
107 + </Th>
108 + <Th numeric {...th("mw")}>
109 + Known MW
110 + </Th>
111 + <Th numeric>Pipeline</Th>
112 + <Th numeric className="hidden lg:table-cell">
113 + Projects
114 + </Th>
115 + </tr>
116 + </THead>
117 + <TBody>
118 + {res.items.map((o) => (
119 + <Tr key={o.id}>
120 + <Td primary>
121 + <div className="flex flex-wrap items-center gap-2">
122 + <RowLink href={routes.operator(o.slug)}>{o.name}</RowLink>
123 + {o.kind && (
124 + <Badge tone="outline" size="xs" className="sm:hidden">
125 + {OPERATOR_KIND_LABEL[o.kind]}
126 + </Badge>
127 + )}
128 + </div>
129 + </Td>
130 + <Td label="Kind" className="hidden sm:table-cell">
131 + {o.kind ? <Badge tone="outline" size="xs">{OPERATOR_KIND_LABEL[o.kind]}</Badge> : <span className="text-ink-4">—</span>}
132 + </Td>
133 + <Td label="HQ">
134 + <CountryFlag iso2={o.hqCountryIso2} withName className="max-w-[160px] text-ink-2" />
135 + </Td>
136 + <Td label="Facilities" numeric>
137 + {fmtInt(o.facilityCount)}
138 + </Td>
139 + <Td label="Countries" numeric>
140 + {fmtInt(o.countryCount)}
141 + </Td>
142 + <Td label="Metros" numeric className="hidden md:table-cell">
143 + {o.metroCount === undefined ? <span className="text-ink-4">—</span> : fmtInt(o.metroCount)}
144 + </Td>
145 + <Td label="Known MW" numeric>
146 + {o.knownMw === null || o.knownMw === undefined ? <span className="text-ink-4">—</span> : fmtMw(o.knownMw)}
147 + </Td>
148 + <Td label="Pipeline" numeric>
149 + {o.plannedMw === null || o.plannedMw === undefined ? (
150 + <span className="text-ink-4">—</span>
151 + ) : (
152 + <span title="Planned + under-construction power (lower bound)">
153 + <EstimateMark className="mr-0.5" />
154 + {fmtMw(o.plannedMw)}
155 + </span>
156 + )}
157 + </Td>
158 + <Td label="Projects" numeric className="hidden lg:table-cell">
159 + {o.projectCount === undefined ? <span className="text-ink-4">—</span> : fmtInt(o.projectCount)}
160 + </Td>
161 + </Tr>
162 + ))}
163 + </TBody>
164 + </Table>
165 + )}
166 + <Pagination page={page} perPage={PER_PAGE} total={res.total} hrefFor={(p) => hrefWith(base, sp, { page: p > 1 ? p : undefined })} label="operators" />
167 + </Page>
168 + );
169 +}
added apps/web/src/app/(site)/operators/[slug]/bars.tsx +9 −0
@@ -0,0 +1,9 @@
1 +"use client";
2 +
3 +import { BarList, type BarListItem } from "@/components/ui/charts";
4 +import { fmtInt, fmtMw } from "@/lib/format";
5 +
6 +/** Server pages cannot pass `format` functions to the client BarList — pick the formatter by name here. */
7 +export function CountBarList({ items, unit = "count", showRank = true, className }: { items: BarListItem[]; unit?: "count" | "mw"; showRank?: boolean; className?: string }) {
8 + return <BarList items={items} format={unit === "mw" ? (v) => fmtMw(v) : (v) => fmtInt(v)} showRank={showRank} className={className} />;
9 +}
added apps/web/src/app/(site)/operators/[slug]/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { api } from "@/lib/api";
4 +
5 +/**
6 + * Existence gate: a 404 from the API becomes a real HTTP 404 here, before the segment's loading.tsx
7 + * streams a 200 shell. The page re-fetches the same URL (deduplicated by the fetch cache).
8 + */
9 +export default async function OperatorLayout({ children, params }: { children: ReactNode; params: Promise<{ slug: string }> }) {
10 + const { slug } = await params;
11 + const r = await api.operator(slug);
12 + if (!r.ok && r.status === 404) notFound();
13 + return <>{children}</>;
14 +}
added apps/web/src/app/(site)/operators/[slug]/loading.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonBlock, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-4" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-40" />
8 + <Skeleton className="mb-2 h-8 w-64 max-w-full" />
9 + <Skeleton className="mb-6 h-3.5 w-80 max-w-full" />
10 + <div className="grid grid-cols-3 gap-4 border-y border-hair py-3 sm:grid-cols-6">
11 + {Array.from({ length: 6 }).map((_, i) => (
12 + <div key={i} className="flex flex-col gap-2">
13 + <Skeleton className="h-7 w-16" />
14 + <Skeleton className="h-2.5 w-20" />
15 + </div>
16 + ))}
17 + </div>
18 + <div className="mt-6 grid gap-6 md:grid-cols-2">
19 + <SkeletonRows rows={6} />
20 + <SkeletonBlock className="h-40" />
21 + </div>
22 + <SkeletonBlock className="mt-6 h-[220px]" />
23 + <SkeletonRows rows={8} className="mt-6" />
24 + </Page>
25 + );
26 +}
added apps/web/src/app/(site)/operators/[slug]/opengraph-image.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import { api } from "@/lib/api";
2 +import { countryName, fmtInt, fmtMw } from "@/lib/format";
3 +import { OPERATOR_KIND_LABEL } from "@/lib/labels";
4 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
5 +
6 +export const alt = "Operator profile on DataCenterIndex";
7 +export const size = OG_SIZE;
8 +export const contentType = OG_CONTENT_TYPE;
9 +export const revalidate = 3600;
10 +
11 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
12 + const { slug } = await params;
13 + const r = await api.operator(slug);
14 + if (!r.ok) return renderOg({ eyebrow: "Operator", title: slug.replace(/-/g, " "), subtitle: "Facilities, footprint, capacity and change history." });
15 + const o = r.data;
16 + const sub = [o.kind ? OPERATOR_KIND_LABEL[o.kind] : null, o.hqCountryIso2 ? `HQ ${countryName(o.hqCountryIso2)}` : null].filter(Boolean).join(" · ");
17 + return renderOg({
18 + eyebrow: "Operator",
19 + title: o.name,
20 + subtitle: sub || "Data center operator",
21 + stats: [
22 + { label: "Facilities", value: fmtInt(o.facilityCount) },
23 + { label: "Countries", value: fmtInt(o.countryCount) },
24 + { label: "Known MW", value: o.knownMw ? fmtMw(o.knownMw) : "—" },
25 + { label: "Pipeline", value: o.plannedMw ? `≈ ${fmtMw(o.plannedMw)}` : "—" },
26 + ],
27 + });
28 +}
added apps/web/src/app/(site)/operators/[slug]/page.tsx +251 −0
@@ -0,0 +1,251 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
5 +import { EntityMap } from "@/components/entity/entity-map";
6 +import { JsonLd } from "@/components/entity/json-ld";
7 +import { ExtLink } from "@/components/entity/kv";
8 +import { fPageHref, PagedFacilities, parseFPage } from "@/components/entity/paged-facilities";
9 +import { SectionNav } from "@/components/entity/section-nav";
10 +import { SourcesFooter } from "@/components/entity/sources-footer";
11 +import { EventList } from "@/components/event-list";
12 +import { ProjectList } from "@/components/project-list";
13 +import { Badge, EstimateMark } from "@/components/ui/badge";
14 +import { Page, Section } from "@/components/ui/card";
15 +import { StackedBar } from "@/components/ui/charts";
16 +import { CountBarList } from "./bars";
17 +import { CountryFlag } from "@/components/ui/country-flag";
18 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
19 +import { EmptyState, ErrorState, OfflineNotice } from "@/components/ui/states";
20 +import { SourceHistory } from "@/components/ui/timeline";
21 +import { api } from "@/lib/api";
22 +import type { DetailFacilitiesMeta } from "@/lib/dto";
23 +import { countryName, fmtInt, fmtMw, pluralize, splitMw } from "@/lib/format";
24 +import { OPERATOR_KIND_LABEL } from "@/lib/labels";
25 +import { routes } from "@/lib/routes";
26 +import { compactJsonLd, NOT_FOUND_METADATA, pageMetadata } from "@/lib/seo";
27 +import type { SearchParams } from "@/lib/sort";
28 +
29 +export const revalidate = 300;
30 +
31 +type Props = { params: Promise<{ slug: string }>; searchParams: Promise<SearchParams> };
32 +
33 +function describe(o: { name: string; kind: string | null; facilityCount: number; countryCount: number; knownMw?: number | null; plannedMw?: number | null; hqCountryIso2?: string | null }): string {
34 + const parts = [`${o.name} — ${o.kind ? OPERATOR_KIND_LABEL[o.kind as keyof typeof OPERATOR_KIND_LABEL].toLowerCase() : "data center operator"}`];
35 + parts.push(`${fmtInt(o.facilityCount)} ${pluralize(o.facilityCount, "facility", "facilities")} in ${fmtInt(o.countryCount)} ${pluralize(o.countryCount, "country", "countries")}`);
36 + if (o.knownMw) parts.push(`${fmtMw(o.knownMw)} known operational capacity`);
37 + if (o.plannedMw) parts.push(`≈ ${fmtMw(o.plannedMw)} in the pipeline`);
38 + if (o.hqCountryIso2) parts.push(`headquartered in ${countryName(o.hqCountryIso2)}`);
39 + return `${parts.join(", ")}. Sourced facility list, footprint map, projects and change history on DataCenterIndex.`;
40 +}
41 +
42 +export async function generateMetadata({ params }: Props): Promise<Metadata> {
43 + const { slug } = await params;
44 + const r = await api.operator(slug);
45 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Operator", alternates: { canonical: routes.operator(slug) } };
46 + return pageMetadata({ title: r.data.name, description: describe(r.data), path: routes.operator(slug) });
47 +}
48 +
49 +function MwValue({ mw, estimate }: { mw: number | null | undefined; estimate?: boolean }) {
50 + const s = splitMw(mw);
51 + if (!s) return <span className="text-ink-4">—</span>;
52 + return (
53 + <>
54 + {estimate && <EstimateMark className="mr-0.5 text-[0.6em]" />}
55 + {s.value}
56 + </>
57 + );
58 +}
59 +
60 +export default async function OperatorPage({ params, searchParams }: Props) {
61 + const [{ slug }, sp] = await Promise.all([params, searchParams]);
62 + const fPage = parseFPage(sp.fPage);
63 + const r = await api.operator(slug, fPage > 1 ? { fPage } : undefined);
64 + if (!r.ok && r.status === 404) notFound();
65 + if (!r.ok) {
66 + return (
67 + <Page className="py-6">
68 + <OfflineNotice detail={r.error} className="mb-4" />
69 + <ErrorState />
70 + </Page>
71 + );
72 + }
73 + const o = r.data;
74 + const meta = (r.meta ?? {}) as DetailFacilitiesMeta;
75 + const facTotal = meta.facilitiesTotal ?? o.facilityCount;
76 + const facPer = meta.facilitiesPerPage ?? 50;
77 + const base = routes.operator(slug);
78 + const kindLabel = o.kind ? OPERATOR_KIND_LABEL[o.kind] : null;
79 + const knownUnit = splitMw(o.knownMw)?.unit ?? "MW";
80 + const plannedUnit = splitMw(o.plannedMw)?.unit ?? "MW";
81 + const aliases = o.aliases.filter((a) => a && a !== o.name);
82 + const sameAs = Object.values(o.externalIds ?? {})
83 + .map(String)
84 + .filter((v) => /^https?:\/\//.test(v));
85 + const countries = [...o.countries].sort((a, b) => b.facilityCount - a.facilityCount);
86 + const statusTotal = Object.values(o.statusBreakdown).reduce((a, b) => a + (b ?? 0), 0);
87 +
88 + const jsonLd = compactJsonLd({
89 + "@context": "https://schema.org",
90 + "@type": "Organization",
91 + "@id": `${routes.operator(slug)}#org`,
92 + name: o.name,
93 + url: o.website ?? undefined,
94 + alternateName: aliases.length ? aliases : undefined,
95 + sameAs: sameAs.length ? sameAs : undefined,
96 + description: o.description ?? describe(o),
97 + parentOrganization: o.parent ? { "@type": "Organization", name: o.parent.name } : undefined,
98 + location: o.hqCountryIso2 ? { "@type": "Country", name: countryName(o.hqCountryIso2) } : undefined,
99 + });
100 +
101 + const sections = [
102 + { id: "footprint", label: "Footprint", count: countries.length + statusTotal },
103 + { id: "map", label: "Map", count: o.facilityCount },
104 + { id: "facilities", label: "Facilities", count: facTotal },
105 + { id: "projects", label: "Projects", count: o.projects.length },
106 + { id: "changes", label: "Changes", count: o.recentEvents.length },
107 + { id: "history", label: "Source history", count: o.sourceHistory.length },
108 + ];
109 +
110 + return (
111 + <Page>
112 + <JsonLd data={jsonLd} />
113 + <div className="pt-3">
114 + <Breadcrumbs items={[{ name: "Operators", href: routes.operators() }, { name: o.name, href: base }]} />
115 + </div>
116 +
117 + <header className="py-5 sm:py-7">
118 + <div className="flex flex-wrap items-center gap-2">
119 + <div className="label-caps">Operator</div>
120 + {kindLabel && (
121 + <Badge tone={o.kind === "hyperscaler" ? "accent" : "outline"} size="xs">
122 + {kindLabel}
123 + </Badge>
124 + )}
125 + </div>
126 + <h1 className="mt-1.5 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[30px]">{o.name}</h1>
127 + <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] text-ink-2">
128 + {o.hqCountryIso2 && (
129 + <span className="inline-flex items-center gap-1.5">
130 + <span className="text-ink-3">HQ</span>
131 + <Link href={routes.country(o.hqCountryIso2.toLowerCase())} className="inline-flex items-center gap-1.5 hover:text-ink hover:underline">
132 + <CountryFlag iso2={o.hqCountryIso2} withName />
133 + </Link>
134 + </span>
135 + )}
136 + {o.website && <ExtLink href={o.website} className="no-underline hover:underline" />}
137 + {o.parent && (
138 + <span>
139 + <span className="text-ink-3">Part of </span>
140 + <Link href={routes.operator(o.parent.slug)} className="text-ink hover:text-accent hover:underline">
141 + {o.parent.name}
142 + </Link>
143 + </span>
144 + )}
145 + </div>
146 + {o.description && <p className="mt-3 max-w-2xl text-[13.5px] text-ink-2">{o.description}</p>}
147 + {aliases.length > 0 && (
148 + <div className="mt-3 flex flex-wrap items-center gap-1.5">
149 + <span className="text-[11.5px] text-ink-3">Also known as</span>
150 + {aliases.slice(0, 6).map((a) => (
151 + <Badge key={a} tone="outline" size="xs" className="max-w-[200px]">
152 + <span className="truncate">{a}</span>
153 + </Badge>
154 + ))}
155 + {aliases.length > 6 && <span className="text-[11.5px] text-ink-4">+{aliases.length - 6}</span>}
156 + </div>
157 + )}
158 + </header>
159 +
160 + <MetricRow cols={6}>
161 + <MetricCell>
162 + <Metric value={fmtInt(o.facilityCount)} label="Facilities" />
163 + </MetricCell>
164 + <MetricCell>
165 + <Metric value={fmtInt(o.countryCount)} label="Countries" />
166 + </MetricCell>
167 + <MetricCell>
168 + <Metric value={o.metroCount === undefined ? "—" : fmtInt(o.metroCount)} label="Metros" />
169 + </MetricCell>
170 + <MetricCell>
171 + <Metric value={<MwValue mw={o.knownMw} />} unit={o.knownMw ? knownUnit : undefined} label="Known MW" note={o.knownMw ? "operational, published figures" : "no published figure"} />
172 + </MetricCell>
173 + <MetricCell>
174 + <Metric value={<MwValue mw={o.plannedMw} estimate />} unit={o.plannedMw ? plannedUnit : undefined} label="Pipeline MW" note={o.plannedMw ? "planned + construction" : "no published figure"} />
175 + </MetricCell>
176 + <MetricCell>
177 + <Metric value={o.projectCount === undefined ? fmtInt(o.projects.length) : fmtInt(o.projectCount)} label="Projects" />
178 + </MetricCell>
179 + </MetricRow>
180 +
181 + <SectionNav items={sections} className="mt-4" />
182 +
183 + <div className="flex flex-col gap-8 pt-6">
184 + <Section id="footprint" title="Footprint" description={`${fmtInt(countries.length)} ${pluralize(countries.length, "country", "countries")} · ${fmtInt(statusTotal)} facilities by status`} className="scroll-mt-28">
185 + <div className="grid gap-6 md:grid-cols-2">
186 + <div>
187 + <div className="label-caps mb-2">Facilities by country</div>
188 + {countries.length ? (
189 + <CountBarList items={countries.slice(0, 12).map((c) => ({ label: <span className="inline-flex items-center gap-1.5"><CountryFlag iso2={c.iso2} size={12} />{c.name}</span>, value: c.facilityCount, href: routes.country(c.slug), secondary: c.knownMw ? fmtMw(c.knownMw) : undefined }))} />
190 + ) : (
191 + <EmptyState compact title="No facilities located yet" />
192 + )}
193 + {countries.length > 12 && <p className="mt-2 text-[11.5px] text-ink-4">Top 12 of {countries.length} countries shown — the facility table below covers all of them.</p>}
194 + </div>
195 + <div>
196 + <div className="label-caps mb-2">Status</div>
197 + {statusTotal ? <StackedBar data={o.statusBreakdown} height={12} /> : <EmptyState compact title="No status data" />}
198 + {o.metros.length > 0 && (
199 + <div className="mt-5">
200 + <div className="label-caps mb-2">Top markets</div>
201 + <ul className="flex flex-wrap gap-1.5">
202 + {[...o.metros]
203 + .sort((a, b) => b.facilityCount - a.facilityCount)
204 + .slice(0, 10)
205 + .map((m) => (
206 + <li key={m.id}>
207 + <Link href={routes.market(m.slug)} className="inline-flex h-7 items-center gap-1.5 rounded-sm border border-hair bg-surface px-2 text-[12px] text-ink hover:bg-surface-2">
208 + <CountryFlag iso2={m.countryIso2} size={11} />
209 + {m.name}
210 + <span className="figure text-ink-3">{m.facilityCount}</span>
211 + </Link>
212 + </li>
213 + ))}
214 + </ul>
215 + </div>
216 + )}
217 + </div>
218 + </div>
219 + </Section>
220 +
221 + <Section id="map" title="Map" description="Facilities operated; hollow rings are city-level locations." className="scroll-mt-28">
222 + <EntityMap filters={{ operator: o.slug }} center={{ lat: 25, lng: 10 }} zoom={1.2} openLabel="Open footprint in map" className="h-[240px] sm:h-[360px]" />
223 + </Section>
224 +
225 + <Section id="facilities" title="Facilities" description={`${fmtInt(facTotal)} indexed · sorted by known capacity`} className="scroll-mt-28">
226 + <PagedFacilities items={o.facilities} total={facTotal} page={fPage} perPage={facPer} hrefFor={(p) => fPageHref(base, sp, p)} columns={["market", "country", "status", "mw", "type", "confidence"]} />
227 + </Section>
228 +
229 + {o.projects.length > 0 && (
230 + <Section id="projects" title="Projects" description="Announced and under-construction developments linked to this operator." className="scroll-mt-28">
231 + <ProjectList items={o.projects} />
232 + </Section>
233 + )}
234 +
235 + {o.recentEvents.length > 0 && (
236 + <Section id="changes" title="Recent changes" description="Detected on the operator's facilities and projects." className="scroll-mt-28">
237 + <EventList events={o.recentEvents} dense />
238 + </Section>
239 + )}
240 +
241 + {o.sourceHistory.length > 0 && (
242 + <Section id="history" title="Source history" description="Observations and page changes that shaped this record." className="scroll-mt-28">
243 + <SourceHistory items={o.sourceHistory.slice(0, 40)} />
244 + </Section>
245 + )}
246 + </div>
247 +
248 + <SourcesFooter sources={r.sources} updatedAt={o.updatedAt} />
249 + </Page>
250 + );
251 +}
added apps/web/src/app/(site)/projects/(index)/loading.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonBlock, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-16" />
8 + <Skeleton className="mb-6 h-7 w-64" />
9 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
10 + {Array.from({ length: 4 }).map((_, i) => (
11 + <div key={i} className="flex flex-col gap-2">
12 + <Skeleton className="h-6 w-16" />
13 + <Skeleton className="h-2.5 w-24" />
14 + </div>
15 + ))}
16 + </div>
17 + <div className="grid gap-6 py-6 md:grid-cols-3">
18 + <SkeletonBlock className="h-24" />
19 + <SkeletonBlock className="h-24" />
20 + <SkeletonBlock className="h-24" />
21 + </div>
22 + <Skeleton className="mb-3 h-9 w-full" />
23 + <SkeletonRows rows={10} />
24 + </Page>
25 + );
26 +}
added apps/web/src/app/(site)/projects/(index)/page.tsx +240 −0
@@ -0,0 +1,240 @@
1 +import type { FacilityStatus } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import { Suspense } from "react";
5 +import { PipelineByYear } from "@/components/home/charts";
6 +import { ConfidenceBadge, EstimateMark, StatusBadge } from "@/components/ui/badge";
7 +import { Page, PageHeader } from "@/components/ui/card";
8 +import { BarList, StackedBar } from "@/components/ui/charts";
9 +import { CountryFlag } from "@/components/ui/country-flag";
10 +import { FilterChips, type Chip } from "@/components/ui/filter-chips";
11 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
12 +import { Pagination } from "@/components/ui/pagination";
13 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
14 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
15 +import { api } from "@/lib/api";
16 +import { fmtDate, fmtInt, fmtMw, fmtUsd, placeLine } from "@/lib/format";
17 +import { fmtPartialDateSafe } from "@/lib/format-project";
18 +import { STATUS_SHORT } from "@/lib/labels";
19 +import { routes } from "@/lib/routes";
20 +import { pageMetadata } from "@/lib/seo";
21 +import { spGet, spInt, type SearchParams } from "@/lib/sort";
22 +import { ProjectFilters } from "../filters";
23 +import { projectQueryString, type ProjectQuery } from "../query";
24 +
25 +export const revalidate = 120;
26 +
27 +export const metadata: Metadata = pageMetadata({
28 + title: "Data center projects",
29 + description: "Announced, permitted and under-construction data center projects worldwide with planned capacity, investment and expected opening.",
30 + path: routes.projects(),
31 +});
32 +
33 +const PER_PAGE = 50;
34 +const SORTS = ["updated", "mw", "announced", "opening"] as const;
35 +
36 +function parseQuery(sp: SearchParams): ProjectQuery {
37 + const q: ProjectQuery = {};
38 + const s = spGet(sp, "status");
39 + if (s) q.status = s;
40 + const c = spGet(sp, "country");
41 + if (c) q.country = c.toUpperCase();
42 + const o = spGet(sp, "operator");
43 + if (o) q.operator = o;
44 + const mw = Number(spGet(sp, "min_mw"));
45 + if (Number.isFinite(mw) && mw > 0) q.min_mw = mw;
46 + const ai = spGet(sp, "ai");
47 + if (ai === "1" || ai === "true") q.ai = true;
48 + const text = spGet(sp, "q");
49 + if (text) q.q = text;
50 + const sort = spGet(sp, "sort");
51 + if (sort && (SORTS as readonly string[]).includes(sort)) q.sort = sort as ProjectQuery["sort"];
52 + const order = spGet(sp, "order");
53 + if (order === "asc" || order === "desc") q.order = order;
54 + q.page = spInt(sp, "page");
55 + return q;
56 +}
57 +
58 +export default async function ProjectsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
59 + const sp = await searchParams;
60 + const query = parseQuery(sp);
61 + const page = query.page ?? 1;
62 + const [pipe, list, countriesRes] = await Promise.all([
63 + api.projectPipeline(),
64 + api.projects({ status: query.status, country: query.country, operator: query.operator, min_mw: query.min_mw, ai: query.ai, q: query.q, sort: query.sort ?? "updated", order: query.order, page, per_page: PER_PAGE }),
65 + api.countries(),
66 + ]);
67 + const p = pipe.ok ? pipe.data : null;
68 + const countries = countriesRes.items.map((c) => ({ iso2: c.iso2, name: c.name }));
69 + const href = (patch: Partial<ProjectQuery>) => routes.projects(projectQueryString({ ...query, page: undefined, ...patch }));
70 + const sortHref = (key: (typeof SORTS)[number]) => {
71 + const active = (query.sort ?? "updated") === key;
72 + const next = active ? (query.order === "asc" ? "desc" : "asc") : key === "opening" || key === "announced" ? "asc" : "desc";
73 + return href({ sort: key, order: next });
74 + };
75 + const sortState = (key: (typeof SORTS)[number]) => ((query.sort ?? "updated") === key ? (query.order ?? (key === "opening" || key === "announced" ? "asc" : "desc")) : null);
76 +
77 + const byStatus: Partial<Record<FacilityStatus, number>> = {};
78 + for (const s of p?.byStatus ?? []) byStatus[s.status] = (byStatus[s.status] ?? 0) + s.count;
79 + const byYear = (p?.byYear ?? []).map((y) => ({ year: y.year, count: y.count, mw: y.mw }));
80 +
81 + const chips: Chip[] = [];
82 + for (const s of (query.status ?? "").split(",").filter(Boolean)) chips.push({ key: `status-${s}`, label: "Status", value: STATUS_SHORT[s as FacilityStatus] ?? s, removeHref: href({ status: (query.status ?? "").split(",").filter((x) => x && x !== s).join(",") || undefined }) });
83 + if (query.country) chips.push({ key: "country", label: "Country", value: countries.find((c) => c.iso2 === query.country)?.name ?? query.country, removeHref: href({ country: undefined }) });
84 + if (query.operator) chips.push({ key: "operator", label: "Operator", value: query.operator, removeHref: href({ operator: undefined }) });
85 + if (query.min_mw) chips.push({ key: "min_mw", label: "Planned", value: `≥ ${fmtMw(query.min_mw)}`, removeHref: href({ min_mw: undefined }) });
86 + if (query.ai) chips.push({ key: "ai", label: "AI", value: "AI infrastructure", removeHref: href({ ai: undefined }) });
87 + if (query.q) chips.push({ key: "q", label: "Search", value: query.q, removeHref: href({ q: undefined }) });
88 +
89 + return (
90 + <Page>
91 + <PageHeader eyebrow="Tracker" title="Data center projects" description={p ? `${fmtInt(p.totals.projects)} projects tracked from announcement to opening. Planned MW is a published figure (≈ never imputed); investment as announced.` : "Announced, permitted and under-construction projects worldwide."} />
92 +
93 + {!pipe.ok && <OfflineNotice detail={pipe.error} className="mb-3" />}
94 +
95 + <MetricRow cols={4}>
96 + <MetricCell>
97 + <Metric value={p ? fmtInt(p.totals.projects) : "—"} label="Projects" size="sm" />
98 + </MetricCell>
99 + <MetricCell>
100 + <Metric
101 + value={
102 + p?.totals.plannedMw === null || p?.totals.plannedMw === undefined ? (
103 + "—"
104 + ) : (
105 + <>
106 + <EstimateMark className="mr-0.5 text-[0.6em]" />
107 + {fmtMw(p.totals.plannedMw).split(" ")[0]}
108 + </>
109 + )
110 + }
111 + unit={p?.totals.plannedMw ? fmtMw(p.totals.plannedMw).split(" ")[1] : undefined}
112 + label="Planned capacity"
113 + note="sum of published planned MW"
114 + size="sm"
115 + />
116 + </MetricCell>
117 + <MetricCell>
118 + <Metric value={p?.totals.investmentUsd ? fmtUsd(p.totals.investmentUsd) : "—"} label="Announced investment" note={p?.totals.investmentUsd ? "where disclosed" : "not disclosed"} size="sm" />
119 + </MetricCell>
120 + <MetricCell>
121 + <Metric value={p ? fmtInt(p.totals.ai) : "—"} label="AI projects" size="sm" />
122 + </MetricCell>
123 + </MetricRow>
124 +
125 + <div className="grid gap-6 py-6 md:grid-cols-3">
126 + <div>
127 + <div className="label-caps mb-2">By status</div>
128 + {Object.keys(byStatus).length ? <StackedBar data={byStatus} /> : <EmptyState compact title="No projects yet" />}
129 + </div>
130 + <div>
131 + <div className="label-caps mb-2">By expected year</div>
132 + {byYear.length ? <PipelineByYear data={byYear} /> : <EmptyState compact title="No dated pipeline yet" description="Projects with a published expected opening will chart here." />}
133 + </div>
134 + <div>
135 + <div className="label-caps mb-2">Top countries</div>
136 + {p?.byCountry.length ? (
137 + <BarList items={p.byCountry.slice(0, 8).map((c) => ({ label: <CountryFlag iso2={c.countryIso2} withName />, value: c.count, href: c.slug ? routes.country(c.slug) : href({ country: c.countryIso2 }), secondary: c.mw !== null ? `≈ ${fmtMw(c.mw)}` : undefined }))} />
138 + ) : (
139 + <EmptyState compact title="No located projects yet" description="Country appears once a project is geolocated." />
140 + )}
141 + </div>
142 + </div>
143 +
144 + <div className="sticky top-12 z-[20] -mx-3 border-y border-hair bg-paper/95 px-3 py-2 backdrop-blur sm:top-14 sm:mx-0 sm:px-0">
145 + <Suspense>
146 + <ProjectFilters query={query} total={list.total} countries={countries} />
147 + </Suspense>
148 + </div>
149 + <FilterChips chips={chips} resetHref={routes.projects()} className="py-2" />
150 +
151 + <div className="pt-1">
152 + {list.items.length === 0 ? (
153 + <EmptyState compact title={chips.length ? "No projects match these filters" : "No projects tracked yet"} description={chips.length ? "Loosen a filter or clear them all." : "Announced and under-construction projects appear once ingested."} />
154 + ) : (
155 + <Table>
156 + <THead>
157 + <tr>
158 + <Th>Project</Th>
159 + <Th className="hidden sm:table-cell">Operator</Th>
160 + <Th>Location</Th>
161 + <Th>Status</Th>
162 + <Th numeric sort={sortState("mw")} sortHref={sortHref("mw")}>
163 + Planned MW
164 + </Th>
165 + <Th numeric className="hidden lg:table-cell">
166 + Investment
167 + </Th>
168 + <Th className="hidden lg:table-cell" sort={sortState("announced")} sortHref={sortHref("announced")}>
169 + Announced
170 + </Th>
171 + <Th sort={sortState("opening")} sortHref={sortHref("opening")}>
172 + Expected
173 + </Th>
174 + <Th className="hidden lg:table-cell">Confidence</Th>
175 + </tr>
176 + </THead>
177 + <TBody>
178 + {list.items.map((pr) => (
179 + <Tr key={pr.id}>
180 + <Td primary>
181 + <RowLink href={routes.project(pr.slug)}>{pr.name}</RowLink>
182 + <div className="text-[11.5px] text-ink-3 sm:hidden">{pr.operator?.name ?? "Operator unknown"}</div>
183 + </Td>
184 + <Td label="Operator" className="hidden sm:table-cell">
185 + {pr.operator ? (
186 + <Link href={routes.operator(pr.operator.slug)} className="text-ink-2 hover:text-ink hover:underline">
187 + {pr.operator.name}
188 + </Link>
189 + ) : (
190 + <span className="text-ink-4">—</span>
191 + )}
192 + </Td>
193 + <Td label="Location">
194 + <span className="inline-flex max-w-[220px] items-center gap-1.5 text-ink-2">
195 + <CountryFlag iso2={pr.countryIso2} size={12} />
196 + <span className="truncate">{pr.metro ? <Link href={routes.market(pr.metro.slug)} className="hover:text-ink hover:underline">{pr.metro.name}</Link> : placeLine(pr)}</span>
197 + </span>
198 + </Td>
199 + <Td label="Status">
200 + <StatusBadge status={pr.status} short size="xs" />
201 + </Td>
202 + <Td label="Planned MW" numeric>
203 + {pr.plannedMw === null ? (
204 + <span className="text-ink-4">—</span>
205 + ) : (
206 + <span>
207 + <EstimateMark className="mr-0.5" />
208 + {fmtMw(pr.plannedMw)}
209 + </span>
210 + )}
211 + </Td>
212 + <Td label="Investment" numeric className="hidden lg:table-cell">
213 + {pr.investmentUsd === null ? <span className="text-ink-4">—</span> : fmtUsd(pr.investmentUsd)}
214 + </Td>
215 + <Td label="Announced" className="figure hidden text-[12px] text-ink-3 lg:table-cell">
216 + {fmtDate(pr.announcedOn)}
217 + </Td>
218 + <Td label="Expected" className="figure text-[12px]">
219 + {pr.expectedOpening ? fmtPartialDateSafe(pr.expectedOpening) : <span className="text-ink-4">TBD</span>}
220 + </Td>
221 + <Td label="Confidence" className="hidden lg:table-cell">
222 + <ConfidenceBadge level={pr.confidence} size="xs" />
223 + </Td>
224 + </Tr>
225 + ))}
226 + </TBody>
227 + </Table>
228 + )}
229 + </div>
230 + <Pagination page={page} perPage={PER_PAGE} total={list.total} hrefFor={(pg) => routes.projects(projectQueryString({ ...query, page: pg }))} label="projects" className="mt-3" />
231 + <p className="mt-6 text-[11.5px] text-ink-4">
232 + Projects are reconciled from operator press releases, planning filings and news; status changes are versioned and appear in the{" "}
233 + <Link href={routes.live("type=project_announced,project_status_changed,construction_started")} className="underline decoration-hair-2 hover:text-ink">
234 + live feed
235 + </Link>
236 + .
237 + </p>
238 + </Page>
239 + );
240 +}
added apps/web/src/app/(site)/projects/[slug]/data.ts +6 −0
@@ -0,0 +1,6 @@
1 +import type { ProjectDetail } from "@dci/core";
2 +import { cache } from "react";
3 +import { api, type ApiResult } from "@/lib/api";
4 +
5 +/** Per-request memoised project loader shared by layout (existence gate), metadata, page and OG image. */
6 +export const getProject = cache(async (slug: string): Promise<ApiResult<ProjectDetail>> => api.project(slug));
added apps/web/src/app/(site)/projects/[slug]/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { getProject } from "./data";
4 +
5 +/**
6 + * Existence gate for /projects/[slug]: resolves the project before this segment's loading boundary streams,
7 + * so an unknown slug is a real 404 (not a soft one). Other API failures fall through to the page.
8 + */
9 +export default async function ProjectLayout({ children, params }: { children: ReactNode; params: Promise<{ slug: string }> }) {
10 + const { slug } = await params;
11 + const r = await getProject(slug);
12 + if (!r.ok && r.status === 404) notFound();
13 + return <>{children}</>;
14 +}
added apps/web/src/app/(site)/projects/[slug]/loading.tsx +23 −0
@@ -0,0 +1,23 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-4" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-40" />
8 + <Skeleton className="mb-2 h-3 w-16" />
9 + <Skeleton className="mb-2 h-7 w-80 max-w-full" />
10 + <Skeleton className="mb-6 h-4 w-56" />
11 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
12 + {Array.from({ length: 4 }).map((_, i) => (
13 + <div key={i} className="flex flex-col gap-2">
14 + <Skeleton className="h-6 w-16" />
15 + <Skeleton className="h-2.5 w-20" />
16 + </div>
17 + ))}
18 + </div>
19 + <Skeleton className="mt-4 h-10 w-full" />
20 + <SkeletonRows rows={6} className="mt-8" />
21 + </Page>
22 + );
23 +}
added apps/web/src/app/(site)/projects/[slug]/opengraph-image.tsx +29 −0
@@ -0,0 +1,29 @@
1 +import { fmtMw, fmtUsd, placeLine } from "@/lib/format";
2 +import { fmtPartialDateSafe } from "@/lib/format-project";
3 +import { STATUS_LABEL } from "@/lib/labels";
4 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
5 +import { getProject } from "./data";
6 +
7 +export const alt = "Data center project — DataCenterIndex";
8 +export const size = OG_SIZE;
9 +export const contentType = OG_CONTENT_TYPE;
10 +export const revalidate = 3600;
11 +
12 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
13 + const { slug } = await params;
14 + const r = await getProject(slug);
15 + if (!r.ok) return renderOg({ eyebrow: "Project", title: "Data center project", subtitle: "DataCenterIndex — announced, permitted and under-construction projects." });
16 + const p = r.data;
17 + const place = placeLine(p);
18 + return renderOg({
19 + eyebrow: "Project",
20 + title: p.name.length > 90 ? `${p.name.slice(0, 87)}…` : p.name,
21 + subtitle: [p.operator?.name, place !== "—" ? place : null].filter(Boolean).join(" · ") || undefined,
22 + stats: [
23 + { label: "Planned MW", value: p.plannedMw === null ? "—" : `≈ ${fmtMw(p.plannedMw)}` },
24 + { label: "Investment", value: p.investmentUsd === null ? "—" : fmtUsd(p.investmentUsd) },
25 + { label: "Status", value: STATUS_LABEL[p.status] },
26 + { label: "Expected", value: p.expectedOpening ? fmtPartialDateSafe(p.expectedOpening) : "TBD" },
27 + ],
28 + });
29 +}
added apps/web/src/app/(site)/projects/[slug]/page.tsx +281 −0
@@ -0,0 +1,281 @@
1 +import type { EventType, ProjectDetail } from "@dci/core";
2 +import { partialDateSortKey } from "@dci/core";
3 +import type { Metadata } from "next";
4 +import Link from "next/link";
5 +import { notFound } from "next/navigation";
6 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
7 +import { EntityMap } from "@/components/entity/entity-map";
8 +import { JsonLd } from "@/components/entity/json-ld";
9 +import { ExtLink, KeyValueList } from "@/components/entity/kv";
10 +import { ProvenanceTable, WithProvenance } from "@/components/entity/provenance";
11 +import { SectionNav } from "@/components/entity/section-nav";
12 +import { SourcesFooter } from "@/components/entity/sources-footer";
13 +import { EventList } from "@/components/event-list";
14 +import { ConfidenceBadge, EstimateMark, StatusBadge } from "@/components/ui/badge";
15 +import { Page, Section } from "@/components/ui/card";
16 +import { CountryFlag } from "@/components/ui/country-flag";
17 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
18 +import { ErrorState, OfflineNotice } from "@/components/ui/states";
19 +import { SourceHistory, TimelineList, type TimelineEntry } from "@/components/ui/timeline";
20 +import { countryName, fmtDate, fmtInt, fmtMw, fmtUsd, placeLine } from "@/lib/format";
21 +import { fmtPartialDateSafe } from "@/lib/format-project";
22 +import { EVENT_LABEL, STATUS_LABEL } from "@/lib/labels";
23 +import { routes } from "@/lib/routes";
24 +import { absoluteUrl, compactJsonLd, NOT_FOUND_METADATA, pageMetadata } from "@/lib/seo";
25 +import { getProject } from "./data";
26 +
27 +export const revalidate = 300;
28 +
29 +type Props = { params: Promise<{ slug: string }> };
30 +
31 +function describe(p: ProjectDetail): string {
32 + if (p.description) return p.description;
33 + const bits = [`${p.name}: ${STATUS_LABEL[p.status].toLowerCase()} data center project`];
34 + if (p.operator) bits.push(`by ${p.operator.name}`);
35 + const place = placeLine(p);
36 + if (place !== "—") bits.push(`in ${place}`);
37 + if (p.plannedMw !== null) bits.push(`— ≈ ${fmtMw(p.plannedMw)} planned`);
38 + if (p.investmentUsd !== null) bits.push(`, ${fmtUsd(p.investmentUsd)} announced`);
39 + if (p.expectedOpening) bits.push(`, expected ${fmtPartialDateSafe(p.expectedOpening)}`);
40 + return `${bits.join(" ")}. Timeline, status history and provenance.`;
41 +}
42 +
43 +export async function generateMetadata({ params }: Props): Promise<Metadata> {
44 + const { slug } = await params;
45 + const r = await getProject(slug);
46 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Project" };
47 + return pageMetadata({ title: r.data.name, description: describe(r.data), path: routes.project(r.data.slug) });
48 +}
49 +
50 +function humanType(t: string): string {
51 + return EVENT_LABEL[t as EventType] ?? t.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
52 +}
53 +
54 +function toneFor(t: string): TimelineEntry["tone"] {
55 + switch (t) {
56 + case "construction_started":
57 + case "phase_announced":
58 + return "warn";
59 + case "facility_opened":
60 + case "cloud_region_launched":
61 + return "ok";
62 + case "project_announced":
63 + case "expansion_announced":
64 + case "investment_announced":
65 + case "power_agreement":
66 + return "accent";
67 + case "closure":
68 + case "incident":
69 + return "danger";
70 + default:
71 + return "neutral";
72 + }
73 +}
74 +
75 +export default async function ProjectPage({ params }: Props) {
76 + const { slug } = await params;
77 + const r = await getProject(slug);
78 + if (!r.ok && r.status === 404) notFound();
79 + if (!r.ok) {
80 + return (
81 + <Page className="py-6">
82 + <OfflineNotice detail={r.error} className="mb-4" />
83 + <ErrorState />
84 + </Page>
85 + );
86 + }
87 + const p = r.data;
88 + const base = routes.project(p.slug);
89 + const place = placeLine(p);
90 + const hasGeo = p.lat !== null && p.lng !== null;
91 +
92 + const timeline: TimelineEntry[] = [...p.timeline]
93 + .sort((a, b) => partialDateSortKey(a.date.length > 10 ? a.date.slice(0, 10) : a.date) - partialDateSortKey(b.date.length > 10 ? b.date.slice(0, 10) : b.date))
94 + .map((t) => ({ date: t.date, title: humanType(t.type), description: t.description, meta: t.sourceName ? <span>{t.sourceName}</span> : undefined, href: t.url, tone: toneFor(t.type) }));
95 + if (p.expectedOpening) {
96 + const last = timeline.length ? partialDateSortKey(timeline[timeline.length - 1]!.date.slice(0, 10)) : Number.NEGATIVE_INFINITY;
97 + if (partialDateSortKey(p.expectedOpening) >= last) {
98 + timeline.push({ date: p.expectedOpening, title: <span className="text-ink-2">Expected opening</span>, description: p.phaseCount ? `phase 1 of ${fmtInt(p.phaseCount)} — expected, as published by the source` : "expected, as published by the source", tone: "neutral" });
99 + }
100 + }
101 + const statusHistory: TimelineEntry[] = p.statusHistory.map((s) => ({ date: s.date, title: `${s.from ? `${STATUS_LABEL[s.from]} → ` : ""}${STATUS_LABEL[s.to]}`, href: s.url, tone: s.to === "under_construction" ? "warn" : s.to === "operational" ? "ok" : s.to === "cancelled" || s.to === "delayed" ? "danger" : "neutral" }));
102 +
103 + const sections = [
104 + { id: "timeline", label: "Timeline", count: timeline.length },
105 + { id: "status-history", label: "Status history", count: statusHistory.length },
106 + { id: "about", label: "About", count: p.description || p.sourceUrl ? 1 : 0 },
107 + { id: "location", label: "Location", count: hasGeo || place !== "—" ? 1 : 0 },
108 + { id: "provenance", label: "Provenance", count: p.provenance.length },
109 + { id: "changes", label: "Changes", count: p.events.length },
110 + { id: "history", label: "Source history", count: p.sourceHistory.length },
111 + ];
112 +
113 + const jsonLd = compactJsonLd({
114 + "@context": "https://schema.org",
115 + "@type": "Project",
116 + name: p.name,
117 + description: p.description ?? undefined,
118 + url: absoluteUrl(base),
119 + sameAs: p.sourceUrl ?? undefined,
120 + foundingDate: p.announcedOn ?? undefined,
121 + parentOrganization: p.operator ? { "@type": "Organization", name: p.operator.name, url: absoluteUrl(routes.operator(p.operator.slug)) } : undefined,
122 + location: place !== "—" || hasGeo ? compactJsonLd({ "@type": "Place", name: place !== "—" ? place : undefined, address: p.countryIso2 ? { "@type": "PostalAddress", addressLocality: p.city ?? undefined, addressRegion: p.regionName ?? undefined, addressCountry: p.countryIso2 } : undefined, geo: hasGeo ? { "@type": "GeoCoordinates", latitude: p.lat, longitude: p.lng } : undefined }) : undefined,
123 + });
124 +
125 + return (
126 + <Page>
127 + <Breadcrumbs items={[{ name: "Projects", href: routes.projects() }, { name: p.name, href: base }]} className="pt-3" />
128 + <JsonLd data={jsonLd} />
129 +
130 + <header className="py-5 sm:py-7">
131 + <div className="label-caps mb-1.5">Project</div>
132 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{p.name}</h1>
133 + <div className="mt-2 flex flex-wrap items-center gap-1.5">
134 + <WithProvenance items={p.provenance} fields={["status"]}>
135 + <StatusBadge status={p.status} />
136 + </WithProvenance>
137 + <ConfidenceBadge level={p.confidence} short={false} />
138 + </div>
139 + <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-ink-2">
140 + {p.operator ? (
141 + <Link href={routes.operator(p.operator.slug)} className="font-medium text-ink hover:text-accent hover:underline">
142 + {p.operator.name}
143 + </Link>
144 + ) : (
145 + <span className="text-ink-3">Operator not published</span>
146 + )}
147 + {p.facility && (
148 + <>
149 + <span aria-hidden className="text-ink-4">
150 + ·
151 + </span>
152 + <Link href={routes.facility(p.facility.slug)} className="hover:text-ink hover:underline">
153 + Facility: {p.facility.name}
154 + </Link>
155 + </>
156 + )}
157 + {(place !== "—" || p.metro) && (
158 + <>
159 + <span aria-hidden className="text-ink-4">
160 + ·
161 + </span>
162 + <span className="inline-flex items-center gap-1.5">
163 + <CountryFlag iso2={p.countryIso2} size={12} />
164 + {p.metro ? (
165 + <Link href={routes.market(p.metro.slug)} className="hover:text-ink hover:underline">
166 + {p.metro.name}
167 + </Link>
168 + ) : null}
169 + {p.metro && place !== "—" && <span className="text-ink-4">·</span>}
170 + {place !== "—" && <span>{place}</span>}
171 + </span>
172 + </>
173 + )}
174 + </div>
175 + </header>
176 +
177 + <MetricRow cols={Math.min(6, 2 + [p.investmentUsd, p.announcedOn, p.acreage, p.phaseCount].filter((v) => v !== null).length)}>
178 + <MetricCell>
179 + <Metric
180 + value={
181 + p.plannedMw === null ? (
182 + "—"
183 + ) : (
184 + <WithProvenance items={p.provenance} fields={["plannedMw", "plannedPowerMw"]}>
185 + <EstimateMark className="mr-0.5 text-[0.6em]" />
186 + {fmtMw(p.plannedMw).split(" ")[0]}
187 + </WithProvenance>
188 + )
189 + }
190 + unit={p.plannedMw === null ? undefined : fmtMw(p.plannedMw).split(" ")[1]}
191 + label="Planned capacity"
192 + note={p.plannedMw === null ? "not published" : "as announced"}
193 + size="sm"
194 + />
195 + </MetricCell>
196 + {p.investmentUsd !== null && (
197 + <MetricCell>
198 + <Metric value={<WithProvenance items={p.provenance} fields={["investmentUsd"]}>{fmtUsd(p.investmentUsd)}</WithProvenance>} label="Investment" note="as announced" size="sm" />
199 + </MetricCell>
200 + )}
201 + {p.announcedOn && (
202 + <MetricCell>
203 + <Metric value={<WithProvenance items={p.provenance} fields={["announcedOn"]}>{fmtDate(p.announcedOn)}</WithProvenance>} label="Announced" size="sm" />
204 + </MetricCell>
205 + )}
206 + <MetricCell>
207 + <Metric value={p.expectedOpening ? <WithProvenance items={p.provenance} fields={["expectedOpening"]}>{fmtPartialDateSafe(p.expectedOpening)}</WithProvenance> : "—"} label="Expected opening" note={p.expectedOpening ? undefined : "not published"} size="sm" />
208 + </MetricCell>
209 + {p.acreage !== null && (
210 + <MetricCell>
211 + <Metric value={fmtInt(p.acreage)} unit="acres" label="Site" size="sm" />
212 + </MetricCell>
213 + )}
214 + {p.phaseCount !== null && (
215 + <MetricCell>
216 + <Metric value={fmtInt(p.phaseCount)} label="Phases" size="sm" />
217 + </MetricCell>
218 + )}
219 + </MetricRow>
220 +
221 + <SectionNav items={sections} className="mt-2" />
222 +
223 + <div className="flex flex-col gap-8 pt-6">
224 + <Section id="timeline" title="Construction timeline" description="Dated milestones from the sources indexed; partial dates (year, quarter) are shown as published." className="scroll-mt-28">
225 + {timeline.length ? <TimelineList items={timeline} /> : <p className="text-[12.5px] text-ink-3">No dated milestone has been published for this project yet.</p>}
226 + </Section>
227 +
228 + {statusHistory.length > 0 && (
229 + <Section id="status-history" title="Status history" description="Detected status transitions." className="scroll-mt-28">
230 + <TimelineList items={statusHistory} dense />
231 + </Section>
232 + )}
233 +
234 + {(p.description || p.sourceUrl) && (
235 + <Section id="about" title="About" className="scroll-mt-28">
236 + {p.description && <p className="max-w-3xl text-[14px] leading-relaxed text-ink-2">{p.description}</p>}
237 + {p.sourceUrl && (
238 + <p className="mt-3 text-[12.5px] text-ink-3">
239 + Primary source: <ExtLink href={p.sourceUrl} />
240 + </p>
241 + )}
242 + </Section>
243 + )}
244 +
245 + {(hasGeo || place !== "—") && (
246 + <Section id="location" title="Location" description={hasGeo ? "Approximate site location as published; the ring marks the uncertainty." : "Location as published; no coordinates yet."} className="scroll-mt-28">
247 + {hasGeo ? <EntityMap center={{ lat: p.lat!, lng: p.lng! }} zoom={10} precision="approximate" filters={p.countryIso2 ? { country: p.countryIso2 } : undefined} className="h-[220px] sm:h-[300px]" /> : null}
248 + <KeyValueList
249 + className={hasGeo ? "mt-3" : undefined}
250 + items={[
251 + { label: "City", value: p.city },
252 + { label: "Region", value: p.regionName },
253 + { label: "Country", value: p.countryIso2 ? <span className="inline-flex items-center gap-1.5"><CountryFlag iso2={p.countryIso2} size={12} />{countryName(p.countryIso2)}</span> : null },
254 + { label: "Market", value: p.metro ? <Link href={routes.market(p.metro.slug)} className="hover:text-accent hover:underline">{p.metro.name}</Link> : null },
255 + { label: "Coordinates", value: hasGeo ? <span className="figure">{p.lat!.toFixed(4)}, {p.lng!.toFixed(4)}</span> : null },
256 + ]}
257 + />
258 + </Section>
259 + )}
260 +
261 + <Section id="provenance" title="Provenance" description="Every field with its source, observation dates, confidence and extraction method." className="scroll-mt-28">
262 + <ProvenanceTable items={p.provenance} />
263 + </Section>
264 +
265 + {p.events.length > 0 && (
266 + <Section id="changes" title="Changes" description="Detected events on this project." className="scroll-mt-28">
267 + <EventList events={p.events} dense />
268 + </Section>
269 + )}
270 +
271 + {p.sourceHistory.length > 0 && (
272 + <Section id="history" title="Source history" description="When each source was observed, changed or verified." className="scroll-mt-28">
273 + <SourceHistory items={p.sourceHistory} />
274 + </Section>
275 + )}
276 + </div>
277 +
278 + <SourcesFooter sources={r.sources} updatedAt={p.lastUpdate} />
279 + </Page>
280 + );
281 +}
added apps/web/src/app/(site)/projects/filters.tsx +152 −0
@@ -0,0 +1,152 @@
1 +"use client";
2 +
3 +import type { FacilityStatus } from "@dci/core";
4 +import { PIPELINE_STATUSES } from "@dci/core";
5 +import { SlidersHorizontal } from "lucide-react";
6 +import { useRouter } from "next/navigation";
7 +import { useState } from "react";
8 +import { Button } from "@/components/ui/button";
9 +import { ToggleChip } from "@/components/ui/filter-chips";
10 +import { Sheet } from "@/components/ui/sheet";
11 +import { fmtInt } from "@/lib/format";
12 +import { STATUS_SHORT } from "@/lib/labels";
13 +import { routes } from "@/lib/routes";
14 +import { PROJECT_SORTS, projectQueryString, type ProjectQuery } from "./query";
15 +
16 +const STATUSES: FacilityStatus[] = [...PIPELINE_STATUSES, "cancelled"];
17 +
18 +const field = "h-9 w-full rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink outline-none placeholder:text-ink-4 focus-visible:ring-2 focus-visible:ring-accent";
19 +
20 +function Fields({ draft, setDraft, countries }: { draft: ProjectQuery; setDraft: (q: ProjectQuery) => void; countries: Array<{ iso2: string; name: string }> }) {
21 + const statuses = new Set((draft.status ?? "").split(",").filter(Boolean));
22 + const toggle = (s: FacilityStatus) => {
23 + const next = new Set(statuses);
24 + if (next.has(s)) next.delete(s);
25 + else next.add(s);
26 + setDraft({ ...draft, status: next.size ? Array.from(next).join(",") : undefined });
27 + };
28 + return (
29 + <div className="flex flex-col divide-y divide-hair">
30 + <fieldset className="py-3 first:pt-0">
31 + <legend className="label-caps mb-2">Status</legend>
32 + <div className="flex flex-wrap gap-1.5">
33 + {STATUSES.map((s) => (
34 + <ToggleChip key={s} active={statuses.has(s)} onClick={() => toggle(s)}>
35 + {STATUS_SHORT[s]}
36 + </ToggleChip>
37 + ))}
38 + </div>
39 + </fieldset>
40 + <fieldset className="grid gap-3 py-3 sm:grid-cols-2">
41 + <label className="flex flex-col gap-1 text-[12px] text-ink-3">
42 + Country
43 + <select className={field} value={draft.country ?? ""} onChange={(e) => setDraft({ ...draft, country: e.target.value || undefined })}>
44 + <option value="">All countries</option>
45 + {countries.map((c) => (
46 + <option key={c.iso2} value={c.iso2}>
47 + {c.name}
48 + </option>
49 + ))}
50 + </select>
51 + </label>
52 + <label className="flex flex-col gap-1 text-[12px] text-ink-3">
53 + Operator
54 + <input className={field} placeholder="Operator slug or name" value={draft.operator ?? ""} onChange={(e) => setDraft({ ...draft, operator: e.target.value || undefined })} />
55 + </label>
56 + <label className="flex flex-col gap-1 text-[12px] text-ink-3">
57 + Minimum planned MW
58 + <input className={field} type="number" inputMode="numeric" min={0} step={10} placeholder="e.g. 100" value={draft.min_mw ?? ""} onChange={(e) => setDraft({ ...draft, min_mw: e.target.value ? Number(e.target.value) : undefined })} />
59 + </label>
60 + <label className="flex flex-col gap-1 text-[12px] text-ink-3">
61 + Sort
62 + <select className={field} value={draft.sort ?? "updated"} onChange={(e) => setDraft({ ...draft, sort: e.target.value as ProjectQuery["sort"] })}>
63 + {PROJECT_SORTS.map((s) => (
64 + <option key={s.v} value={s.v}>
65 + {s.label}
66 + </option>
67 + ))}
68 + </select>
69 + </label>
70 + </fieldset>
71 + <fieldset className="py-3">
72 + <legend className="label-caps mb-2">Flags</legend>
73 + <ToggleChip active={!!draft.ai} onClick={() => setDraft({ ...draft, ai: draft.ai ? undefined : true })}>
74 + AI infrastructure
75 + </ToggleChip>
76 + </fieldset>
77 + </div>
78 + );
79 +}
80 +
81 +/** URL-synced project filters: Sheet on mobile, quick status chips + sort inline on ≥ md. */
82 +export function ProjectFilters({ query, total, countries }: { query: ProjectQuery; total: number | null; countries: Array<{ iso2: string; name: string }> }) {
83 + const router = useRouter();
84 + const [open, setOpen] = useState(false);
85 + const [draft, setDraft] = useState<ProjectQuery>(query);
86 + const [synced, setSynced] = useState(query);
87 + if (synced !== query) {
88 + setSynced(query);
89 + setDraft(query);
90 + }
91 + const active = [query.status, query.country, query.operator, query.min_mw, query.ai, query.q].filter(Boolean).length;
92 + const apply = (q: ProjectQuery) => {
93 + router.replace(routes.projects(projectQueryString({ ...q, page: undefined })), { scroll: false });
94 + setOpen(false);
95 + };
96 + const statuses = new Set((query.status ?? "").split(",").filter(Boolean));
97 + const quickToggle = (s: FacilityStatus) => {
98 + const next = new Set(statuses);
99 + if (next.has(s)) next.delete(s);
100 + else next.add(s);
101 + apply({ ...query, status: next.size ? Array.from(next).join(",") : undefined });
102 + };
103 +
104 + return (
105 + <div className="flex items-center gap-2">
106 + <Button size="sm" variant={active ? "primary" : "secondary"} onClick={() => setOpen(true)} aria-haspopup="dialog">
107 + <SlidersHorizontal className="size-3.5" strokeWidth={1.75} /> Filters
108 + {active > 0 && <span className="figure ml-0.5 rounded-xs bg-paper/20 px-1 text-[11px]">{active}</span>}
109 + </Button>
110 + <div className="-mx-1 hidden gap-1 overflow-x-auto px-1 no-scrollbar md:flex">
111 + {(["announced", "permitting", "approved", "under_construction", "delayed"] as FacilityStatus[]).map((s) => (
112 + <ToggleChip key={s} active={statuses.has(s)} onClick={() => quickToggle(s)} className="h-7 text-[12px]">
113 + {STATUS_SHORT[s]}
114 + </ToggleChip>
115 + ))}
116 + </div>
117 + <label className="ml-auto hidden items-center gap-1.5 text-[12px] text-ink-3 md:inline-flex">
118 + Sort
119 + <select className="h-7 rounded-sm border border-hair-2 bg-surface px-1.5 text-[12px] text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent" value={query.sort ?? "updated"} onChange={(e) => apply({ ...query, sort: e.target.value as ProjectQuery["sort"] })}>
120 + {PROJECT_SORTS.map((s) => (
121 + <option key={s.v} value={s.v}>
122 + {s.label}
123 + </option>
124 + ))}
125 + </select>
126 + </label>
127 + {total !== null && (
128 + <span className="figure ml-auto text-[12.5px] text-ink-3 md:ml-2" aria-live="polite">
129 + {fmtInt(total)} {total === 1 ? "project" : "projects"}
130 + </span>
131 + )}
132 + <Sheet
133 + open={open}
134 + onClose={() => setOpen(false)}
135 + title="Filter projects"
136 + heightVh={84}
137 + footer={
138 + <div className="flex items-center justify-between gap-2">
139 + <Button variant="ghost" onClick={() => setDraft({})}>
140 + Reset
141 + </Button>
142 + <Button variant="primary" onClick={() => apply(draft)}>
143 + Apply
144 + </Button>
145 + </div>
146 + }
147 + >
148 + <Fields draft={draft} setDraft={setDraft} countries={countries} />
149 + </Sheet>
150 + </div>
151 + );
152 +}
added apps/web/src/app/(site)/projects/query.ts +33 −0
@@ -0,0 +1,33 @@
1 +/** URL state of the project tracker — shared by the server page and the client filter bar (no "use client" here). */
2 +export interface ProjectQuery {
3 + status?: string;
4 + country?: string;
5 + operator?: string;
6 + min_mw?: number;
7 + ai?: boolean;
8 + q?: string;
9 + sort?: "updated" | "mw" | "announced" | "opening";
10 + order?: "asc" | "desc";
11 + page?: number;
12 +}
13 +
14 +export const PROJECT_SORTS: Array<{ v: NonNullable<ProjectQuery["sort"]>; label: string }> = [
15 + { v: "updated", label: "Last update" },
16 + { v: "mw", label: "Planned MW" },
17 + { v: "announced", label: "Announced" },
18 + { v: "opening", label: "Expected opening" },
19 +];
20 +
21 +export function projectQueryString(q: ProjectQuery): string {
22 + const p = new URLSearchParams();
23 + if (q.q) p.set("q", q.q);
24 + if (q.status) p.set("status", q.status);
25 + if (q.country) p.set("country", q.country);
26 + if (q.operator) p.set("operator", q.operator);
27 + if (q.min_mw) p.set("min_mw", String(q.min_mw));
28 + if (q.ai) p.set("ai", "1");
29 + if (q.sort && q.sort !== "updated") p.set("sort", q.sort);
30 + if (q.order) p.set("order", q.order);
31 + if (q.page && q.page > 1) p.set("page", String(q.page));
32 + return p.toString();
33 +}
added apps/web/src/app/(site)/rankings/(index)/loading.tsx +15 −0
@@ -0,0 +1,15 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-20" />
8 + <Skeleton className="mb-3 h-7 w-40" />
9 + <Skeleton className="mb-6 h-4 w-full max-w-xl" />
10 + <Skeleton className="mb-6 h-10 w-full" />
11 + <SkeletonRows rows={6} />
12 + <SkeletonRows rows={4} className="mt-8" />
13 + </Page>
14 + );
15 +}
added apps/web/src/app/(site)/rankings/(index)/page.tsx +120 −0
@@ -0,0 +1,120 @@
1 +import type { Ranking } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import { SectionNav } from "@/components/entity/section-nav";
5 +import { Badge } from "@/components/ui/badge";
6 +import { Card, CardBody, CardHeader, Page, PageHeader, Section } from "@/components/ui/card";
7 +import { BarList } from "@/components/ui/charts";
8 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
9 +import { api } from "@/lib/api";
10 +import { fmtDate, fmtInt } from "@/lib/format";
11 +import { routes } from "@/lib/routes";
12 +import { pageMetadata } from "@/lib/seo";
13 +import { SCOPE_LABEL, SCOPE_ORDER, firstSentence, fmtRankValue, rowHref } from "../ranking-format";
14 +
15 +export const revalidate = 120;
16 +
17 +export const metadata: Metadata = pageMetadata({
18 + title: "Rankings",
19 + description: "Countries, markets and operators ranked by facilities, known capacity and pipeline. Every MW-based ranking carries its coverage; entities below the coverage gate are excluded, not estimated.",
20 + path: routes.rankings(),
21 +});
22 +
23 +/* Verbatim from docs/RANKINGS.md — “Definitions shared by all rankings”. */
24 +const DEFINITIONS: Array<{ term: string; text: string }> = [
25 + { term: "Known MW", text: "Σ over operational facilities of IT capacity, or total power when the operator publishes only that. Figures marked as estimates count only when no measured figure exists for the facility, and the facility is flagged mw_is_estimate. We never impute MW to facilities without a published or filed figure." },
26 + { term: "Pipeline MW", text: "Σ over facilities not yet operational of planned power (or IT/total power when no planned figure is published), split into planned MW (planning statuses) and construction MW (under construction)." },
27 + { term: "Coverage", text: "Facilities with any MW figure ÷ facilities." },
28 + { term: "Coverage gate", text: "MW-based and per-capita rankings exclude entities with fewer than 5 facilities or coverage below 35 % (minCoverage), so that thin data does not produce spurious leaders. Count-based rankings have no gate." },
29 + { term: "Delta", text: "Rank change versus the previous snapshot of the same ranking (positive = moved up)." },
30 +];
31 +
32 +function RankingCard({ r }: { r: Ranking }) {
33 + const top = (r.rows ?? []).slice(0, 3);
34 + return (
35 + <li className="py-4 first:pt-0 last:pb-0">
36 + <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_280px] md:gap-6">
37 + <div className="min-w-0">
38 + <div className="flex flex-wrap items-center gap-2">
39 + <Link href={routes.ranking(r.key)} className="text-[14.5px] font-semibold text-ink hover:text-accent hover:underline">
40 + {r.label}
41 + </Link>
42 + <Badge tone="outline" size="xs" mono>
43 + {r.unit}
44 + </Badge>
45 + </div>
46 + <p className="mt-1 line-clamp-2 text-[12.5px] text-ink-2">{firstSentence(r.methodology ?? "")}</p>
47 + <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11.5px] text-ink-3">
48 + {r.total > 0 ? (
49 + <span className="figure">
50 + {fmtInt(r.total)} {r.total === 1 ? "entity" : "entities"} ranked
51 + </span>
52 + ) : (
53 + <span className="text-ink-4">{/mw|per/i.test(r.unit) ? "No entity meets the coverage gate yet" : "No rows computed yet"}</span>
54 + )}
55 + <span className="figure">Computed {fmtDate(r.computedAt)}</span>
56 + <Link href={routes.ranking(r.key)} className="text-ink-2 hover:text-accent">
57 + Full ranking →
58 + </Link>
59 + </div>
60 + </div>
61 + {top.length > 0 && (
62 + <div className="min-w-0">
63 + <BarList items={top.map((row) => ({ label: row.name, value: row.value, href: rowHref(row, r.scope), rank: row.rank, coverage: row.coverage }))} format={(v) => fmtRankValue(v, r.unit)} showRank />
64 + </div>
65 + )}
66 + </div>
67 + </li>
68 + );
69 +}
70 +
71 +export default async function RankingsPage() {
72 + const list = await api.rankings();
73 + const ok = list.length > 0;
74 + const groups = SCOPE_ORDER.map((scope) => ({ scope, items: list.filter((r) => r.scope === scope).sort((a, b) => a.label.localeCompare(b.label)) })).filter((g) => g.items.length);
75 +
76 + return (
77 + <Page>
78 + <PageHeader
79 + eyebrow="Rankings"
80 + title="Rankings"
81 + description="Countries, markets and operators ranked by facilities, known capacity and pipeline. Every MW-based ranking carries its coverage; entities below the coverage gate are excluded, not estimated."
82 + />
83 + {!ok && <OfflineNotice detail="rankings unavailable" className="mb-3" />}
84 + {ok && <SectionNav items={groups.map((g) => ({ id: g.scope, label: SCOPE_LABEL[g.scope], count: g.items.length }))} className="mb-4" />}
85 + {!ok && <EmptyState title="Rankings unavailable" description="The index API did not return any ranking. They are recomputed by the worker after each crawl." />}
86 +
87 + <div className="flex flex-col gap-8">
88 + {groups.map((g) => (
89 + <Section key={g.scope} id={g.scope} title={SCOPE_LABEL[g.scope]} description={`${g.items.length} ${g.items.length === 1 ? "ranking" : "rankings"}`} className="scroll-mt-28">
90 + <ul className="divide-y divide-hair">
91 + {g.items.map((r) => (
92 + <RankingCard key={r.key} r={r} />
93 + ))}
94 + </ul>
95 + </Section>
96 + ))}
97 + </div>
98 +
99 + <Card className="mt-10">
100 + <CardHeader title="Definitions shared by all rankings" description="Verbatim from the ranking methodology." />
101 + <CardBody>
102 + <dl className="grid gap-3 text-[13px] text-ink-2 sm:grid-cols-[140px_minmax(0,1fr)] sm:gap-x-6 sm:gap-y-2">
103 + {DEFINITIONS.map((d) => (
104 + <div key={d.term} className="contents">
105 + <dt className="font-medium text-ink">{d.term}</dt>
106 + <dd className="mb-2 sm:mb-0">{d.text}</dd>
107 + </div>
108 + ))}
109 + </dl>
110 + <p className="mt-4 text-[12.5px] text-ink-3">
111 + Facility = a live record (merged duplicates excluded), any status. Operational = operational, partially operational or expansion. Under construction = under_construction. Planned = rumored, proposed, announced, permitting, approved or delayed.{" "}
112 + <Link href={`${routes.methodology()}#rankings`} className="text-ink underline decoration-hair-2 hover:text-accent">
113 + Full methodology
114 + </Link>
115 + </p>
116 + </CardBody>
117 + </Card>
118 + </Page>
119 + );
120 +}
added apps/web/src/app/(site)/rankings/[key]/layout.tsx +11 −0
@@ -0,0 +1,11 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { api } from "@/lib/api";
4 +
5 +/** Existence check outside the loading boundary so unknown keys return a real 404 (the fetch is cached for the page). */
6 +export default async function RankingLayout({ children, params }: { children: ReactNode; params: Promise<{ key: string }> }) {
7 + const { key } = await params;
8 + const r = await api.ranking(key, { limit: 200 });
9 + if (!r.ok && (r.status === 404 || r.status === 400)) notFound();
10 + return children;
11 +}
added apps/web/src/app/(site)/rankings/[key]/loading.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-4 h-3 w-40" />
8 + <Skeleton className="mb-2 h-3 w-24" />
9 + <Skeleton className="mb-3 h-7 w-72" />
10 + <Skeleton className="mb-6 h-4 w-56" />
11 + <SkeletonRows rows={10} />
12 + </Page>
13 + );
14 +}
added apps/web/src/app/(site)/rankings/[key]/opengraph-image.tsx +19 −0
@@ -0,0 +1,19 @@
1 +import { api } from "@/lib/api";
2 +import { fmtInt } from "@/lib/format";
3 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
4 +import { SCOPE_LABEL, fmtRankValue } from "../ranking-format";
5 +
6 +export const alt = "DataCenterIndex ranking";
7 +export const size = OG_SIZE;
8 +export const contentType = OG_CONTENT_TYPE;
9 +export const revalidate = 3600;
10 +
11 +export default async function Image({ params }: { params: Promise<{ key: string }> }) {
12 + const { key } = await params;
13 + const r = await api.ranking(key, { limit: 3 });
14 + if (!r.ok) return renderOg({ eyebrow: "Ranking", title: "DataCenterIndex rankings", subtitle: "Countries, markets and operators ranked by facilities, capacity and pipeline." });
15 + const d = r.data;
16 + const rows = d.rows ?? [];
17 + const stats = rows.length ? rows.slice(0, 3).map((row) => ({ label: `#${row.rank} ${row.name}`.slice(0, 28), value: fmtRankValue(row.value, d.unit) })) : [{ label: "Entities ranked", value: fmtInt(d.total) }];
18 + return renderOg({ eyebrow: "Ranking", title: d.label, subtitle: `${SCOPE_LABEL[d.scope]} · ${d.unit}`, stats });
19 +}
added apps/web/src/app/(site)/rankings/[key]/page.tsx +194 −0
@@ -0,0 +1,194 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
5 +import { JsonLd } from "@/components/entity/json-ld";
6 +import { Badge } from "@/components/ui/badge";
7 +import { cn } from "@/components/ui/cn";
8 +import { Card, CardBody, CardHeader, Page } from "@/components/ui/card";
9 +import { CountryFlag } from "@/components/ui/country-flag";
10 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
11 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
12 +import { api } from "@/lib/api";
13 +import { fmtDate, fmtDelta, fmtInt, fmtPct } from "@/lib/format";
14 +import { routes } from "@/lib/routes";
15 +import { NOT_FOUND_METADATA, absoluteUrl, pageMetadata } from "@/lib/seo";
16 +import { SCOPE_LABEL, firstSentence, fmtRankValue, rowHref, secondaryLabel } from "../ranking-format";
17 +
18 +export const revalidate = 120;
19 +
20 +type Params = Promise<{ key: string }>;
21 +
22 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
23 + const { key } = await params;
24 + const r = await api.ranking(key, { limit: 200 });
25 + if (!r.ok) return r.status === 404 || r.status === 400 ? NOT_FOUND_METADATA : { title: "Ranking" };
26 + const d = r.data;
27 + return pageMetadata({
28 + title: d.label,
29 + description: `Ranking of ${SCOPE_LABEL[d.scope].toLowerCase()} by ${d.label.toLowerCase()} — ${d.unit}. ${firstSentence(d.methodology)}`,
30 + path: routes.ranking(d.key),
31 + });
32 +}
33 +
34 +export default async function RankingPage({ params }: { params: Params }) {
35 + const { key } = await params;
36 + const r = await api.ranking(key, { limit: 200 });
37 + if (!r.ok && (r.status === 404 || r.status === 400)) notFound();
38 + if (!r.ok) {
39 + return (
40 + <Page className="py-6">
41 + <Breadcrumbs items={[{ name: "Rankings", href: routes.rankings() }, { name: key, href: routes.ranking(key) }]} className="mb-4" />
42 + <OfflineNotice detail={r.error} />
43 + </Page>
44 + );
45 + }
46 + const d = r.data;
47 + const rows = d.rows ?? [];
48 + const showFlag = d.scope === "countries" || rows.some((x) => x.countryIso2);
49 + const hasSecondary = rows.some((x) => x.secondary !== null && x.secondary !== undefined);
50 + const hasCoverage = rows.some((x) => x.coverage !== null && x.coverage !== undefined);
51 + const hasDelta = rows.some((x) => x.delta !== null && x.delta !== undefined);
52 + const secLabel = secondaryLabel(d.methodology);
53 +
54 + const jsonLd = {
55 + "@context": "https://schema.org",
56 + "@type": "ItemList",
57 + name: d.label,
58 + description: firstSentence(d.methodology),
59 + url: absoluteUrl(routes.ranking(d.key)),
60 + numberOfItems: rows.length,
61 + itemListOrder: "https://schema.org/ItemListOrderDescending",
62 + itemListElement: rows.slice(0, 100).map((row) => ({ "@type": "ListItem", position: row.rank, name: row.name, url: absoluteUrl(rowHref(row, d.scope)) })),
63 + };
64 +
65 + return (
66 + <Page>
67 + <JsonLd data={jsonLd} />
68 + <Breadcrumbs items={[{ name: "Rankings", href: routes.rankings() }, { name: d.label, href: routes.ranking(d.key) }]} className="pt-4" />
69 + <header className="py-4 sm:py-6">
70 + <div className="label-caps mb-1.5">Ranking · {SCOPE_LABEL[d.scope]}</div>
71 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{d.label}</h1>
72 + <div className="mt-2 flex flex-wrap items-center gap-2">
73 + <Badge tone="outline" mono>
74 + {d.unit}
75 + </Badge>
76 + <Badge tone="neutral">{SCOPE_LABEL[d.scope]}</Badge>
77 + <span className="figure text-[12px] text-ink-3">Computed {fmtDate(d.computedAt)}</span>
78 + {d.total > 0 && (
79 + <span className="figure text-[12px] text-ink-3">
80 + · {fmtInt(d.total)} {d.total === 1 ? "entity" : "entities"}
81 + </span>
82 + )}
83 + </div>
84 + {d.minCoverage !== undefined && d.minCoverage !== null && <p className="mt-2 max-w-2xl text-[12.5px] text-ink-3">Entities with fewer than 5 facilities or MW coverage below {fmtPct(d.minCoverage)} are excluded, not estimated.</p>}
85 + </header>
86 +
87 + {rows.length === 0 ? (
88 + <EmptyState
89 + title="No entity meets the coverage gate yet"
90 + description={
91 + d.minCoverage !== undefined && d.minCoverage !== null
92 + ? `This ranking only lists entities with at least 5 facilities and MW coverage of ${fmtPct(d.minCoverage)} or more. Nothing is estimated to fill the table; rows appear as primary sources publish capacity figures.`
93 + : "Rows appear once the worker has computed this ranking from indexed facilities. Nothing is estimated to fill the table."
94 + }
95 + action={
96 + <Link href={routes.rankings()} className="text-[13px] text-ink underline decoration-hair-2 hover:text-accent">
97 + All rankings
98 + </Link>
99 + }
100 + />
101 + ) : (
102 + <Table dense>
103 + <THead>
104 + <tr>
105 + <Th numeric className="w-12">
106 + #
107 + </Th>
108 + <Th>{d.scope === "countries" ? "Country" : d.scope === "metros" ? "Market" : d.scope === "operators" ? "Operator" : "Facility"}</Th>
109 + <Th numeric>{d.unit}</Th>
110 + {hasSecondary && (
111 + <Th numeric className="hidden md:table-cell">
112 + {secLabel}
113 + </Th>
114 + )}
115 + {hasCoverage && <Th className="w-[140px]">Coverage</Th>}
116 + {hasDelta && (
117 + <Th numeric className="hidden sm:table-cell">
118 + Δ rank
119 + </Th>
120 + )}
121 + </tr>
122 + </THead>
123 + <TBody>
124 + {rows.map((row) => (
125 + <Tr key={row.id}>
126 + <Td label="Rank" numeric className="text-ink-3">
127 + {row.rank}
128 + </Td>
129 + <Td primary>
130 + <span className="inline-flex items-center gap-2">
131 + {showFlag && row.countryIso2 && <CountryFlag iso2={row.countryIso2} size={13} />}
132 + <RowLink href={rowHref(row, d.scope)}>{row.name}</RowLink>
133 + </span>
134 + </Td>
135 + <Td label={d.unit} numeric>
136 + {fmtRankValue(row.value, d.unit)}
137 + </Td>
138 + {hasSecondary && (
139 + <Td label={secLabel} numeric className="hidden text-ink-3 md:table-cell">
140 + {row.secondary === null || row.secondary === undefined ? "—" : fmtInt(row.secondary)}
141 + </Td>
142 + )}
143 + {hasCoverage && (
144 + <Td label="Coverage">
145 + {row.coverage === null || row.coverage === undefined ? (
146 + <span className="text-ink-4">—</span>
147 + ) : (
148 + <span className="flex w-full items-center gap-2 sm:w-[120px]">
149 + <span className="figure w-10 text-right text-[12px] text-ink-2">{fmtPct(row.coverage)}</span>
150 + <span className="h-[3px] min-w-0 flex-1 bg-surface-3" aria-hidden>
151 + <span className={cn("block h-full", row.coverage < 0.5 ? "bg-ink-4" : "bg-ink-2")} style={{ width: `${Math.max(2, Math.min(100, row.coverage * 100))}%` }} />
152 + </span>
153 + </span>
154 + )}
155 + </Td>
156 + )}
157 + {hasDelta && (
158 + <Td label="Δ rank" numeric className="hidden sm:table-cell">
159 + {row.delta === null || row.delta === undefined ? <span className="text-ink-4">new</span> : <span className={cn(row.delta > 0 ? "text-ok" : row.delta < 0 ? "text-danger" : "text-ink-3")}>{fmtDelta(row.delta)}</span>}
160 + </Td>
161 + )}
162 + </Tr>
163 + ))}
164 + </TBody>
165 + </Table>
166 + )}
167 +
168 + <div className="mt-8 grid gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
169 + <Card>
170 + <CardHeader title="Methodology" description={`Unit: ${d.unit} · scope: ${SCOPE_LABEL[d.scope].toLowerCase()}`} />
171 + <CardBody>
172 + <p className="text-[13.5px] leading-relaxed text-ink-2">{d.methodology}</p>
173 + </CardBody>
174 + </Card>
175 + <Card>
176 + <CardHeader title="How to read this" />
177 + <CardBody className="text-[12.5px] leading-relaxed text-ink-2">
178 + <p>Values are lower bounds computed from published or filed figures only. Coverage is the share of an entity&apos;s facilities carrying any MW figure; a low coverage means the value understates reality.</p>
179 + <p className="mt-2">Δ rank compares with the previous computation of the same ranking; “new” marks an entity that was not ranked before.</p>
180 + <p className="mt-3">
181 + <Link href={routes.rankings()} className="text-ink underline decoration-hair-2 hover:text-accent">
182 + All rankings
183 + </Link>
184 + <span className="text-ink-4"> · </span>
185 + <Link href={`${routes.methodology()}#rankings`} className="text-ink underline decoration-hair-2 hover:text-accent">
186 + Methodology
187 + </Link>
188 + </p>
189 + </CardBody>
190 + </Card>
191 + </div>
192 + </Page>
193 + );
194 +}
added apps/web/src/app/(site)/rankings/ranking-format.ts +42 −0
@@ -0,0 +1,42 @@
1 +import type { Ranking, RankingRow } from "@dci/core";
2 +import { DASH, fmtCompact, fmtInt, fmtMw } from "@/lib/format";
3 +import { entityHref } from "@/lib/routes";
4 +
5 +export const SCOPE_LABEL: Record<Ranking["scope"], string> = {
6 + countries: "Countries",
7 + metros: "Markets",
8 + operators: "Operators",
9 + facilities: "Facilities",
10 +};
11 +
12 +export const SCOPE_ORDER: Ranking["scope"][] = ["countries", "metros", "operators", "facilities"];
13 +
14 +/** Format a ranking value according to the ranking unit. */
15 +export function fmtRankValue(v: number | null | undefined, unit: string): string {
16 + if (v === null || v === undefined || !Number.isFinite(v)) return DASH;
17 + const u = unit.toLowerCase();
18 + if (u === "mw") return fmtMw(v);
19 + if (u.includes("per") || u.includes("kw") || u.includes("$")) return `${fmtCompact(v, 2)} ${unit}`;
20 + return fmtInt(v);
21 +}
22 +
23 +/** First sentence of the methodology text (for excerpts). */
24 +export function firstSentence(text: string): string {
25 + const m = text.match(/^.*?[.!?](\s|$)/);
26 + return (m ? m[0] : text).trim();
27 +}
28 +
29 +/** "Secondary: known MW." → "Known MW" */
30 +export function secondaryLabel(methodology: string): string {
31 + const m = methodology.match(/Secondary:\s*([^.]+)\./i);
32 + if (!m) return "Secondary";
33 + const s = m[1]!.trim();
34 + return s.charAt(0).toUpperCase() + s.slice(1);
35 +}
36 +
37 +/** Site href for a ranking row — the API already returns a site path; fall back to entityHref. */
38 +export function rowHref(row: RankingRow, scope: Ranking["scope"]): string {
39 + if (row.href && row.href.startsWith("/")) return row.href;
40 + const type = scope === "countries" ? "country" : scope === "metros" ? "metro" : scope === "operators" ? "operator" : "facility";
41 + return entityHref(type, row.slug, row.name);
42 +}
added apps/web/src/app/(site)/search/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-24" />
8 + <Skeleton className="mb-6 h-7 w-64" />
9 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
10 + {Array.from({ length: 4 }).map((_, i) => (
11 + <div key={i} className="flex flex-col gap-2">
12 + <Skeleton className="h-7 w-20" />
13 + <Skeleton className="h-2.5 w-24" />
14 + </div>
15 + ))}
16 + </div>
17 + <SkeletonRows rows={8} className="mt-6" />
18 + </Page>
19 + );
20 +}
added apps/web/src/app/(site)/sources/(index)/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-2 h-3 w-24" />
8 + <Skeleton className="mb-6 h-7 w-48" />
9 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-4">
10 + {Array.from({ length: 4 }).map((_, i) => (
11 + <div key={i} className="flex flex-col gap-2">
12 + <Skeleton className="h-6 w-16" />
13 + <Skeleton className="h-2.5 w-24" />
14 + </div>
15 + ))}
16 + </div>
17 + <SkeletonRows rows={10} className="mt-6" />
18 + </Page>
19 + );
20 +}
added apps/web/src/app/(site)/sources/(index)/page.tsx +236 −0
@@ -0,0 +1,236 @@
1 +import type { SourceKind } from "@dci/core";
2 +import { SOURCE_KINDS } from "@dci/core";
3 +import type { Metadata } from "next";
4 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
5 +import { ExtLink } from "@/components/entity/kv";
6 +import { SectionNav } from "@/components/entity/section-nav";
7 +import { Badge } from "@/components/ui/badge";
8 +import { Button } from "@/components/ui/button";
9 +import { Page, PageHeader, Section } from "@/components/ui/card";
10 +import { FilterChips, type Chip } from "@/components/ui/filter-chips";
11 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
12 +import { EmptyState, OfflineNotice } from "@/components/ui/states";
13 +import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";
14 +import { api } from "@/lib/api";
15 +import type { SourceWithCounts } from "@/lib/dto";
16 +import { fmtDate, fmtInt } from "@/lib/format";
17 +import { SOURCE_KIND_LABEL } from "@/lib/labels";
18 +import { routes } from "@/lib/routes";
19 +import { pageMetadata } from "@/lib/seo";
20 +import { compareBy, hrefWith, parseSort, sortTh, spGet, type SearchParams } from "@/lib/sort";
21 +
22 +export const revalidate = 120;
23 +
24 +export const metadata: Metadata = pageMetadata({
25 + title: "Sources",
26 + description: "Every source the index reads, with its licence, attribution and how much of the graph it backs. Facts only — each value links back to the page it was observed on.",
27 + path: routes.sources(),
28 +});
29 +
30 +const SORT_KEYS = ["name", "kind", "documents", "provenance", "facilities", "updated"] as const;
31 +type SortKey = (typeof SORT_KEYS)[number];
32 +const KIND_SET = new Set<string>(SOURCE_KINDS);
33 +
34 +function getter(key: SortKey): (s: SourceWithCounts) => number | string | null {
35 + switch (key) {
36 + case "name":
37 + return (s) => s.name;
38 + case "kind":
39 + return (s) => SOURCE_KIND_LABEL[s.kind];
40 + case "documents":
41 + return (s) => s.documents;
42 + case "provenance":
43 + return (s) => s.provenanceRows;
44 + case "facilities":
45 + return (s) => s.facilities;
46 + case "updated":
47 + return (s) => s.lastDocumentAt ?? s.updatedAt;
48 + }
49 +}
50 +
51 +function truncate(s: string | null | undefined, n: number): string {
52 + if (!s) return "—";
53 + return s.length > n ? `${s.slice(0, n - 1)}…` : s;
54 +}
55 +
56 +function SourceTable({ items, sp, grouped, sort }: { items: SourceWithCounts[]; sp: SearchParams; grouped: boolean; sort: ReturnType<typeof parseSort<SortKey>> }) {
57 + const base = routes.sources();
58 + const th = (k: SortKey) => sortTh(base, sp, sort, k);
59 + return (
60 + <Table dense>
61 + <THead>
62 + <tr>
63 + <Th {...th("name")}>Source</Th>
64 + {!grouped && <Th {...th("kind")}>Kind</Th>}
65 + <Th className="hidden lg:table-cell">License</Th>
66 + <Th className="hidden xl:table-cell">Attribution</Th>
67 + <Th numeric {...th("documents")}>
68 + Documents
69 + </Th>
70 + <Th numeric {...th("provenance")}>
71 + Provenance rows
72 + </Th>
73 + <Th numeric {...th("facilities")}>
74 + Facilities
75 + </Th>
76 + <Th className="hidden md:table-cell" {...th("updated")}>
77 + Last document
78 + </Th>
79 + <Th className="hidden sm:table-cell">Link</Th>
80 + </tr>
81 + </THead>
82 + <TBody>
83 + {items.map((s) => (
84 + <Tr key={s.id}>
85 + <Td primary>
86 + <RowLink href={routes.source(s.id)}>{s.name}</RowLink>
87 + <div className="figure text-[11.5px] text-ink-3">{s.domain}</div>
88 + </Td>
89 + {!grouped && (
90 + <Td label="Kind">
91 + <Badge tone="outline" size="xs">
92 + {SOURCE_KIND_LABEL[s.kind]}
93 + </Badge>
94 + </Td>
95 + )}
96 + <Td label="License" className="hidden max-w-[240px] text-[12px] text-ink-2 lg:table-cell" title={s.license ?? undefined}>
97 + {truncate(s.license, 60)}
98 + </Td>
99 + <Td label="Attribution" className="hidden max-w-[200px] text-[12px] text-ink-3 xl:table-cell" title={s.attribution ?? undefined}>
100 + {truncate(s.attribution, 48)}
101 + </Td>
102 + <Td label="Documents" numeric>
103 + {fmtInt(s.documents)}
104 + </Td>
105 + <Td label="Provenance rows" numeric>
106 + {fmtInt(s.provenanceRows)}
107 + </Td>
108 + <Td label="Facilities" numeric>
109 + {fmtInt(s.facilities)}
110 + </Td>
111 + <Td label="Last document" className="figure hidden text-[12px] text-ink-3 md:table-cell">
112 + {fmtDate(s.lastDocumentAt)}
113 + </Td>
114 + <Td label="Link" className="hidden text-[12px] sm:table-cell">
115 + {s.url ? <ExtLink href={s.url}>{s.domain}</ExtLink> : <span className="text-ink-4">—</span>}
116 + </Td>
117 + </Tr>
118 + ))}
119 + </TBody>
120 + </Table>
121 + );
122 +}
123 +
124 +export default async function SourcesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
125 + const sp = await searchParams;
126 + const res = await api.sources();
127 + const all = res.items;
128 + const q = spGet(sp, "q")?.trim().toLowerCase();
129 + const kindParam = spGet(sp, "kind");
130 + const kind = kindParam && KIND_SET.has(kindParam) ? (kindParam as SourceKind) : undefined;
131 + const sortGiven = Boolean(spGet(sp, "sort"));
132 + const sort = parseSort<SortKey>(sp, SORT_KEYS, { key: "provenance", order: "desc" });
133 +
134 + let items = all;
135 + if (q) items = items.filter((s) => s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q));
136 + if (kind) items = items.filter((s) => s.kind === kind);
137 + const cmp = compareBy(getter(sort.key), sort.order);
138 + items = [...items].sort((a, b) => cmp(a, b) || a.name.localeCompare(b.name));
139 +
140 + const totals = {
141 + sources: all.length,
142 + documents: all.reduce((a, s) => a + s.documents, 0),
143 + provenance: all.reduce((a, s) => a + s.provenanceRows, 0),
144 + withData: all.filter((s) => s.provenanceRows > 0).length,
145 + };
146 +
147 + const grouped = !sortGiven && !kind;
148 + const kindsPresent = SOURCE_KINDS.filter((k) => all.some((s) => s.kind === k));
149 + const groups = grouped
150 + ? kindsPresent
151 + .map((k) => ({ kind: k, items: items.filter((s) => s.kind === k) }))
152 + .filter((g) => g.items.length)
153 + .sort((a, b) => b.items.reduce((x, s) => x + s.provenanceRows, 0) - a.items.reduce((x, s) => x + s.provenanceRows, 0))
154 + : [];
155 +
156 + const chips: Chip[] = [];
157 + if (q) chips.push({ key: "q", label: "Search", value: q, removeHref: hrefWith(routes.sources(), sp, { q: undefined }) });
158 + if (kind) chips.push({ key: "kind", label: "Kind", value: SOURCE_KIND_LABEL[kind], removeHref: hrefWith(routes.sources(), sp, { kind: undefined }) });
159 + if (sortGiven) chips.push({ key: "sort", label: "Sorted by", value: `${sort.key} ${sort.order}`, removeHref: hrefWith(routes.sources(), sp, { sort: undefined, order: undefined }) });
160 +
161 + return (
162 + <Page>
163 + <Breadcrumbs items={[{ name: "Sources", href: routes.sources() }]} className="pt-3" />
164 + <PageHeader
165 + eyebrow="Registry"
166 + title="Sources"
167 + description="Every source the index reads, with its licence, attribution and how much of the graph it backs. Facts only — each value links back to the page it was observed on."
168 + className="pt-3"
169 + />
170 + {!res.items.length && !res.total && <OfflineNotice detail="sources unavailable" className="mb-3" />}
171 +
172 + <MetricRow cols={4}>
173 + <MetricCell>
174 + <Metric value={fmtInt(totals.sources)} label="Sources" size="sm" />
175 + </MetricCell>
176 + <MetricCell>
177 + <Metric value={fmtInt(totals.documents)} label="Documents archived" size="sm" />
178 + </MetricCell>
179 + <MetricCell>
180 + <Metric value={fmtInt(totals.provenance)} label="Provenance rows" size="sm" />
181 + </MetricCell>
182 + <MetricCell>
183 + <Metric value={fmtInt(totals.withData)} label="Sources with data" size="sm" note="≥ 1 current provenance row" />
184 + </MetricCell>
185 + </MetricRow>
186 +
187 + <form method="get" action={routes.sources()} className="mt-4 flex flex-wrap items-end gap-2" role="search" aria-label="Filter sources">
188 + <label className="flex min-w-0 flex-1 flex-col gap-1 sm:max-w-[280px]">
189 + <span className="label-caps">Search</span>
190 + <input name="q" defaultValue={q ?? ""} placeholder="Name or domain" className="h-9 min-w-0 rounded-sm border border-hair bg-surface px-2.5 text-[13px] text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent" />
191 + </label>
192 + <label className="flex flex-col gap-1">
193 + <span className="label-caps">Kind</span>
194 + <select name="kind" defaultValue={kind ?? ""} className="h-9 rounded-sm border border-hair bg-surface px-2 text-[13px] text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent">
195 + <option value="">All kinds</option>
196 + {kindsPresent.map((k) => (
197 + <option key={k} value={k}>
198 + {SOURCE_KIND_LABEL[k]}
199 + </option>
200 + ))}
201 + </select>
202 + </label>
203 + {sortGiven && <input type="hidden" name="sort" value={sort.key} />}
204 + {sortGiven && <input type="hidden" name="order" value={sort.order} />}
205 + <Button type="submit" size="md" variant="secondary">
206 + Apply
207 + </Button>
208 + </form>
209 + <FilterChips chips={chips} resetHref={routes.sources()} className="mt-3" />
210 +
211 + {!items.length ? (
212 + <EmptyState className="mt-4" title="No source matches" description={all.length ? "Loosen the search or clear the filters." : "The source registry is not reachable right now."} compact />
213 + ) : grouped ? (
214 + <div className="mt-4 flex flex-col gap-6">
215 + <SectionNav items={groups.map((g) => ({ id: `kind-${g.kind}`, label: SOURCE_KIND_LABEL[g.kind], count: g.items.length }))} />
216 + {groups.map((g) => (
217 + <Section key={g.kind} id={`kind-${g.kind}`} className="scroll-mt-28" title={SOURCE_KIND_LABEL[g.kind]} description={`${fmtInt(g.items.length)} ${g.items.length === 1 ? "source" : "sources"} · ${fmtInt(g.items.reduce((a, s) => a + s.provenanceRows, 0))} provenance rows`} level={3}>
218 + <SourceTable items={g.items} sp={sp} grouped sort={sort} />
219 + </Section>
220 + ))}
221 + </div>
222 + ) : (
223 + <div className="mt-4">
224 + <SourceTable items={items} sp={sp} grouped={false} sort={sort} />
225 + </div>
226 + )}
227 + <p className="mt-6 text-[12px] text-ink-4">
228 + Kinds rank source authority when values conflict (operator, government and filings first; news last) — see the{" "}
229 + <a href={`${routes.methodology()}#reconciliation`} className="underline decoration-hair-2 hover:text-ink">
230 + reconciliation rules
231 + </a>
232 + . Crawling policy: <a href={routes.bot()} className="underline decoration-hair-2 hover:text-ink">/bot</a>.
233 + </p>
234 + </Page>
235 + );
236 +}
added apps/web/src/app/(site)/sources/[id]/coverage-bars.tsx +9 −0
@@ -0,0 +1,9 @@
1 +"use client";
2 +
3 +import { BarList, type BarListItem } from "@/components/ui/charts";
4 +import { fmtInt } from "@/lib/format";
5 +
6 +/** Client wrapper so the server page can request integer formatting (functions cannot cross the RSC boundary). */
7 +export function CoverageBars({ items }: { items: BarListItem[] }) {
8 + return <BarList items={items} format={(v) => fmtInt(v)} showRank={false} />;
9 +}
added apps/web/src/app/(site)/sources/[id]/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import { notFound } from "next/navigation";
2 +import type { ReactNode } from "react";
3 +import { api } from "@/lib/api";
4 +
5 +/**
6 + * Existence check outside the loading boundary: a 404 from the API becomes a real HTTP 404 instead of a soft 404
7 + * streamed after a 200 shell. The fetch is deduplicated with the page's own call (same URL, same revalidate).
8 + */
9 +export default async function SourceLayout({ params, children }: { params: Promise<{ id: string }>; children: ReactNode }) {
10 + const { id } = await params;
11 + const r = await api.source(id);
12 + if (!r.ok && r.status === 404) notFound();
13 + return <>{children}</>;
14 +}
added apps/web/src/app/(site)/sources/[id]/loading.tsx +23 −0
@@ -0,0 +1,23 @@
1 +import { Page } from "@/components/ui/card";
2 +import { Skeleton, SkeletonRows, SkeletonText } from "@/components/ui/skeleton";
3 +
4 +export default function Loading() {
5 + return (
6 + <Page className="py-6" aria-busy>
7 + <Skeleton className="mb-3 h-3 w-40" />
8 + <Skeleton className="mb-2 h-3 w-16" />
9 + <Skeleton className="mb-3 h-7 w-72" />
10 + <Skeleton className="mb-6 h-4 w-56" />
11 + <div className="grid grid-cols-2 gap-4 border-y border-hair py-3 sm:grid-cols-5">
12 + {Array.from({ length: 5 }).map((_, i) => (
13 + <div key={i} className="flex flex-col gap-2">
14 + <Skeleton className="h-6 w-16" />
15 + <Skeleton className="h-2.5 w-24" />
16 + </div>
17 + ))}
18 + </div>
19 + <SkeletonText lines={4} className="mt-8" />
20 + <SkeletonRows rows={5} className="mt-6" />
21 + </Page>
22 + );
23 +}
added apps/web/src/app/(site)/sources/[id]/opengraph-image.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { api } from "@/lib/api";
2 +import { fmtInt } from "@/lib/format";
3 +import { SOURCE_KIND_LABEL } from "@/lib/labels";
4 +import { OG_CONTENT_TYPE, OG_SIZE, renderOg } from "@/lib/og-template";
5 +
6 +export const alt = "Source — DataCenterIndex";
7 +export const size = OG_SIZE;
8 +export const contentType = OG_CONTENT_TYPE;
9 +export const revalidate = 3600;
10 +
11 +export default async function Image({ params }: { params: Promise<{ id: string }> }) {
12 + const { id } = await params;
13 + const r = await api.source(id);
14 + if (!r.ok) return renderOg({ eyebrow: "Source", title: "Source registry", subtitle: "Every value in the index links back to the page it was observed on." });
15 + const s = r.data;
16 + return renderOg({
17 + eyebrow: "Source",
18 + title: s.name,
19 + subtitle: `${s.domain} · ${SOURCE_KIND_LABEL[s.kind]}${s.attribution ? ` · ${s.attribution}` : ""}`,
20 + stats: [
21 + { label: "Documents", value: fmtInt(s.documents) },
22 + { label: "Provenance rows", value: fmtInt(s.provenanceRows) },
23 + { label: "Facilities", value: fmtInt(s.facilities) },
24 + ],
25 + });
26 +}
added apps/web/src/app/(site)/sources/[id]/page.tsx +186 −0
@@ -0,0 +1,186 @@
1 +import type { SourceRef } from "@dci/core";
2 +import type { Metadata } from "next";
3 +import Link from "next/link";
4 +import { notFound } from "next/navigation";
5 +import { Breadcrumbs } from "@/components/entity/breadcrumbs";
6 +import { JsonLd } from "@/components/entity/json-ld";
7 +import { ExtLink, KeyValueList } from "@/components/entity/kv";
8 +import { SectionNav } from "@/components/entity/section-nav";
9 +import { SourcesFooter } from "@/components/entity/sources-footer";
10 +import { CoverageBars } from "./coverage-bars";
11 +import { Badge } from "@/components/ui/badge";
12 +import { Button } from "@/components/ui/button";
13 +import { Page, Section } from "@/components/ui/card";
14 +import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";
15 +import { ErrorState, OfflineNotice } from "@/components/ui/states";
16 +import { api } from "@/lib/api";
17 +import { fmtDate, fmtInt } from "@/lib/format";
18 +import { ENTITY_TYPE_LABEL, SOURCE_KIND_LABEL } from "@/lib/labels";
19 +import { routes } from "@/lib/routes";
20 +import { NOT_FOUND_METADATA, absoluteUrl, compactJsonLd, pageMetadata } from "@/lib/seo";
21 +
22 +export const revalidate = 300;
23 +
24 +type Params = Promise<{ id: string }>;
25 +
26 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
27 + const { id } = await params;
28 + const r = await api.source(id);
29 + if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Source" };
30 + const s = r.data;
31 + return pageMetadata({
32 + title: s.name,
33 + description: `${s.name} (${SOURCE_KIND_LABEL[s.kind].toLowerCase()} source, ${s.domain}) — ${fmtInt(s.documents)} documents, ${fmtInt(s.provenanceRows)} provenance rows across ${fmtInt(s.facilities)} facilities in DataCenterIndex.`,
34 + path: routes.source(s.id),
35 + });
36 +}
37 +
38 +function humanise(s: string): string {
39 + return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
40 +}
41 +
42 +export default async function SourcePage({ params }: { params: Params }) {
43 + const { id } = await params;
44 + const r = await api.source(id);
45 + if (!r.ok && r.status === 404) notFound();
46 + if (!r.ok) {
47 + return (
48 + <Page className="py-6">
49 + <Breadcrumbs items={[{ name: "Sources", href: routes.sources() }, { name: id, href: routes.source(id) }]} />
50 + <OfflineNotice detail={r.error} className="my-4" />
51 + <ErrorState />
52 + </Page>
53 + );
54 + }
55 + const s = r.data;
56 + const ref: SourceRef = { id: s.id, name: s.name, domain: s.domain, kind: s.kind, url: s.url, license: s.license, attribution: s.attribution };
57 + const robots = s.robotsAllowed === true ? { tone: "ok" as const, label: "robots.txt allows" } : s.robotsAllowed === false ? { tone: "warn" as const, label: "robots.txt disallows (API endpoint — see notes)" } : { tone: "neutral" as const, label: "robots.txt not evaluated" };
58 + const entityRows = s.byEntityType.map((e) => ({ label: ENTITY_TYPE_LABEL[e.entityType] ?? humanise(e.entityType), value: e.count }));
59 + const pageRows = s.byPageType.map((p) => ({ label: humanise(p.pageType), value: p.count }));
60 + const nav = [
61 + { id: "licence", label: "Licence" },
62 + { id: "notes", label: "Notes", count: s.notes ? 1 : 0 },
63 + { id: "coverage", label: "Coverage", count: entityRows.length + pageRows.length },
64 + { id: "connector", label: "Connector" },
65 + { id: "facilities", label: "Facilities" },
66 + ];
67 +
68 + return (
69 + <Page>
70 + <Breadcrumbs items={[{ name: "Sources", href: routes.sources() }, { name: s.name, href: routes.source(s.id) }]} className="pt-3" />
71 + <JsonLd data={compactJsonLd({ "@context": "https://schema.org", "@type": "Organization", "@id": `${absoluteUrl(routes.source(s.id))}#org`, name: s.name, url: s.url ?? `https://${s.domain}`, description: s.attribution })} />
72 +
73 + <header className="flex flex-col gap-2 py-5 sm:py-7">
74 + <div className="label-caps">Source</div>
75 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{s.name}</h1>
76 + <div className="flex flex-wrap items-center gap-2 text-[13px] text-ink-2">
77 + <Badge tone="outline">{SOURCE_KIND_LABEL[s.kind]}</Badge>
78 + <Badge tone={robots.tone} size="sm">
79 + {robots.label}
80 + </Badge>
81 + <span className="text-ink-4" aria-hidden>
82 + ·
83 + </span>
84 + <ExtLink href={`https://${s.domain}`}>{s.domain}</ExtLink>
85 + {s.url && (
86 + <>
87 + <span className="text-ink-4" aria-hidden>
88 + ·
89 + </span>
90 + <ExtLink href={s.url}>Licence / terms</ExtLink>
91 + </>
92 + )}
93 + <span className="figure text-[11.5px] text-ink-4" title="Authority rank used when values conflict (lower = higher authority)">
94 + priority {s.priority}
95 + </span>
96 + </div>
97 + </header>
98 +
99 + <MetricRow cols={5}>
100 + <MetricCell>
101 + <Metric value={fmtInt(s.documents)} label="Documents" size="sm" />
102 + </MetricCell>
103 + <MetricCell>
104 + <Metric value={fmtInt(s.provenanceRows)} label="Provenance rows" size="sm" />
105 + </MetricCell>
106 + <MetricCell>
107 + <Metric value={fmtInt(s.facilities)} label="Facilities backed" size="sm" />
108 + </MetricCell>
109 + <MetricCell>
110 + <Metric value={fmtDate(s.lastDocumentAt)} label="Last document" size="sm" />
111 + </MetricCell>
112 + <MetricCell>
113 + <Metric value={fmtDate(s.updatedAt)} label="Registry updated" size="sm" />
114 + </MetricCell>
115 + </MetricRow>
116 +
117 + <SectionNav items={nav} className="mt-4" />
118 +
119 + <div className="mt-6 flex flex-col gap-8">
120 + <Section id="licence" className="scroll-mt-28" title="Licence & attribution" description="Recorded per source; the attribution line is shown on every page that uses a value from this source.">
121 + <KeyValueList
122 + items={[
123 + { label: "Licence", value: s.license ?? <span className="text-ink-3">Not recorded — facts only are extracted; the source&rsquo;s own terms apply.</span>, keepEmpty: true },
124 + { label: "Attribution", value: s.attribution ?? <span className="text-ink-3">{`Source: ${s.name}`}</span>, keepEmpty: true },
125 + { label: "Terms URL", value: s.url ? <ExtLink href={s.url} /> : null },
126 + ]}
127 + />
128 + </Section>
129 +
130 + {s.notes && (
131 + <Section id="notes" className="scroll-mt-28" title="Notes" description="Quirks recorded by the connector author (access method, limits, robots exceptions).">
132 + <p className="max-w-3xl whitespace-pre-line text-[13.5px] leading-relaxed text-ink-2">{s.notes}</p>
133 + </Section>
134 + )}
135 +
136 + {(entityRows.length > 0 || pageRows.length > 0) && (
137 + <Section id="coverage" className="scroll-mt-28" title="Coverage" description="Current provenance rows by entity type, and archived documents by page type.">
138 + <div className="grid gap-6 md:grid-cols-2">
139 + {entityRows.length > 0 && (
140 + <div>
141 + <div className="label-caps mb-2">Provenance rows by entity</div>
142 + <CoverageBars items={entityRows} />
143 + </div>
144 + )}
145 + {pageRows.length > 0 && (
146 + <div>
147 + <div className="label-caps mb-2">Documents by page type</div>
148 + <CoverageBars items={pageRows} />
149 + </div>
150 + )}
151 + </div>
152 + </Section>
153 + )}
154 +
155 + <Section id="connector" className="scroll-mt-28" title="Connector" description="How this source is read.">
156 + <KeyValueList
157 + items={[
158 + { label: "Connector id", value: <span className="figure">{s.connectorId}</span> },
159 + { label: "Kind", value: SOURCE_KIND_LABEL[s.kind] },
160 + { label: "robots.txt", value: robots.label },
161 + {
162 + label: "Pipeline",
163 + value: (
164 + <Link href={`${routes.methodology()}#pipeline`} className="underline decoration-hair-2 hover:text-accent">
165 + discover → fetch → archive → extract → normalize → reconcile → publish
166 + </Link>
167 + ),
168 + },
169 + ]}
170 + />
171 + </Section>
172 +
173 + <Section id="facilities" className="scroll-mt-28" title="Facilities backed by this source" description={`${fmtInt(s.facilities)} facilities carry at least one current value observed here.`}>
174 + <p className="max-w-2xl text-[13px] text-ink-2">The public index does not filter facilities by source yet. Browse the facilities index — each facility page lists, field by field, which source backs which value, with first/last observation dates and a link to the page it was read from.</p>
175 + <div className="mt-3">
176 + <Button href={routes.datacenters()} size="sm">
177 + Browse facilities
178 + </Button>
179 + </div>
180 + </Section>
181 + </div>
182 +
183 + <SourcesFooter sources={[ref]} updatedAt={s.updatedAt} note="Licence and attribution are recorded per source and shown wherever its values appear." />
184 + </Page>
185 + );
186 +}
added apps/web/src/app/admin/(shell)/connectors/[id]/page.tsx +12 −0
@@ -0,0 +1,12 @@
1 +import type { Metadata } from "next";
2 +import { ConnectorDetailView } from "@/components/admin/connector-detail";
3 +
4 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
5 + const { id } = await params;
6 + return { title: `${id} · Connector` };
7 +}
8 +
9 +export default async function AdminConnectorDetailPage({ params }: { params: Promise<{ id: string }> }) {
10 + const { id } = await params;
11 + return <ConnectorDetailView id={id} />;
12 +}
added apps/web/src/app/admin/(shell)/connectors/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { ConnectorsTable } from "@/components/admin/connectors-table";
4 +
5 +export const metadata: Metadata = { title: "Connectors" };
6 +
7 +export default function AdminConnectorsPage() {
8 + return (
9 + <Suspense fallback={null}>
10 + <ConnectorsTable />
11 + </Suspense>
12 + );
13 +}
added apps/web/src/app/admin/(shell)/devtool/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { DevTool } from "@/components/admin/devtool";
4 +
5 +export const metadata: Metadata = { title: "Connector dev tool" };
6 +
7 +export default function AdminDevtoolPage() {
8 + return (
9 + <Suspense fallback={null}>
10 + <DevTool />
11 + </Suspense>
12 + );
13 +}
added apps/web/src/app/admin/(shell)/documents/[id]/page.tsx +12 −0
@@ -0,0 +1,12 @@
1 +import type { Metadata } from "next";
2 +import { DocumentInspectView } from "@/components/admin/document-inspect";
3 +
4 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
5 + const { id } = await params;
6 + return { title: `${id} · Document` };
7 +}
8 +
9 +export default async function AdminDocumentPage({ params }: { params: Promise<{ id: string }> }) {
10 + const { id } = await params;
11 + return <DocumentInspectView id={id} />;
12 +}
added apps/web/src/app/admin/(shell)/documents/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { DocumentsTable } from "@/components/admin/documents-table";
4 +
5 +export const metadata: Metadata = { title: "Documents" };
6 +
7 +export default function AdminDocumentsPage() {
8 + return (
9 + <Suspense fallback={null}>
10 + <DocumentsTable />
11 + </Suspense>
12 + );
13 +}
added apps/web/src/app/admin/(shell)/events/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { EventsReview } from "@/components/admin/events-review";
4 +
5 +export const metadata: Metadata = { title: "Events" };
6 +
7 +export default function AdminEventsPage() {
8 + return (
9 + <Suspense fallback={null}>
10 + <EventsReview />
11 + </Suspense>
12 + );
13 +}
added apps/web/src/app/admin/(shell)/facilities/[id]/edit/page.tsx +12 −0
@@ -0,0 +1,12 @@
1 +import type { Metadata } from "next";
2 +import { FacilityEditView } from "@/components/admin/facility-edit";
3 +
4 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
5 + const { id } = await params;
6 + return { title: `Curate ${id}` };
7 +}
8 +
9 +export default async function AdminFacilityEditPage({ params }: { params: Promise<{ id: string }> }) {
10 + const { id } = await params;
11 + return <FacilityEditView id={id} />;
12 +}
added apps/web/src/app/admin/(shell)/layout.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import { cookies, headers } from "next/headers";
2 +import { redirect } from "next/navigation";
3 +import type { ReactNode } from "react";
4 +import { AdminShell } from "@/components/admin/shell";
5 +import { ADMIN_COOKIE } from "@/lib/admin-api";
6 +
7 +export const dynamic = "force-dynamic";
8 +
9 +/**
10 + * Auth guard for every console page: no `dci_admin` cookie → /admin/login?next=…
11 + * The token itself is validated by the API on each proxied call (a stale cookie yields 401 → the client hook
12 + * redirects to the login page with reason=expired).
13 + */
14 +export default async function AdminShellLayout({ children }: { children: ReactNode }) {
15 + const jar = await cookies();
16 + if (!jar.get(ADMIN_COOKIE)?.value) {
17 + const h = await headers();
18 + const next = h.get("x-invoke-path") ?? h.get("next-url") ?? "/admin";
19 + redirect(`/admin/login?next=${encodeURIComponent(next.startsWith("/admin") ? next : "/admin")}`);
20 + }
21 + return <AdminShell>{children}</AdminShell>;
22 +}
added apps/web/src/app/admin/(shell)/matches/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { MatchesQueue } from "@/components/admin/matches-queue";
4 +
5 +export const metadata: Metadata = { title: "Matches" };
6 +
7 +export default function AdminMatchesPage() {
8 + return (
9 + <Suspense fallback={null}>
10 + <MatchesQueue />
11 + </Suspense>
12 + );
13 +}
added apps/web/src/app/admin/(shell)/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import type { Metadata } from "next";
2 +import { OpsOverviewView } from "@/components/admin/ops-overview";
3 +
4 +export const metadata: Metadata = { title: "Overview" };
5 +
6 +export default function AdminOverviewPage() {
7 + return <OpsOverviewView />;
8 +}
added apps/web/src/app/admin/(shell)/sources/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import type { Metadata } from "next";
2 +import { SourcesTable } from "@/components/admin/sources-table";
3 +
4 +export const metadata: Metadata = { title: "Sources" };
5 +
6 +export default function AdminSourcesPage() {
7 + return <SourcesTable />;
8 +}
added apps/web/src/app/sitemap.xml/ids.ts +65 −0
@@ -0,0 +1,65 @@
1 +import type { MetadataRoute } from "next";
2 +import { SITEMAP_KINDS, type SitemapKind } from "@/lib/dto";
3 +import { routes } from "@/lib/routes";
4 +
5 +/**
6 + * Shared by app/sitemap.ts (per-id sitemaps at /sitemap/<id>.xml) and app/sitemap.xml/route.ts (the index).
7 + * Ids are deterministic strings so the index never depends on the API being reachable at build time:
8 + * static · <kind>-1 … <kind>-PAGES_PER_KIND (5 000 URLs per API page; empty pages yield no URLs)
9 + */
10 +export const PAGES_PER_KIND = 4;
11 +
12 +type Freq = MetadataRoute.Sitemap[number]["changeFrequency"];
13 +
14 +export const STATIC_PAGES: Array<{ path: string; changeFrequency: Freq; priority: number }> = [
15 + { path: routes.home(), changeFrequency: "hourly", priority: 1 },
16 + { path: routes.map(), changeFrequency: "daily", priority: 0.9 },
17 + { path: routes.datacenters(), changeFrequency: "daily", priority: 0.9 },
18 + { path: routes.markets(), changeFrequency: "daily", priority: 0.8 },
19 + { path: routes.operators(), changeFrequency: "daily", priority: 0.8 },
20 + { path: routes.countries(), changeFrequency: "daily", priority: 0.8 },
21 + { path: routes.projects(), changeFrequency: "daily", priority: 0.8 },
22 + { path: routes.rankings(), changeFrequency: "daily", priority: 0.7 },
23 + { path: routes.cloudRegions(), changeFrequency: "weekly", priority: 0.7 },
24 + { path: routes.ixps(), changeFrequency: "weekly", priority: 0.5 },
25 + { path: routes.live(), changeFrequency: "hourly", priority: 0.7 },
26 + { path: routes.sources(), changeFrequency: "weekly", priority: 0.5 },
27 + { path: routes.methodology(), changeFrequency: "monthly", priority: 0.5 },
28 + { path: routes.bot(), changeFrequency: "monthly", priority: 0.3 },
29 + { path: routes.about(), changeFrequency: "monthly", priority: 0.4 },
30 + { path: routes.search(), changeFrequency: "monthly", priority: 0.3 },
31 +];
32 +
33 +export const KIND_HREF: Record<SitemapKind, (slug: string) => string> = {
34 + facilities: routes.facility,
35 + operators: routes.operator,
36 + countries: routes.country,
37 + metros: routes.market,
38 + projects: routes.project,
39 + "cloud-regions": routes.cloudRegion,
40 +};
41 +
42 +export const KIND_FREQ: Record<SitemapKind, Freq> = {
43 + facilities: "weekly",
44 + operators: "weekly",
45 + countries: "daily",
46 + metros: "daily",
47 + projects: "weekly",
48 + "cloud-regions": "monthly",
49 +};
50 +
51 +export function sitemapIds(): string[] {
52 + const ids = ["static"];
53 + for (const kind of SITEMAP_KINDS) for (let p = 1; p <= PAGES_PER_KIND; p++) ids.push(`${kind}-${p}`);
54 + return ids;
55 +}
56 +
57 +export function parseSitemapId(id: string): { kind: SitemapKind; page: number } | "static" | null {
58 + if (id === "static") return "static";
59 + const m = /^(.+)-(\d+)$/.exec(id);
60 + if (!m) return null;
61 + const kind = m[1] as SitemapKind;
62 + if (!(SITEMAP_KINDS as readonly string[]).includes(kind)) return null;
63 + const page = Number(m[2]);
64 + return page >= 1 && page <= PAGES_PER_KIND ? { kind, page } : null;
65 +}
added apps/web/src/app/sitemap.xml/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +import { SITE_URL } from "@/lib/routes";
2 +import { sitemapIds } from "./ids";
3 +
4 +/** Sitemap index (robots.txt points here) listing every per-id sitemap generated by app/sitemap.ts. */
5 +export const revalidate = 3600;
6 +
7 +export function GET(): Response {
8 + const now = new Date().toISOString();
9 + const body = `<?xml version="1.0" encoding="UTF-8"?>
10 +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
11 +${sitemapIds()
12 + .map((id) => ` <sitemap><loc>${SITE_URL}/sitemap/${id}.xml</loc><lastmod>${now}</lastmod></sitemap>`)
13 + .join("\n")}
14 +</sitemapindex>
15 +`;
16 + return new Response(body, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400" } });
17 +}
added apps/web/src/app/sitemap/[id]/route.ts +56 −0
@@ -0,0 +1,56 @@
1 +import { api } from "@/lib/api";
2 +import { SITE_URL } from "@/lib/routes";
3 +import { KIND_FREQ, KIND_HREF, STATIC_PAGES, parseSitemapId } from "../../sitemap.xml/ids";
4 +
5 +/**
6 + * Per-id sitemaps at /sitemap/<id>.xml — `static` for the fixed pages, `<kind>-<page>` for each entity kind
7 + * (5 000 URLs per API page, see /api/v1/sitemap/:kind). Listed by the index at /sitemap.xml.
8 + * Hand-rolled instead of Next's `generateSitemaps` because that convention also claims /sitemap.xml itself
9 + * (and crashes there without an id), which robots.txt points to.
10 + */
11 +export const revalidate = 3600;
12 +
13 +const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
14 +
15 +interface Entry {
16 + url: string;
17 + lastModified?: Date;
18 + changeFrequency?: string;
19 + priority?: number;
20 +}
21 +
22 +function toXml(entries: Entry[]): string {
23 + const body = entries
24 + .map((e) => {
25 + const parts = [`<loc>${esc(e.url)}</loc>`];
26 + if (e.lastModified) parts.push(`<lastmod>${e.lastModified.toISOString()}</lastmod>`);
27 + if (e.changeFrequency) parts.push(`<changefreq>${e.changeFrequency}</changefreq>`);
28 + if (e.priority !== undefined) parts.push(`<priority>${e.priority}</priority>`);
29 + return ` <url>${parts.join("")}</url>`;
30 + })
31 + .join("\n");
32 + return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
33 +}
34 +
35 +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
36 + const { id } = await ctx.params;
37 + if (!id.endsWith(".xml")) return new Response("Not Found", { status: 404 });
38 + const parsed = parseSitemapId(id.slice(0, -4));
39 + if (!parsed) return new Response("Not Found", { status: 404 });
40 +
41 + let entries: Entry[];
42 + if (parsed === "static") {
43 + const now = new Date();
44 + entries = STATIC_PAGES.map((p) => ({ url: `${SITE_URL}${p.path}`, lastModified: now, changeFrequency: p.changeFrequency, priority: p.priority }));
45 + } else {
46 + const r = await api.sitemap(parsed.kind, parsed.page);
47 + if (!r.ok && r.status !== 404) return new Response("Service Unavailable", { status: 503, headers: { "Retry-After": "300" } });
48 + const href = KIND_HREF[parsed.kind];
49 + const priority = parsed.kind === "facilities" || parsed.kind === "countries" ? 0.7 : 0.6;
50 + entries = (r.ok && Array.isArray(r.data) ? r.data : []).map((e) => {
51 + const d = new Date(e.updatedAt);
52 + return { url: `${SITE_URL}${href(e.slug)}`, lastModified: Number.isNaN(d.getTime()) ? undefined : d, changeFrequency: KIND_FREQ[parsed.kind], priority };
53 + });
54 + }
55 + return new Response(toXml(entries), { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400" } });
56 +}
added apps/web/src/components/admin/connector-actions.tsx +144 −0
@@ -0,0 +1,144 @@
1 +"use client";
2 +
3 +import { Play } from "lucide-react";
4 +import { useState } from "react";
5 +import { Button } from "@/components/ui";
6 +import type { ConnectorHealthDTO } from "@/lib/admin-api";
7 +import { Checkbox, Field, Input, Select } from "./primitives";
8 +import { useToast } from "./toast";
9 +import { useAdminMutation } from "./use-admin-query";
10 +
11 +export type RunTask = "crawl" | "discover" | "full" | "reprocess";
12 +
13 +/** Inline "run connector" form: task, group, limit, force → POST connectors/:id/run. */
14 +export function RunForm({ connector, onDone, compact }: { connector: Pick<ConnectorHealthDTO, "id" | "schedule">; onDone?: () => void; compact?: boolean }) {
15 + const [task, setTask] = useState<RunTask>("crawl");
16 + const [group, setGroup] = useState("");
17 + const [limit, setLimit] = useState("");
18 + const [force, setForce] = useState(false);
19 + const { busy, run } = useAdminMutation();
20 + const toast = useToast();
21 + const groups = Object.keys(connector.schedule ?? {});
22 + const submit = async () => {
23 + const body: Record<string, unknown> = { task };
24 + if (group) body.group = group;
25 + if (limit && Number(limit) > 0) body.limit = Math.floor(Number(limit));
26 + if (force) body.force = true;
27 + const r = await run<{ enqueued: boolean; job?: { id?: string; name?: string } }>("run", `connectors/${encodeURIComponent(connector.id)}/run`, { body });
28 + if (r.ok) {
29 + toast({ tone: "ok", title: `Run enqueued · ${connector.id}`, description: `task ${task}${group ? ` · group ${group}` : ""}${r.data.job?.id ? ` · job ${r.data.job.id}` : ""}` });
30 + onDone?.();
31 + } else toast({ tone: "danger", title: "Run failed to enqueue", description: r.error });
32 + };
33 + return (
34 + <div className={compact ? "grid grid-cols-2 gap-2 sm:grid-cols-[140px_1fr_110px_auto_auto] sm:items-end" : "grid gap-2 sm:grid-cols-[160px_1fr_120px_auto_auto] sm:items-end"}>
35 + <Field label="Task">
36 + <Select value={task} onChange={(e) => setTask(e.target.value as RunTask)}>
37 + <option value="crawl">crawl — due pages</option>
38 + <option value="discover">discover — sitemap / RSS / seeds</option>
39 + <option value="full">full — discover + crawl</option>
40 + <option value="reprocess">reprocess — re-extract stored bodies</option>
41 + </Select>
42 + </Field>
43 + <Field label="Group" hint={groups.length ? `schedule groups: ${groups.join(", ")}` : undefined}>
44 + <Input list={`groups-${connector.id}`} value={group} onChange={(e) => setGroup(e.target.value)} placeholder="all groups" mono />
45 + {groups.length > 0 && (
46 + <datalist id={`groups-${connector.id}`}>
47 + {groups.map((g) => (
48 + <option key={g} value={g} />
49 + ))}
50 + </datalist>
51 + )}
52 + </Field>
53 + <Field label="Limit">
54 + <Input type="number" min={1} max={100000} value={limit} onChange={(e) => setLimit(e.target.value)} placeholder="∞" mono />
55 + </Field>
56 + <Checkbox label="force" checked={force} onChange={(e) => setForce(e.target.checked)} />
57 + <Button variant="accent" size="md" onClick={() => void submit()} disabled={busy === "run"}>
58 + <Play className="size-3.5" strokeWidth={2} />
59 + Run
60 + </Button>
61 + </div>
62 + );
63 +}
64 +
65 +/** Inline schedule editor: one interval per group → PATCH connectors/:id/schedule. */
66 +export function ScheduleEditor({ connector, onSaved }: { connector: Pick<ConnectorHealthDTO, "id" | "schedule">; onSaved?: (schedule: Record<string, string>) => void }) {
67 + const [rows, setRows] = useState<Array<{ group: string; interval: string }>>(() => Object.entries(connector.schedule ?? {}).map(([group, interval]) => ({ group, interval })));
68 + const [error, setError] = useState<string | null>(null);
69 + const { busy, run } = useAdminMutation();
70 + const toast = useToast();
71 + const save = async () => {
72 + const body: Record<string, string> = {};
73 + for (const r of rows) {
74 + if (!r.group.trim()) continue;
75 + if (!/^[a-z0-9_*-]+$/i.test(r.group.trim())) return setError(`bad group name "${r.group}"`);
76 + if (!/^(hourly|daily|weekly|biweekly|monthly|quarterly|never|\d+\s*(m|min|h|d|w))$/i.test(r.interval.trim())) return setError(`bad interval for ${r.group}: "${r.interval}" (daily, weekly, 12h, 30m…)`);
77 + body[r.group.trim()] = r.interval.trim().toLowerCase();
78 + }
79 + if (!Object.keys(body).length) return setError("nothing to save");
80 + setError(null);
81 + const r = await run<{ id: string; schedule: Record<string, string>; file: { updated: boolean; error?: string; backup: string | null } }>("schedule", `connectors/${encodeURIComponent(connector.id)}/schedule`, { method: "PATCH", body });
82 + if (r.ok) {
83 + toast({ tone: r.data.file.updated ? "ok" : "warn", title: "Schedule saved", description: r.data.file.updated ? `YAML updated${r.data.file.backup ? " (backup kept)" : ""}` : `DB updated; YAML not updated: ${r.data.file.error ?? "unknown"}` });
84 + onSaved?.(r.data.schedule);
85 + } else {
86 + setError(r.error);
87 + toast({ tone: "danger", title: "Schedule not saved", description: r.error });
88 + }
89 + };
90 + return (
91 + <div className="space-y-2">
92 + <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
93 + {rows.map((r, i) => (
94 + <div key={i} className="flex items-end gap-2">
95 + <Field label="Group" className="flex-1">
96 + <Input value={r.group} onChange={(e) => setRows((p) => p.map((x, j) => (j === i ? { ...x, group: e.target.value } : x)))} mono />
97 + </Field>
98 + <Field label="Interval" className="w-[120px]">
99 + <Input list="dci-intervals" value={r.interval} onChange={(e) => setRows((p) => p.map((x, j) => (j === i ? { ...x, interval: e.target.value } : x)))} mono />
100 + </Field>
101 + <Button size="md" variant="ghost" aria-label="Remove group" onClick={() => setRows((p) => p.filter((_, j) => j !== i))}>
102 + ×
103 + </Button>
104 + </div>
105 + ))}
106 + </div>
107 + <datalist id="dci-intervals">
108 + {["hourly", "3h", "6h", "12h", "daily", "weekly", "biweekly", "monthly", "quarterly", "never"].map((v) => (
109 + <option key={v} value={v} />
110 + ))}
111 + </datalist>
112 + {error && <p className="text-[12px] text-danger">{error}</p>}
113 + <div className="flex flex-wrap gap-2">
114 + <Button size="sm" variant="ghost" onClick={() => setRows((p) => [...p, { group: "", interval: "weekly" }])}>
115 + + group
116 + </Button>
117 + <Button size="sm" variant="primary" onClick={() => void save()} disabled={busy === "schedule"}>
118 + Save schedule
119 + </Button>
120 + <span className="self-center text-[11.5px] text-ink-4">Intervals: hourly, daily, weekly, biweekly, monthly, quarterly, never, or 30m / 12h / 3d / 2w. Writes the DB row and the YAML file (backup kept).</span>
121 + </div>
122 + </div>
123 + );
124 +}
125 +
126 +/** Pause / resume toggle → POST connectors/:id/pause|resume. */
127 +export function PauseResumeButton({ connector, onChanged, size = "xs" }: { connector: Pick<ConnectorHealthDTO, "id" | "health">; onChanged?: (paused: boolean) => void; size?: "xs" | "sm" }) {
128 + const { busy, run } = useAdminMutation();
129 + const toast = useToast();
130 + const paused = connector.health === "paused";
131 + const toggle = async () => {
132 + const action = paused ? "resume" : "pause";
133 + const r = await run<{ id: string; paused: boolean; health: string }>(action, `connectors/${encodeURIComponent(connector.id)}/${action}`, { body: {} });
134 + if (r.ok) {
135 + toast({ tone: "ok", title: `${connector.id} ${r.data.paused ? "paused" : "resumed"}` });
136 + onChanged?.(r.data.paused);
137 + } else toast({ tone: "danger", title: `Could not ${action}`, description: r.error });
138 + };
139 + return (
140 + <Button size={size} variant={paused ? "secondary" : "ghost"} onClick={() => void toggle()} disabled={busy !== null}>
141 + {paused ? "Resume" : "Pause"}
142 + </Button>
143 + );
144 +}
added apps/web/src/components/admin/connector-detail.tsx +335 −0
@@ -0,0 +1,335 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo, useState } from "react";
5 +import { Badge, Button, EmptyState, LineChart, RelativeTime, SkeletonBlock, SkeletonRows, Tab, TabList, TabPanel, Tabs, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
6 +import { cn } from "@/components/ui/cn";
7 +import { DASH, fmtDateTimeUtc, fmtInt } from "@/lib/format";
8 +import { SOURCE_KIND_LABEL } from "@/lib/labels";
9 +import type { AdminRun, ConnectorAdminDetail } from "@/lib/admin-api";
10 +import { PauseResumeButton, RunForm, ScheduleEditor } from "./connector-actions";
11 +import { Code, Disclosure, durationBetween, ErrorNote, ExtLink, fmtMs, fmtPct01, HealthDot, JsonView, KV, Mono, Panel, RefreshButton, RunStatusBadge, Stat } from "./primitives";
12 +import { AdminPageHeader } from "./shell";
13 +import { useAdminQuery } from "./use-admin-query";
14 +import { toYaml } from "./yaml";
15 +
16 +function n(v: unknown): number | null {
17 + return typeof v === "number" && Number.isFinite(v) ? v : null;
18 +}
19 +
20 +function RunLog({ id }: { id: string }) {
21 + const q = useAdminQuery<AdminRun>(`runs/${encodeURIComponent(id)}`);
22 + if (q.loading) return <SkeletonRows rows={3} />;
23 + if (q.error || !q.data) return <ErrorNote error={q.error ?? "not found"} onRetry={() => void q.refresh()} />;
24 + const log = q.data.log ?? [];
25 + return (
26 + <div className="space-y-2">
27 + <JsonView value={q.data.stats} maxHeight={200} collapsible />
28 + {log.length === 0 ? (
29 + <p className="text-[12px] text-ink-4">No log lines recorded.</p>
30 + ) : (
31 + <div className="max-h-[360px] overflow-auto rounded-xs border border-hair bg-paper font-mono text-[11.5px] leading-[1.5]">
32 + {log.map((l, i) => (
33 + <div key={i} className={cn("flex gap-2 border-b border-hair/60 px-2 py-0.5 last:border-0", l.level === "error" && "bg-danger/10 text-danger", l.level === "warn" && "bg-warn/10 text-warn")}>
34 + <span className="shrink-0 text-ink-4">{l.t ? String(l.t).slice(11, 19) : ""}</span>
35 + <span className="w-10 shrink-0 text-ink-3">{l.level ?? ""}</span>
36 + <span className="min-w-0 break-all text-ink-2">{String(l.msg ?? JSON.stringify(l))}</span>
37 + </div>
38 + ))}
39 + </div>
40 + )}
41 + </div>
42 + );
43 +}
44 +
45 +export function ConnectorDetailView({ id }: { id: string }) {
46 + const q = useAdminQuery<ConnectorAdminDetail>(`connectors/${encodeURIComponent(id)}`, { refreshMs: 30_000, timeoutMs: 30_000 });
47 + const [runOpen, setRunOpen] = useState(false);
48 + const d = q.data;
49 + const h = d?.health;
50 + const yaml = useMemo(() => (d ? toYaml(d.config) : ""), [d]);
51 + const series = useMemo(() => {
52 + if (!d) return [];
53 + const runs = [...d.runs].reverse().filter((r) => r.startedAt);
54 + const label = (iso: string | null) => (iso ? iso.slice(5, 16).replace("T", " ") : "");
55 + const pick = (k: string) => runs.map((r) => ({ x: label(r.startedAt), y: n((r.stats ?? {})[k]) }));
56 + return [
57 + { name: "fetched", data: pick("fetched"), color: "var(--ink-2)" },
58 + { name: "changed", data: pick("changed"), color: "var(--accent)" },
59 + { name: "failed", data: pick("failed"), color: "var(--danger)" },
60 + ];
61 + }, [d]);
62 +
63 + if (q.error && !d) {
64 + return (
65 + <div>
66 + <AdminPageHeader eyebrow="Connector" title={id} />
67 + <ErrorNote error={q.status === 404 ? `Connector "${id}" not found` : q.error} onRetry={() => void q.refresh()} />
68 + <Button href="/admin/connectors" className="mt-3" size="sm">
69 + Back to connectors
70 + </Button>
71 + </div>
72 + );
73 + }
74 +
75 + return (
76 + <div>
77 + <AdminPageHeader
78 + eyebrow={
79 + <Link href="/admin/connectors" className="hover:text-ink">
80 + Connectors
81 + </Link>
82 + }
83 + title={
84 + <span className="inline-flex items-center gap-2">
85 + {h && <HealthDot health={h.lastStatus === "running" ? "running" : h.health} size={10} />}
86 + <span className="font-mono">{id}</span>
87 + </span>
88 + }
89 + description={
90 + h ? (
91 + <span className="inline-flex flex-wrap items-center gap-2">
92 + {h.sourceName} · <ExtLink href={`https://${h.domain}`}>{h.domain}</ExtLink>
93 + <Badge size="xs">{SOURCE_KIND_LABEL[h.kind] ?? h.kind}</Badge>
94 + <Badge size="xs" mono tone="outline">
95 + {h.mode}
96 + </Badge>
97 + <Badge size="xs" mono tone="outline">
98 + parser {h.parserVersion}
99 + </Badge>
100 + {d?.paused && (
101 + <Badge size="xs" tone="warn">
102 + paused
103 + </Badge>
104 + )}
105 + {!h.enabled && !d?.paused && <Badge size="xs">disabled</Badge>}
106 + </span>
107 + ) : undefined
108 + }
109 + aside={
110 + <>
111 + <RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />
112 + {h && (
113 + <>
114 + <PauseResumeButton connector={h} size="sm" onChanged={() => void q.refresh()} />
115 + <Button size="sm" href={`/admin/documents?connector=${encodeURIComponent(id)}`}>
116 + Documents
117 + </Button>
118 + <Button size="sm" href={`/admin/devtool?id=${encodeURIComponent(id)}&step=4`}>
119 + Open in dev tool
120 + </Button>
121 + <Button size="sm" variant="accent" onClick={() => setRunOpen((v) => !v)}>
122 + Run…
123 + </Button>
124 + </>
125 + )}
126 + </>
127 + }
128 + />
129 + {runOpen && h && (
130 + <Panel title="Run connector" className="mb-3">
131 + <RunForm connector={h} onDone={() => { setRunOpen(false); void q.refresh(); }} />
132 + </Panel>
133 + )}
134 + {d?.lastError && <ErrorNote error={`Last error: ${d.lastError}`} className="mb-3" />}
135 +
136 + {!h ? (
137 + <SkeletonBlock className="mb-3 h-16" />
138 + ) : (
139 + <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">
140 + <Stat label="Discovered" value={fmtInt(h.discovered)} />
141 + <Stat label="Fetched" value={fmtInt(h.fetched)} />
142 + <Stat label="Changed" value={fmtInt(h.changed)} tone={h.changed > 0 ? "ok" : undefined} />
143 + <Stat label="Failed" value={fmtInt(h.failed)} tone={h.failed > 0 ? "danger" : undefined} />
144 + <Stat label="Extraction" value={fmtPct01(h.extractionSuccess)} tone={h.extractionSuccess != null && h.extractionSuccess < 0.7 ? "warn" : undefined} />
145 + <Stat label="Avg response" value={h.avgResponseMs != null ? fmtMs(h.avgResponseMs) : DASH} />
146 + <Stat label="Credits (7 d)" value={fmtInt(h.cost.credits)} note={`${fmtInt(h.cost.direct)} direct · ${fmtInt(h.cost.firecrawl)} fc · ${fmtInt(h.cost.scrapfly)} sf`} />
147 + <Stat label="Next run" value={h.nextRunAt ? <RelativeTime iso={h.nextRunAt} /> : DASH} note={h.lastRunAt ? <span>last <RelativeTime iso={h.lastRunAt} /></span> : "never run"} />
148 + </div>
149 + )}
150 +
151 + <div className="grid gap-3 lg:grid-cols-3">
152 + <Panel title="Health history" description="Per run — fetched, changed, failed (last 30 runs)" className="lg:col-span-2">
153 + {!d ? <SkeletonBlock className="h-[180px]" /> : series[0]!.data.length < 2 ? <EmptyState compact title="Not enough runs to chart" /> : <LineChart series={series} height={180} ariaLabel="Run history" />}
154 + </Panel>
155 + <Panel title="Cost & fetchers" description="From ClickHouse crawl_log over 7 days (falls back to run stats)">
156 + {!h ? (
157 + <SkeletonRows rows={4} />
158 + ) : (
159 + <KV
160 + items={[
161 + { k: "Direct requests", v: fmtInt(h.cost.direct) },
162 + { k: "Firecrawl requests", v: fmtInt(h.cost.firecrawl) },
163 + { k: "Scrapfly requests", v: fmtInt(h.cost.scrapfly) },
164 + { k: "Premium credits", v: <span className={cn(h.cost.credits > 0 && "text-accent")}>{fmtInt(h.cost.credits)}</span> },
165 + { k: "Fetch level", v: <Mono>{String((d?.config.fetch as { level?: number } | undefined)?.level ?? 1)} → max {String((d?.config.fetch as { maxLevel?: number } | undefined)?.maxLevel ?? 2)}</Mono> },
166 + { k: "Max credits / run", v: fmtInt((d?.config.fetch as { maxCreditsPerRun?: number } | undefined)?.maxCreditsPerRun ?? null) },
167 + { k: "Created", v: d?.createdAt ? fmtDateTimeUtc(d.createdAt) : DASH },
168 + { k: "Updated", v: d?.updatedAt ? fmtDateTimeUtc(d.updatedAt) : DASH },
169 + ]}
170 + />
171 + )}
172 + </Panel>
173 + </div>
174 +
175 + <Tabs defaultValue="runs" className="mt-4">
176 + <TabList label="Connector sections">
177 + <Tab value="runs" count={d?.runs.length ?? null}>
178 + Runs
179 + </Tab>
180 + <Tab value="config">Config</Tab>
181 + <Tab value="schedule">Schedule</Tab>
182 + <Tab value="documents" count={d ? d.documentsByPageType.reduce((a, x) => a + x.count, 0) : null}>
183 + Documents
184 + </Tab>
185 + <Tab value="errors" count={d?.errorSamples.length ?? null}>
186 + Errors
187 + </Tab>
188 + </TabList>
189 +
190 + <TabPanel value="runs" className="pt-3">
191 + {!d ? (
192 + <SkeletonRows rows={5} />
193 + ) : d.runs.length === 0 ? (
194 + <EmptyState compact title="No runs yet" description="Use Run… to enqueue a discover / crawl / full job." />
195 + ) : (
196 + <div className="space-y-1.5">
197 + {d.runs.map((r) => {
198 + const s = r.stats ?? {};
199 + return (
200 + <Disclosure
201 + key={r.id}
202 + summary={
203 + <span className="flex flex-wrap items-center gap-x-3 gap-y-1">
204 + <RunStatusBadge status={r.status} />
205 + <Badge size="xs" mono>
206 + {r.task}
207 + </Badge>
208 + <span className="text-ink-3">
209 + <RelativeTime iso={r.startedAt} />
210 + </span>
211 + <span className="figure text-ink-3">{fmtMs(durationBetween(r.startedAt, r.finishedAt))}</span>
212 + <span className="figure text-ink-2">
213 + {fmtInt(n(s.fetched))} fetched · {fmtInt(n(s.changed))} changed · {fmtInt(n(s.entities))} entities · <span className={cn((n(s.failed) ?? 0) > 0 && "text-danger")}>{fmtInt(n(s.failed))} failed</span>
214 + {n(s.credits) ? <span className="text-accent"> · {fmtInt(n(s.credits))} cr</span> : null}
215 + </span>
216 + <Mono className="ml-auto hidden text-ink-4 sm:inline">{r.id}</Mono>
217 + {r.error && <span className="w-full truncate text-[11.5px] text-danger">{r.error}</span>}
218 + </span>
219 + }
220 + >
221 + <RunLog id={r.id} />
222 + </Disclosure>
223 + );
224 + })}
225 + </div>
226 + )}
227 + </TabPanel>
228 +
229 + <TabPanel value="config" className="pt-3">
230 + {!d ? (
231 + <SkeletonBlock className="h-40" />
232 + ) : (
233 + <div className="space-y-2">
234 + <div className="flex flex-wrap items-center justify-between gap-2 text-[12px] text-ink-3">
235 + <span>Config stored in the connectors table (synced from config/connectors/{id}.yaml). Read-only here — edit in the dev tool.</span>
236 + <Button size="xs" href={`/admin/devtool?id=${encodeURIComponent(id)}&step=4`}>
237 + Edit YAML in dev tool
238 + </Button>
239 + </div>
240 + <Code text={yaml} lang="yaml" maxHeight={640} />
241 + </div>
242 + )}
243 + </TabPanel>
244 +
245 + <TabPanel value="schedule" className="pt-3">{h ? <ScheduleEditor key={JSON.stringify(h.schedule)} connector={h} onSaved={() => void q.refresh()} /> : <SkeletonRows rows={2} />}</TabPanel>
246 +
247 + <TabPanel value="documents" className="pt-3">
248 + {!d ? (
249 + <SkeletonRows rows={3} />
250 + ) : d.documentsByPageType.length === 0 ? (
251 + <EmptyState compact title="No documents registered" />
252 + ) : (
253 + <Table className="text-[12.5px]">
254 + <THead>
255 + <Tr>
256 + <Th>Page type</Th>
257 + <Th numeric>Documents</Th>
258 + <Th numeric>Changed</Th>
259 + <Th numeric>Failed</Th>
260 + <Th numeric>Quarantined</Th>
261 + </Tr>
262 + </THead>
263 + <TBody>
264 + {d.documentsByPageType.map((x) => (
265 + <Tr key={x.pageType}>
266 + <Td primary>
267 + <Link href={`/admin/documents?connector=${encodeURIComponent(id)}&pageType=${encodeURIComponent(x.pageType)}`} className="font-mono text-[12.5px] text-ink hover:text-accent hover:underline">
268 + {x.pageType}
269 + </Link>
270 + </Td>
271 + <Td label="Documents" numeric>
272 + {fmtInt(x.count)}
273 + </Td>
274 + <Td label="Changed" numeric>
275 + {fmtInt(x.changed)}
276 + </Td>
277 + <Td label="Failed" numeric className={cn(x.failed > 0 && "text-danger")}>
278 + {fmtInt(x.failed)}
279 + </Td>
280 + <Td label="Quarantined" numeric className={cn(x.quarantined > 0 && "text-warn")}>
281 + {fmtInt(x.quarantined)}
282 + </Td>
283 + </Tr>
284 + ))}
285 + </TBody>
286 + </Table>
287 + )}
288 + </TabPanel>
289 +
290 + <TabPanel value="errors" className="pt-3">
291 + {!d ? (
292 + <SkeletonRows rows={3} />
293 + ) : d.errorSamples.length === 0 ? (
294 + <EmptyState compact title="No document errors" />
295 + ) : (
296 + <Table className="text-[12.5px]">
297 + <THead>
298 + <Tr>
299 + <Th>URL</Th>
300 + <Th>Error</Th>
301 + <Th numeric>HTTP</Th>
302 + <Th numeric>Count</Th>
303 + <Th>Last checked</Th>
304 + </Tr>
305 + </THead>
306 + <TBody>
307 + {d.errorSamples.map((e) => (
308 + <Tr key={e.id}>
309 + <Td primary>
310 + <Link href={`/admin/documents/${encodeURIComponent(e.id)}`} className="block max-w-[420px] truncate font-mono text-[12px] text-ink hover:text-accent hover:underline" title={e.url}>
311 + {e.url.replace(/^https?:\/\//, "")}
312 + </Link>
313 + </Td>
314 + <Td label="Error" className="max-w-[360px] break-words text-danger">
315 + {e.error ?? DASH}
316 + </Td>
317 + <Td label="HTTP" numeric>
318 + {e.statusCode ?? DASH}
319 + </Td>
320 + <Td label="Count" numeric>
321 + {fmtInt(e.errorCount)}
322 + </Td>
323 + <Td label="Last checked" muted>
324 + <RelativeTime iso={e.lastChecked} />
325 + </Td>
326 + </Tr>
327 + ))}
328 + </TBody>
329 + </Table>
330 + )}
331 + </TabPanel>
332 + </Tabs>
333 + </div>
334 + );
335 +}
added apps/web/src/components/admin/connectors-table.tsx +225 −0
@@ -0,0 +1,225 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo, useState } from "react";
5 +import { Badge, Button, EmptyState, RelativeTime, SearchInput, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
6 +import { cn } from "@/components/ui/cn";
7 +import { DASH, fmtInt } from "@/lib/format";
8 +import { SOURCE_KIND_LABEL } from "@/lib/labels";
9 +import type { ConnectorHealthDTO } from "@/lib/admin-api";
10 +import { PauseResumeButton, RunForm, ScheduleEditor } from "./connector-actions";
11 +import { ErrorNote, fmtMs, fmtPct01, HealthDot, Mono, RefreshButton, Segmented, Select } from "./primitives";
12 +import { AdminPageHeader } from "./shell";
13 +import { useAdminQuery } from "./use-admin-query";
14 +import { useUrlState } from "./use-url-state";
15 +
16 +type SortKey = "id" | "kind" | "health" | "lastRunAt" | "nextRunAt" | "discovered" | "fetched" | "changed" | "failed" | "extractionSuccess" | "avgResponseMs" | "credits" | "parserVersion";
17 +const HEALTH_ORDER: Record<string, number> = { failing: 0, degraded: 1, ok: 2, never_run: 3, paused: 4 };
18 +const HEALTH_FILTERS = ["all", "ok", "degraded", "failing", "paused", "never_run", "enabled", "disabled"] as const;
19 +type HealthFilter = (typeof HEALTH_FILTERS)[number];
20 +
21 +function sortValue(c: ConnectorHealthDTO, k: SortKey): number | string {
22 + switch (k) {
23 + case "credits":
24 + return c.cost.credits;
25 + case "health":
26 + return HEALTH_ORDER[c.health] ?? 9;
27 + case "lastRunAt":
28 + case "nextRunAt":
29 + return c[k] ? new Date(c[k] as string).getTime() : -1;
30 + case "extractionSuccess":
31 + case "avgResponseMs":
32 + return c[k] ?? -1;
33 + default: {
34 + const v = c[k];
35 + return typeof v === "number" ? v : String(v ?? "");
36 + }
37 + }
38 +}
39 +
40 +export function ConnectorsTable() {
41 + const q = useAdminQuery<ConnectorHealthDTO[]>("connectors", { refreshMs: 60_000, timeoutMs: 30_000 });
42 + const [params, setParams] = useUrlState();
43 + const kind = params.kind ?? "";
44 + const health = (HEALTH_FILTERS.includes(params.health as HealthFilter) ? params.health : "all") as HealthFilter;
45 + const search = params.q ?? "";
46 + const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "health", dir: "asc" });
47 + const [open, setOpen] = useState<{ id: string; mode: "run" | "schedule" } | null>(null);
48 +
49 + const list = useMemo(() => q.data ?? [], [q.data]);
50 + const counts = useMemo(() => {
51 + const c: Record<string, number> = { all: list.length, enabled: 0, disabled: 0 };
52 + for (const x of list) {
53 + c[x.health] = (c[x.health] ?? 0) + 1;
54 + if (x.enabled) c.enabled!++;
55 + else c.disabled!++;
56 + }
57 + return c;
58 + }, [list]);
59 + const kinds = useMemo(() => [...new Set(list.map((c) => c.kind))].sort(), [list]);
60 +
61 + const rows = useMemo(() => {
62 + const s = search.trim().toLowerCase();
63 + const out = list.filter((c) => (!kind || c.kind === kind) && (health === "all" || (health === "enabled" ? c.enabled : health === "disabled" ? !c.enabled : c.health === health)) && (!s || c.id.includes(s) || c.sourceName.toLowerCase().includes(s) || c.domain.toLowerCase().includes(s)));
64 + out.sort((a, b) => {
65 + const va = sortValue(a, sort.key);
66 + const vb = sortValue(b, sort.key);
67 + const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
68 + return (sort.dir === "asc" ? cmp : -cmp) || a.id.localeCompare(b.id);
69 + });
70 + return out;
71 + }, [list, kind, health, search, sort]);
72 +
73 + const th = (key: SortKey, label: string, numeric?: boolean) => (
74 + <Th numeric={numeric} sort={sort.key === key ? sort.dir : null} className="cursor-pointer select-none" onClick={() => setSort((s) => ({ key, dir: s.key === key && s.dir === "asc" ? "desc" : s.key === key ? "asc" : numeric ? "desc" : "asc" }))}>
75 + {label}
76 + </Th>
77 + );
78 +
79 + return (
80 + <div>
81 + <AdminPageHeader title="Connectors" description={q.data ? `${fmtInt(list.length)} connectors · ${fmtInt(counts.enabled ?? 0)} enabled · health computed from the last run and document registry` : "Connector control center"} aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />
82 + <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />
83 +
84 + <div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center">
85 + <Segmented
86 + label="Health"
87 + value={health}
88 + onChange={(v) => setParams({ health: v === "all" ? null : v })}
89 + options={HEALTH_FILTERS.map((h) => ({ value: h, label: h === "all" ? "All" : h === "never_run" ? "Never run" : h[0]!.toUpperCase() + h.slice(1), count: counts[h] ?? 0 }))}
90 + />
91 + <div className="flex flex-1 gap-2">
92 + <Select value={kind} onChange={(e) => setParams({ kind: e.target.value || null })} className="w-[170px] shrink-0" aria-label="Kind">
93 + <option value="">All kinds</option>
94 + {kinds.map((k) => (
95 + <option key={k} value={k}>
96 + {SOURCE_KIND_LABEL[k] ?? k}
97 + </option>
98 + ))}
99 + </Select>
100 + <SearchInput size="sm" value={search} onChange={(v) => setParams({ q: v || null })} placeholder="Search id, name, domain" wrapperClassName="flex-1" />
101 + </div>
102 + </div>
103 +
104 + {q.loading && !q.data ? (
105 + <SkeletonRows rows={10} />
106 + ) : rows.length === 0 ? (
107 + <EmptyState compact title="No connectors match" description="Adjust the health / kind filters or the search." />
108 + ) : (
109 + <div className="overflow-x-auto rounded-sm border border-hair bg-surface">
110 + <Table className="min-w-[1180px] text-[12.5px] sm:[&_td]:py-1.5" responsive={false}>
111 + <THead>
112 + <Tr>
113 + <Th className="pl-3">{""}</Th>
114 + {th("id", "Connector")}
115 + {th("kind", "Kind")}
116 + <Th>Mode</Th>
117 + <Th>State</Th>
118 + {th("lastRunAt", "Last crawl")}
119 + {th("nextRunAt", "Next")}
120 + {th("discovered", "Disc.", true)}
121 + {th("fetched", "Fetched", true)}
122 + {th("changed", "Changed", true)}
123 + {th("failed", "Failed", true)}
124 + {th("extractionSuccess", "Extract %", true)}
125 + {th("avgResponseMs", "Avg ms", true)}
126 + {th("credits", "Cost", true)}
127 + {th("parserVersion", "Parser")}
128 + <Th className="pr-3">Actions</Th>
129 + </Tr>
130 + </THead>
131 + <TBody>
132 + {rows.map((c) => {
133 + const isOpen = open?.id === c.id;
134 + return (
135 + <ConnectorRow key={c.id} c={c} open={isOpen ? open.mode : null} onToggle={(mode) => setOpen((o) => (o?.id === c.id && o.mode === mode ? null : { id: c.id, mode }))} onChanged={() => void q.refresh()} mutate={q.mutate} />
136 + );
137 + })}
138 + </TBody>
139 + </Table>
140 + </div>
141 + )}
142 + </div>
143 + );
144 +}
145 +
146 +function ConnectorRow({ c, open, onToggle, onChanged, mutate }: { c: ConnectorHealthDTO; open: "run" | "schedule" | null; onToggle: (m: "run" | "schedule") => void; onChanged: () => void; mutate: (fn: (prev: ConnectorHealthDTO[] | null) => ConnectorHealthDTO[] | null) => void }) {
147 + const cost = c.cost;
148 + return (
149 + <>
150 + <Tr className={cn(!c.enabled && "opacity-70", open && "bg-surface-2/60")}>
151 + <Td className="pl-3">
152 + <HealthDot health={c.lastStatus === "running" ? "running" : c.health} />
153 + </Td>
154 + <Td primary>
155 + <Link href={`/admin/connectors/${encodeURIComponent(c.id)}`} className="font-mono text-[12.5px] font-medium text-ink hover:text-accent hover:underline">
156 + {c.id}
157 + </Link>
158 + <div className="max-w-[240px] truncate text-[11.5px] text-ink-3" title={`${c.sourceName} · ${c.domain}`}>
159 + {c.sourceName}
160 + </div>
161 + </Td>
162 + <Td muted>{SOURCE_KIND_LABEL[c.kind] ?? c.kind}</Td>
163 + <Td>
164 + <Badge size="xs" mono tone="outline">
165 + {c.mode}
166 + </Badge>
167 + </Td>
168 + <Td>
169 + {c.health === "paused" ? (
170 + <Badge size="xs" tone="warn">
171 + paused
172 + </Badge>
173 + ) : c.enabled ? (
174 + <Badge size="xs" tone="ok">
175 + enabled
176 + </Badge>
177 + ) : (
178 + <Badge size="xs">disabled</Badge>
179 + )}
180 + </Td>
181 + <Td muted>{c.lastRunAt ? <RelativeTime iso={c.lastRunAt} /> : DASH}</Td>
182 + <Td muted>{c.nextRunAt ? <RelativeTime iso={c.nextRunAt} /> : DASH}</Td>
183 + <Td numeric>{fmtInt(c.discovered)}</Td>
184 + <Td numeric>{fmtInt(c.fetched)}</Td>
185 + <Td numeric className={cn(c.changed > 0 && "text-accent-ink")}>
186 + {fmtInt(c.changed)}
187 + </Td>
188 + <Td numeric className={cn(c.failed > 0 && "text-danger")}>
189 + {fmtInt(c.failed)}
190 + </Td>
191 + <Td numeric className={cn(c.extractionSuccess != null && c.extractionSuccess < 0.7 && "text-warn")}>
192 + {fmtPct01(c.extractionSuccess)}
193 + </Td>
194 + <Td numeric>{c.avgResponseMs != null ? fmtMs(c.avgResponseMs) : DASH}</Td>
195 + <Td numeric title={`direct ${cost.direct} · firecrawl ${cost.firecrawl} · scrapfly ${cost.scrapfly} · ${cost.credits} credits (7 d)`}>
196 + <span className="text-ink-3">{fmtInt(cost.direct)}</span>
197 + {cost.firecrawl > 0 && <span className="ml-1 text-st-planned">fc {fmtInt(cost.firecrawl)}</span>}
198 + {cost.scrapfly > 0 && <span className="ml-1 text-st-permitting">sf {fmtInt(cost.scrapfly)}</span>}
199 + {cost.credits > 0 && <span className="ml-1 text-accent">{fmtInt(cost.credits)} cr</span>}
200 + </Td>
201 + <Td>
202 + <Mono>{c.parserVersion}</Mono>
203 + </Td>
204 + <Td className="pr-3">
205 + <div className="flex items-center gap-1">
206 + <Button size="xs" variant={open === "run" ? "primary" : "secondary"} onClick={() => onToggle("run")}>
207 + Run
208 + </Button>
209 + <PauseResumeButton connector={c} onChanged={(paused) => mutate((prev) => prev?.map((x) => (x.id === c.id ? { ...x, health: paused ? "paused" : x.lastRunAt ? "ok" : "never_run", enabled: !paused && x.enabled } : x)) ?? prev)} />
210 + <Button size="xs" variant={open === "schedule" ? "primary" : "ghost"} onClick={() => onToggle("schedule")}>
211 + Schedule
212 + </Button>
213 + </div>
214 + </Td>
215 + </Tr>
216 + {open && (
217 + <tr className="bg-surface-2/60">
218 + <td colSpan={16} className="px-3 pb-3 pt-1">
219 + {open === "run" ? <RunForm connector={c} compact onDone={onChanged} /> : <ScheduleEditor connector={c} onSaved={(schedule) => mutate((prev) => prev?.map((x) => (x.id === c.id ? { ...x, schedule } : x)) ?? prev)} />}
220 + </td>
221 + </tr>
222 + )}
223 + </>
224 + );
225 +}
added apps/web/src/components/admin/devtool/index.tsx +234 −0
@@ -0,0 +1,234 @@
1 +"use client";
2 +
3 +import { Check } from "lucide-react";
4 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5 +import { useMounted } from "@/components/theme";
6 +import { Badge, Button } from "@/components/ui";
7 +import { cn } from "@/components/ui/cn";
8 +import { adminFetch, adminFetchText, type ConfigListItem, type DevtoolFetchResult, type DevtoolPreviewResult, type RunSampleResult, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api";
9 +import { ErrorNote, Mono, Select, Spinner } from "../primitives";
10 +import { AdminPageHeader } from "../shell";
11 +import { useToast } from "../toast";
12 +import { useAdminQuery } from "../use-admin-query";
13 +import { useUrlState } from "../use-url-state";
14 +import { buildYaml, draftFromConfig, EMPTY_DRAFT, loadDraft, saveDraft, type DevtoolShared, type Draft } from "./state";
15 +import { StepDeploy } from "./step-deploy";
16 +import { StepFetch } from "./step-fetch";
17 +import { StepPreview } from "./step-preview";
18 +import { StepRules } from "./step-rules";
19 +import { StepSample } from "./step-sample";
20 +import { StepSave } from "./step-save";
21 +
22 +const STEPS = [
23 + { n: 1, label: "Fetch", short: "Fetch sample" },
24 + { n: 2, label: "Rules", short: "Extraction rules" },
25 + { n: 3, label: "Preview", short: "Preview fields" },
26 + { n: 4, label: "Save", short: "Save connector" },
27 + { n: 5, label: "Sample", short: "Run on pages" },
28 + { n: 6, label: "Deploy", short: "Compare & deploy" },
29 +] as const;
30 +
31 +export function DevTool() {
32 + const [params, setParams] = useUrlState();
33 + const step = Math.min(6, Math.max(1, Number(params.step ?? 1) || 1));
34 + // Draft is read lazily from localStorage (+ ?url=) on the client; the server render and the first client render
35 + // both return null (useMounted) so there is no hydration mismatch and no setState inside an effect.
36 + const hydrated = useMounted();
37 + const [draft, setDraft] = useState<Draft>(() => {
38 + if (typeof window === "undefined") return EMPTY_DRAFT;
39 + const stored = loadDraft() ?? EMPTY_DRAFT;
40 + const u = new URLSearchParams(window.location.search).get("url");
41 + return u ? { ...stored, fetch: { ...stored.fetch, url: u } } : stored;
42 + });
43 + const [initialId] = useState<string | null>(() => (typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("id")));
44 + const [fetchResult, setFetchResult] = useState<DevtoolFetchResult | null>(null);
45 + const [previewResult, setPreviewResult] = useState<DevtoolPreviewResult | null>(null);
46 + const [validation, setValidation] = useState<ValidateConfigResult | null>(null);
47 + const [saveResult, setSaveResult] = useState<SaveConfigResult | null>(null);
48 + const [sampleResult, setSampleResult] = useState<RunSampleResult | null>(null);
49 + const [loadingId, setLoadingId] = useState<string | null>(null);
50 + const [loadError, setLoadError] = useState<string | null>(null);
51 + const toast = useToast();
52 + const configs = useAdminQuery<ConfigListItem[]>("devtool/configs", { timeoutMs: 30_000 });
53 +
54 + // ?id=<connector> different from the stored draft → load it once after mount
55 + const requestedId = useRef<string | null>(initialId && initialId !== draft.meta.id ? initialId : null);
56 +
57 + // persist (debounced)
58 + useEffect(() => {
59 + if (!hydrated) return;
60 + const t = setTimeout(() => saveDraft(draft), 300);
61 + return () => clearTimeout(t);
62 + }, [draft, hydrated]);
63 +
64 + const update = useCallback((patch: Partial<Draft> | ((d: Draft) => Draft)) => {
65 + setDraft((d) => (typeof patch === "function" ? patch(d) : { ...d, ...patch }));
66 + }, []);
67 +
68 + const goto = useCallback(
69 + (n: number) => {
70 + setParams({ step: n === 1 ? null : n, url: draft.fetch.url || null, id: draft.meta.id || null }, { push: true });
71 + },
72 + [setParams, draft.fetch.url, draft.meta.id],
73 + );
74 +
75 + // keep url/id in the address bar in sync (replace, no history)
76 + useEffect(() => {
77 + if (!hydrated) return;
78 + const wantUrl = draft.fetch.url || null;
79 + const wantId = draft.meta.id || null;
80 + if ((params.url ?? null) !== wantUrl || (params.id ?? null) !== wantId) setParams({ url: wantUrl, id: wantId });
81 + }, [draft.fetch.url, draft.meta.id, hydrated, params.url, params.id, setParams]);
82 +
83 + const loadConnector = useCallback(
84 + async (id: string) => {
85 + if (!id) return;
86 + setLoadingId(id);
87 + setLoadError(null);
88 + const text = await adminFetchText(`devtool/configs/${encodeURIComponent(id)}`);
89 + if (!text.ok) {
90 + setLoadingId(null);
91 + setLoadError(`Could not load ${id}.yaml: ${text.error}`);
92 + return;
93 + }
94 + const v = await adminFetch<ValidateConfigResult>("devtool/validate-config", { method: "POST", body: { yaml: text.text } });
95 + setLoadingId(null);
96 + if (!v.ok) {
97 + setLoadError(`validate-config failed: ${v.error}`);
98 + return;
99 + }
100 + setValidation(v.data);
101 + if (v.data.ok) {
102 + setDraft((d) => draftFromConfig(v.data.ok ? v.data.config : {}, text.text, { ...d, fetch: { ...d.fetch, url: "" } }));
103 + toast({ tone: "ok", title: `Loaded ${id}`, description: `${Object.keys(v.data.config.extractors as object).length} extractor(s) · parser ${String(v.data.config.parserVersion)}` });
104 + } else {
105 + setDraft((d) => ({ ...d, yaml: text.text, yamlDirty: true, meta: { ...d.meta, id } }));
106 + toast({ tone: "warn", title: `Loaded ${id} with schema errors`, description: v.data.errors.map((e) => `${e.path}: ${e.message}`).join("; ").slice(0, 200) });
107 + setParams({ step: 4 });
108 + }
109 + setFetchResult(null);
110 + setPreviewResult(null);
111 + setSaveResult(null);
112 + setSampleResult(null);
113 + },
114 + [toast, setParams],
115 + );
116 +
117 + useEffect(() => {
118 + if (!hydrated || !requestedId.current) return;
119 + const id = requestedId.current;
120 + requestedId.current = null;
121 + const t = setTimeout(() => void loadConnector(id), 0);
122 + return () => clearTimeout(t);
123 + }, [hydrated, loadConnector]);
124 +
125 + const reset = () => {
126 + if (!window.confirm("Clear the current draft (rules, YAML, sample URLs)?")) return;
127 + setDraft(EMPTY_DRAFT);
128 + setFetchResult(null);
129 + setPreviewResult(null);
130 + setValidation(null);
131 + setSaveResult(null);
132 + setSampleResult(null);
133 + setParams({ step: null, url: null, id: null });
134 + };
135 +
136 + const shared: DevtoolShared = useMemo(
137 + () => ({ draft, update, fetchResult, setFetchResult, previewResult, setPreviewResult, validation, setValidation, saveResult, setSaveResult, sampleResult, setSampleResult, goto }),
138 + [draft, update, fetchResult, previewResult, validation, saveResult, sampleResult, goto],
139 + );
140 +
141 + const done = {
142 + 1: Boolean(fetchResult && !fetchResult.error),
143 + 2: draft.extractor.fields.some((f) => f.name.trim()),
144 + 3: Boolean(previewResult && previewResult.entities.length > 0),
145 + 4: Boolean(saveResult),
146 + 5: Boolean(sampleResult),
147 + 6: false,
148 + } as Record<number, boolean>;
149 +
150 + if (!hydrated) return null;
151 +
152 + return (
153 + <div>
154 + <AdminPageHeader
155 + title="Connector development tool"
156 + description="Fetch a live page through the escalation stack, define declarative extraction rules, preview the normalized entities, save the YAML, run it on several pages, then deploy a parser version. Nothing here persists documents or entities."
157 + aside={
158 + <>
159 + <div className="flex items-center gap-2">
160 + <Select value="" onChange={(e) => void loadConnector(e.target.value)} className="w-[230px]" aria-label="Load existing connector" disabled={loadingId !== null}>
161 + <option value="">{loadingId ? `Loading ${loadingId}…` : "Load existing connector…"}</option>
162 + {(configs.data ?? []).map((c) => (
163 + <option key={c.id} value={c.id}>
164 + {c.id}
165 + {c.valid ? "" : " (invalid)"} · {c.parserVersion}
166 + </option>
167 + ))}
168 + </Select>
169 + {loadingId && <Spinner />}
170 + </div>
171 + <Button size="sm" variant="ghost" onClick={reset}>
172 + New draft
173 + </Button>
174 + </>
175 + }
176 + />
177 + <ErrorNote error={loadError} className="mb-3" />
178 +
179 + <div className="mb-3 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">
180 + {draft.meta.id ? (
181 + <span>
182 + Editing <Mono className="text-ink">{draft.meta.id}</Mono>
183 + </span>
184 + ) : (
185 + <span>New connector draft</span>
186 + )}
187 + {draft.fetch.url && (
188 + <span className="min-w-0 truncate">
189 + · <Mono>{draft.fetch.url}</Mono>
190 + </span>
191 + )}
192 + {draft.savedAt && <span className="ml-auto text-ink-4">draft kept in this browser</span>}
193 + </div>
194 +
195 + <ol className="mb-4 flex overflow-x-auto rounded-sm border border-hair bg-surface no-scrollbar">
196 + {STEPS.map((s) => {
197 + const active = s.n === step;
198 + return (
199 + <li key={s.n} className="flex min-w-0 flex-1">
200 + <button type="button" onClick={() => goto(s.n)} aria-current={active ? "step" : undefined} className={cn("flex h-11 w-full min-w-[96px] items-center gap-2 border-r border-hair px-3 text-left text-[12.5px] last:border-r-0 sm:min-w-0", active ? "bg-ink text-paper" : "text-ink-2 hover:bg-surface-2")}>
201 + <span className={cn("inline-flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium", active ? "border-paper/50" : done[s.n] ? "border-ok bg-ok text-white" : "border-hair-2")}>{done[s.n] && !active ? <Check className="size-3" strokeWidth={2.5} /> : s.n}</span>
202 + <span className="truncate">
203 + <span className="sm:hidden">{s.label}</span>
204 + <span className="hidden sm:inline">{s.short}</span>
205 + </span>
206 + </button>
207 + </li>
208 + );
209 + })}
210 + </ol>
211 +
212 + {step === 1 && <StepFetch s={shared} />}
213 + {step === 2 && <StepRules s={shared} />}
214 + {step === 3 && <StepPreview s={shared} />}
215 + {step === 4 && <StepSave s={shared} />}
216 + {step === 5 && <StepSample s={shared} />}
217 + {step === 6 && <StepDeploy s={shared} configs={configs.data ?? []} onReloadConfigs={() => void configs.refresh()} />}
218 +
219 + <div className="mt-4 flex items-center justify-between border-t border-hair pt-3">
220 + <Button size="sm" variant="ghost" onClick={() => goto(step - 1)} disabled={step <= 1}>
221 + ← {STEPS[step - 2]?.short ?? ""}
222 + </Button>
223 + {step === 3 && !draft.yaml && (
224 + <Badge size="xs" tone="info">
225 + the YAML template is generated when you enter step 4
226 + </Badge>
227 + )}
228 + <Button size="sm" variant="secondary" onClick={() => { if (step === 3 && !draft.yamlDirty) update({ yaml: buildYaml(draft) }); goto(step + 1); }} disabled={step >= 6}>
229 + {STEPS[step]?.short ?? ""} →
230 + </Button>
231 + </div>
232 + </div>
233 + );
234 +}
added apps/web/src/components/admin/devtool/state.ts +396 −0
@@ -0,0 +1,396 @@
1 +/**
2 + * Connector dev tool — draft model, conversions to/from the YAML config schema (packages/connectors/src/config.ts),
3 + * template builder and localStorage persistence. Pure functions, no React.
4 + */
5 +import type { DevtoolFetchResult, DevtoolPreviewResult, RunSampleResult, SaveConfigResult, ValidateConfigResult } from "@/lib/admin-api";
6 +import { toYaml } from "../yaml";
7 +
8 +export type RuleType = "selector" | "attr" | "jsonld" | "meta" | "jsonPath" | "regex" | "constant";
9 +
10 +export const RULE_TYPES: Array<{ value: RuleType; label: string; hint: string }> = [
11 + { value: "selector", label: "CSS selector", hint: "text of the first (or all) matching elements" },
12 + { value: "attr", label: "Attribute", hint: "attribute value of a selected element (href, src, data-*)" },
13 + { value: "jsonld", label: "JSON-LD", hint: "value inside a JSON-LD object of a given @type, via JSON path" },
14 + { value: "meta", label: "Meta tag", hint: "<meta name|property=…> content" },
15 + { value: "jsonPath", label: "Embedded JSON", hint: "JSON path over embedded JSON blobs (__NEXT_DATA__, drupal-settings…)" },
16 + { value: "regex", label: "Regex", hint: "regular expression over the element text (or the whole page)" },
17 + { value: "constant", label: "Constant", hint: "fixed default value" },
18 +];
19 +
20 +export const TRANSFORMS = ["", "mw", "sqm", "ha", "money", "date", "status", "type", "country", "int", "float", "trim", "lower", "url", "bool"] as const;
21 +export const EXTRACT_KINDS = ["facility", "operator", "campus", "cloud_region", "ixp", "project", "news_event", "country"] as const;
22 +export const SOURCE_KINDS_LIST = ["operator", "government", "filing", "utility", "dataset", "community", "secondary", "news", "cloud_provider", "registry"] as const;
23 +export const COMMON_FIELDS: Record<string, string[]> = {
24 + facility: ["name", "address", "city", "regionName", "countryIso2", "postalCode", "lat", "lng", "status", "facilityType", "itCapacityMw", "totalPowerMw", "plannedPowerMw", "buildingSqm", "siteAreaHa", "openedOn", "operatorName", "website", "description", "tier", "pue", "coolingType", "renewableClaim", "certifications"],
25 + operator: ["name", "kind", "website", "hqCountryIso2", "description"],
26 + campus: ["name", "address", "city", "countryIso2", "lat", "lng", "operatorName", "plannedPowerMw"],
27 + cloud_region: ["code", "name", "city", "countryIso2", "lat", "lng", "availabilityZones", "launchedOn", "status", "providerName"],
28 + ixp: ["name", "nameLong", "city", "countryIso2", "website", "networkCount"],
29 + project: ["name", "operatorName", "city", "countryIso2", "status", "announcedOn", "expectedOpening", "plannedMw", "investmentUsd", "description"],
30 + news_event: ["title", "summary", "publishedAt", "url", "operatorName", "countryIso2", "mw", "eventType"],
31 + country: ["iso2", "name"],
32 +};
33 +
34 +export interface FieldRule {
35 + id: string;
36 + name: string;
37 + type: RuleType;
38 + selector: string;
39 + attr: string;
40 + regex: string;
41 + regexFlags: string;
42 + group: string;
43 + all: boolean;
44 + jsonPath: string;
45 + jsonld: string;
46 + meta: string;
47 + transform: string;
48 + default: string;
49 +}
50 +
51 +export interface ExtractorDraft {
52 + name: string;
53 + kind: string;
54 + key: string;
55 + each: string;
56 + minCertainty: string;
57 + parser: string;
58 + match: string;
59 + fields: FieldRule[];
60 +}
61 +
62 +export interface FetchDraft {
63 + url: string;
64 + level: number;
65 + maxLevel: number;
66 + renderJs: boolean;
67 + userAgent: "bot" | "browser";
68 + waitForSelector: string;
69 +}
70 +
71 +export interface MetaDraft {
72 + id: string;
73 + name: string;
74 + domain: string;
75 + kind: string;
76 + license: string;
77 + attribution: string;
78 + homepage: string;
79 + scheduleGroup: string;
80 + scheduleInterval: string;
81 + rpm: string;
82 + maxCreditsPerRun: string;
83 +}
84 +
85 +export interface DefaultsDraft {
86 + operatorName: string;
87 + operatorKind: string;
88 + countryIso2: string;
89 + facilityType: string;
90 + status: string;
91 +}
92 +
93 +export interface Draft {
94 + fetch: FetchDraft;
95 + extractor: ExtractorDraft;
96 + defaults: DefaultsDraft;
97 + meta: MetaDraft;
98 + /** full YAML in the editor (step 4); `yamlDirty` = edited by hand since the last regeneration */
99 + yaml: string;
100 + yamlDirty: boolean;
101 + sampleUrls: string;
102 + savedAt: number | null;
103 +}
104 +
105 +export interface DevtoolShared {
106 + draft: Draft;
107 + update: (patch: Partial<Draft> | ((d: Draft) => Draft)) => void;
108 + fetchResult: DevtoolFetchResult | null;
109 + setFetchResult: (r: DevtoolFetchResult | null) => void;
110 + previewResult: DevtoolPreviewResult | null;
111 + setPreviewResult: (r: DevtoolPreviewResult | null) => void;
112 + validation: ValidateConfigResult | null;
113 + setValidation: (r: ValidateConfigResult | null) => void;
114 + saveResult: SaveConfigResult | null;
115 + setSaveResult: (r: SaveConfigResult | null) => void;
116 + sampleResult: RunSampleResult | null;
117 + setSampleResult: (r: RunSampleResult | null) => void;
118 + goto: (step: number) => void;
119 +}
120 +
121 +let seq = 0;
122 +export function uid(): string {
123 + seq += 1;
124 + return `${Date.now().toString(36)}${seq.toString(36)}`;
125 +}
126 +
127 +export function newFieldRule(partial: Partial<FieldRule> = {}): FieldRule {
128 + return { id: uid(), name: "", type: "selector", selector: "", attr: "", regex: "", regexFlags: "", group: "", all: false, jsonPath: "", jsonld: "", meta: "", transform: "", default: "", ...partial };
129 +}
130 +
131 +export const EMPTY_DRAFT: Draft = {
132 + fetch: { url: "", level: 1, maxLevel: 2, renderJs: false, userAgent: "bot", waitForSelector: "" },
133 + extractor: { name: "facility", kind: "facility", key: "{url}", each: "", minCertainty: "", parser: "", match: "", fields: [newFieldRule({ name: "name", type: "selector", selector: "h1" })] },
134 + defaults: { operatorName: "", operatorKind: "", countryIso2: "", facilityType: "", status: "" },
135 + meta: { id: "", name: "", domain: "", kind: "operator", license: "", attribution: "", homepage: "", scheduleGroup: "seed", scheduleInterval: "weekly", rpm: "20", maxCreditsPerRun: "40" },
136 + yaml: "",
137 + yamlDirty: false,
138 + sampleUrls: "",
139 + savedAt: null,
140 +};
141 +
142 +/* ------------------------------------------------------------------ helpers */
143 +
144 +export function domainOf(url: string): string {
145 + try {
146 + return new URL(url).hostname;
147 + } catch {
148 + return "";
149 + }
150 +}
151 +
152 +export function slugify(s: string): string {
153 + return s
154 + .toLowerCase()
155 + .replace(/^www\./, "")
156 + .replace(/\.[a-z]{2,}(\.[a-z]{2})?$/, "")
157 + .replace(/[^a-z0-9]+/g, "-")
158 + .replace(/^-+|-+$/g, "")
159 + .slice(0, 40);
160 +}
161 +
162 +export function parseUrls(text: string): string[] {
163 + const out: string[] = [];
164 + for (const line of text.split(/\r?\n|,|\s+/)) {
165 + const s = line.trim();
166 + if (!s) continue;
167 + try {
168 + const u = new URL(s);
169 + if (u.protocol === "http:" || u.protocol === "https:") out.push(u.toString());
170 + } catch {
171 + /* skip */
172 + }
173 + }
174 + return [...new Set(out)];
175 +}
176 +
177 +function numOrUndef(s: string): number | undefined {
178 + const n = Number(s);
179 + return s.trim() !== "" && Number.isFinite(n) ? n : undefined;
180 +}
181 +
182 +function coerceDefault(s: string): unknown {
183 + const t = s.trim();
184 + if (t === "") return undefined;
185 + if (t === "true") return true;
186 + if (t === "false") return false;
187 + if (t === "null") return null;
188 + if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
189 + return s;
190 +}
191 +
192 +/* ------------------------------------------------------------------ rule ↔ config */
193 +
194 +/** One field rule → schema `selectorRule` (bare string when it is just a selector). */
195 +export function ruleToConfig(r: FieldRule): string | Record<string, unknown> | null {
196 + const o: Record<string, unknown> = {};
197 + switch (r.type) {
198 + case "selector":
199 + if (!r.selector.trim()) return null;
200 + o.selector = r.selector.trim();
201 + if (r.all) o.all = true;
202 + if (r.regex.trim()) {
203 + o.regex = r.regex;
204 + if (r.regexFlags.trim()) o.regexFlags = r.regexFlags.trim();
205 + if (numOrUndef(r.group) !== undefined) o.group = numOrUndef(r.group);
206 + }
207 + break;
208 + case "attr":
209 + if (!r.selector.trim() || !r.attr.trim()) return null;
210 + o.selector = r.selector.trim();
211 + o.attr = r.attr.trim();
212 + if (r.all) o.all = true;
213 + break;
214 + case "jsonld":
215 + if (!r.jsonld.trim() && !r.jsonPath.trim()) return null;
216 + if (r.jsonld.trim()) o.jsonld = r.jsonld.trim();
217 + if (r.jsonPath.trim()) o.jsonPath = r.jsonPath.trim();
218 + break;
219 + case "meta":
220 + if (!r.meta.trim()) return null;
221 + o.meta = r.meta.trim();
222 + break;
223 + case "jsonPath":
224 + if (!r.jsonPath.trim()) return null;
225 + o.jsonPath = r.jsonPath.trim();
226 + break;
227 + case "regex":
228 + if (!r.regex.trim()) return null;
229 + o.regex = r.regex;
230 + if (r.regexFlags.trim()) o.regexFlags = r.regexFlags.trim();
231 + if (numOrUndef(r.group) !== undefined) o.group = numOrUndef(r.group);
232 + if (r.selector.trim()) o.selector = r.selector.trim();
233 + if (r.all) o.all = true;
234 + break;
235 + case "constant":
236 + if (r.default.trim() === "") return null;
237 + break;
238 + }
239 + if (r.transform) o.transform = r.transform;
240 + const def = coerceDefault(r.default);
241 + if (def !== undefined) o.default = def;
242 + const keys = Object.keys(o);
243 + if (keys.length === 1 && keys[0] === "selector") return o.selector as string;
244 + return o;
245 +}
246 +
247 +/** schema `selectorRule` → field rule (best effort classification). */
248 +export function configToRule(name: string, rule: unknown): FieldRule {
249 + if (typeof rule === "string") return newFieldRule({ name, type: "selector", selector: rule });
250 + const o = (rule ?? {}) as Record<string, unknown>;
251 + const str = (k: string) => (typeof o[k] === "string" ? (o[k] as string) : typeof o[k] === "number" ? String(o[k]) : "");
252 + const base: Partial<FieldRule> = { name, selector: str("selector"), attr: str("attr"), regex: str("regex"), regexFlags: str("regexFlags"), group: str("group"), all: o.all === true, jsonPath: str("jsonPath"), jsonld: str("jsonld"), meta: str("meta"), transform: str("transform"), default: o.default === undefined ? "" : typeof o.default === "string" ? o.default : JSON.stringify(o.default) };
253 + let type: RuleType = "selector";
254 + if (o.jsonld) type = "jsonld";
255 + else if (o.meta) type = "meta";
256 + else if (o.jsonPath) type = "jsonPath";
257 + else if (o.attr) type = "attr";
258 + else if (o.regex && !o.selector) type = "regex";
259 + else if (o.selector) type = "selector";
260 + else if (o.default !== undefined) type = "constant";
261 + return newFieldRule({ ...base, type });
262 +}
263 +
264 +export function extractorToConfig(ex: ExtractorDraft, opts: { includeMatch?: boolean } = {}): Record<string, unknown> {
265 + const fields: Record<string, unknown> = {};
266 + for (const f of ex.fields) {
267 + const name = f.name.trim();
268 + if (!name) continue;
269 + const cfg = ruleToConfig(f);
270 + if (cfg !== null) fields[name] = cfg;
271 + }
272 + const o: Record<string, unknown> = { kind: ex.kind };
273 + if (opts.includeMatch && ex.match.trim()) o.match = ex.match.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
274 + if (ex.parser.trim()) o.parser = ex.parser.trim();
275 + if (ex.key.trim()) o.key = ex.key.trim();
276 + if (ex.each.trim()) o.each = ex.each.trim();
277 + if (Object.keys(fields).length) o.fields = fields;
278 + const mc = numOrUndef(ex.minCertainty);
279 + if (mc !== undefined) o.minCertainty = mc;
280 + return o;
281 +}
282 +
283 +export function defaultsToConfig(d: DefaultsDraft): Record<string, unknown> | undefined {
284 + const o: Record<string, unknown> = {};
285 + for (const [k, v] of Object.entries(d)) if (v.trim()) o[k] = v.trim();
286 + return Object.keys(o).length ? o : undefined;
287 +}
288 +
289 +/** Full connector config object from the draft (what the YAML template serializes). */
290 +export function buildConfigObject(draft: Draft): Record<string, unknown> {
291 + const { meta, fetch, extractor, defaults } = draft;
292 + const id = meta.id.trim() || slugify(domainOf(fetch.url) || "new-connector") || "new-connector";
293 + const domain = meta.domain.trim() || domainOf(fetch.url) || "example.com";
294 + const exName = extractor.name.trim() || extractor.kind;
295 + const seeds = fetch.url ? [{ url: fetch.url, group: meta.scheduleGroup.trim() || "seed", pageType: extractor.kind === "facility" ? "facility_page" : extractor.kind === "cloud_region" ? "cloud_region" : extractor.kind === "news_event" ? "press_release" : undefined }] : [];
296 + const cfg: Record<string, unknown> = {
297 + id,
298 + name: meta.name.trim() || domain,
299 + domain,
300 + kind: meta.kind || "operator",
301 + mode: "html",
302 + priority: 3,
303 + enabled: false,
304 + license: meta.license.trim() || undefined,
305 + attribution: meta.attribution.trim() || undefined,
306 + homepage: meta.homepage.trim() || (fetch.url ? `${new URL(fetch.url).origin}/` : undefined),
307 + fetch: { level: fetch.level, maxLevel: Math.max(fetch.level, fetch.maxLevel), renderJs: fetch.renderJs || undefined, userAgent: fetch.userAgent === "browser" ? "browser" : undefined, waitForSelector: fetch.waitForSelector.trim() || undefined, rpm: numOrUndef(meta.rpm) ?? 20, concurrency: 2, respectRobots: true, maxCreditsPerRun: numOrUndef(meta.maxCreditsPerRun) ?? 40 },
308 + discovery: { sitemap: false, seeds, include: domain ? [`^https?://${domain.replace(/\./g, "\\.")}/`] : [], exclude: ["\\?"], maxUrlsPerRun: 500 },
309 + schedule: { [meta.scheduleGroup.trim() || "seed"]: meta.scheduleInterval.trim() || "weekly" },
310 + extractors: { [exName]: extractorToConfig(extractor, { includeMatch: true }) },
311 + defaults: defaultsToConfig(defaults),
312 + parserVersion: "v1",
313 + };
314 + return cfg;
315 +}
316 +
317 +export function buildYaml(draft: Draft): string {
318 + const cfg = buildConfigObject(draft);
319 + const header = `# ${cfg.name} — generated by the DataCenterIndex connector dev tool\n# Set enabled: true once \`pnpm dci run ${cfg.id} --dry-run --limit 5\` yields valid entities. Document the source in docs/connectors/${cfg.id}.md.\n`;
320 + return header + toYaml(cfg);
321 +}
322 +
323 +/** Populate a draft from a parsed connector config (as returned by devtool/validate-config) + its raw YAML text. */
324 +export function draftFromConfig(cfg: Record<string, unknown>, yaml: string, prev: Draft = EMPTY_DRAFT): Draft {
325 + const str = (v: unknown, d = "") => (typeof v === "string" ? v : typeof v === "number" ? String(v) : d);
326 + const fetch = (cfg.fetch ?? {}) as Record<string, unknown>;
327 + const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
328 + const seeds = Array.isArray(discovery.seeds) ? (discovery.seeds as Array<string | { url?: string; group?: string }>) : [];
329 + const firstSeed = seeds[0];
330 + const seedUrl = typeof firstSeed === "string" ? firstSeed : (firstSeed?.url ?? "");
331 + const schedule = (cfg.schedule ?? {}) as Record<string, string>;
332 + const [group, interval] = Object.entries(schedule)[0] ?? ["seed", "weekly"];
333 + const extractors = (cfg.extractors ?? {}) as Record<string, Record<string, unknown>>;
334 + const exEntries = Object.entries(extractors);
335 + const chosen = exEntries.find(([, e]) => e.fields && Object.keys(e.fields as object).length) ?? exEntries[0];
336 + const [exName, ex] = chosen ?? ["facility", {}];
337 + const fields = Object.entries((ex.fields ?? {}) as Record<string, unknown>).map(([n, r]) => configToRule(n, r));
338 + const defaults = (cfg.defaults ?? {}) as Record<string, unknown>;
339 + return {
340 + ...prev,
341 + fetch: { url: prev.fetch.url || seedUrl, level: Number(fetch.level ?? 1) || 1, maxLevel: Number(fetch.maxLevel ?? 2) || 2, renderJs: fetch.renderJs === true, userAgent: fetch.userAgent === "browser" ? "browser" : "bot", waitForSelector: str(fetch.waitForSelector) },
342 + extractor: { name: exName, kind: str(ex.kind, "facility"), key: str(ex.key), each: str(ex.each), minCertainty: str(ex.minCertainty), parser: str(ex.parser), match: Array.isArray(ex.match) ? (ex.match as string[]).join("\n") : "", fields: fields.length ? fields : [newFieldRule({ name: "name", selector: "h1" })] },
343 + defaults: { operatorName: str(defaults.operatorName), operatorKind: str(defaults.operatorKind), countryIso2: str(defaults.countryIso2), facilityType: str(defaults.facilityType), status: str(defaults.status) },
344 + meta: { id: str(cfg.id), name: str(cfg.name), domain: str(cfg.domain), kind: str(cfg.kind, "operator"), license: str(cfg.license), attribution: str(cfg.attribution), homepage: str(cfg.homepage), scheduleGroup: group ?? "seed", scheduleInterval: interval ?? "weekly", rpm: str(fetch.rpm, "20"), maxCreditsPerRun: str(fetch.maxCreditsPerRun, "40") },
345 + yaml,
346 + yamlDirty: false,
347 + };
348 +}
349 +
350 +/* ------------------------------------------------------------------ persistence */
351 +
352 +export const DRAFT_KEY = "dci:devtool:draft:v1";
353 +
354 +export function loadDraft(): Draft | null {
355 + try {
356 + const raw = window.localStorage.getItem(DRAFT_KEY);
357 + if (!raw) return null;
358 + const parsed = JSON.parse(raw) as Partial<Draft>;
359 + return { ...EMPTY_DRAFT, ...parsed, fetch: { ...EMPTY_DRAFT.fetch, ...(parsed.fetch ?? {}) }, extractor: { ...EMPTY_DRAFT.extractor, ...(parsed.extractor ?? {}), fields: (parsed.extractor?.fields ?? EMPTY_DRAFT.extractor.fields).map((f) => newFieldRule(f)) }, defaults: { ...EMPTY_DRAFT.defaults, ...(parsed.defaults ?? {}) }, meta: { ...EMPTY_DRAFT.meta, ...(parsed.meta ?? {}) } };
360 + } catch {
361 + return null;
362 + }
363 +}
364 +
365 +export function saveDraft(d: Draft): void {
366 + try {
367 + window.localStorage.setItem(DRAFT_KEY, JSON.stringify({ ...d, savedAt: Date.now() }));
368 + } catch {
369 + /* quota — ignore */
370 + }
371 +}
372 +
373 +/** Union of entity field names present (non-empty) across a list of entities, in a stable order. */
374 +export function fieldUnion(entities: Array<Record<string, unknown>>, preferred: string[] = []): string[] {
375 + const seen = new Set<string>();
376 + const out: string[] = [];
377 + for (const p of preferred) {
378 + if (entities.some((e) => p in e)) {
379 + seen.add(p);
380 + out.push(p);
381 + }
382 + }
383 + for (const e of entities) {
384 + for (const k of Object.keys(e)) {
385 + if (!seen.has(k) && k !== "provenance") {
386 + seen.add(k);
387 + out.push(k);
388 + }
389 + }
390 + }
391 + return out;
392 +}
393 +
394 +export function isEmptyValue(v: unknown): boolean {
395 + return v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0);
396 +}
added apps/web/src/components/admin/devtool/step-deploy.tsx +205 −0
@@ -0,0 +1,205 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useEffect, useMemo, useState } from "react";
5 +import { Badge, Button, EmptyState } from "@/components/ui";
6 +import { adminFetch, adminFetchText, type ConfigListItem, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api";
7 +import { Checkbox, DiffView, ErrorNote, Field, Input, KV, Mono, Panel, Select, Spinner } from "../primitives";
8 +import { useToast } from "../toast";
9 +import { bumpVersion } from "../yaml";
10 +import type { DevtoolShared } from "./state";
11 +
12 +interface DeployLog {
13 + step: string;
14 + ok: boolean;
15 + detail: string;
16 +}
17 +
18 +/** Loads config/connectors/<id>.yaml text; `token` forces a reload. Loading is derived from the key, never set in the effect. */
19 +function useSavedYaml(id: string, token: number): { loading: boolean; text: string | null; error: string | null } {
20 + const key = `${id}|${token}`;
21 + const [slot, setSlot] = useState<{ key: string; text: string | null; error: string | null }>({ key: "", text: null, error: null });
22 + useEffect(() => {
23 + if (!id) return;
24 + let alive = true;
25 + void adminFetchText(`devtool/configs/${encodeURIComponent(id)}`).then((r) => {
26 + if (!alive) return;
27 + setSlot({ key, text: r.ok ? r.text : null, error: r.ok ? null : r.status === 404 ? `No saved config for "${id}" — this would be a new connector (save it in step 4 first).` : (r.error ?? "load failed") });
28 + });
29 + return () => {
30 + alive = false;
31 + };
32 + }, [id, key]);
33 + const current = slot.key === key;
34 + return { loading: Boolean(id) && !current, text: current ? slot.text : null, error: current ? slot.error : null };
35 +}
36 +
37 +export function StepDeploy({ s, configs, onReloadConfigs }: { s: DevtoolShared; configs: ConfigListItem[]; onReloadConfigs: () => void }) {
38 + const { draft, update, validation, setValidation, setSaveResult } = s;
39 + const [id, setId] = useState(draft.meta.id);
40 + const [reloadToken, setReloadToken] = useState(0);
41 + const savedQ = useSavedYaml(id, reloadToken);
42 + const saved = savedQ.text != null ? { id, text: savedQ.text } : null;
43 + const [versionInput, setVersionInput] = useState<string | null>(null);
44 + const [reprocess, setReprocess] = useState(true);
45 + const [busy, setBusy] = useState(false);
46 + const [log, setLog] = useState<DeployLog[]>([]);
47 + const toast = useToast();
48 + const listed = configs.find((c) => c.id === id);
49 + const savedVersion = useMemo(() => {
50 + const m = /^parserVersion:\s*["']?([^"'\n#]+)/m.exec(saved?.text ?? "");
51 + return m?.[1]?.trim() ?? listed?.parserVersion ?? "v1";
52 + }, [saved, listed]);
53 + const draftVersion = useMemo(() => /^parserVersion:\s*["']?([^"'\n#]+)/m.exec(draft.yaml)?.[1]?.trim() ?? null, [draft.yaml]);
54 + const version = versionInput ?? bumpVersion(savedVersion);
55 +
56 + const changeId = (v: string) => {
57 + setId(v.toLowerCase());
58 + setVersionInput(null);
59 + setLog([]);
60 + };
61 +
62 + const deploy = async () => {
63 + if (!id) return;
64 + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return toast({ tone: "danger", title: "Invalid connector id" });
65 + if (!draft.yaml.trim()) return toast({ tone: "danger", title: "No YAML to deploy (step 4)" });
66 + if (!window.confirm(`Deploy ${id}: set parserVersion ${version}, write ${id}.yaml (backup kept), upsert the connectors row${reprocess ? ", then enqueue a reprocess run over stored documents" : ""}?`)) return;
67 + setBusy(true);
68 + const out: DeployLog[] = [];
69 + const push = (l: DeployLog) => {
70 + out.push(l);
71 + setLog([...out]);
72 + };
73 + const v = await adminFetch<ValidateConfigResult>("devtool/validate-config", { method: "POST", body: { yaml: draft.yaml } });
74 + if (!v.ok || !v.data.ok) {
75 + push({ step: "validate", ok: false, detail: v.ok ? (v.data.ok ? "" : v.data.errors.map((e) => `${e.path}: ${e.message}`).join("; ")) : v.error });
76 + setValidation(v.ok ? v.data : null);
77 + setBusy(false);
78 + toast({ tone: "danger", title: "Deploy aborted: YAML invalid" });
79 + return;
80 + }
81 + setValidation(v.data);
82 + if (String(v.data.config.id) !== id) {
83 + push({ step: "validate", ok: false, detail: `YAML id "${String(v.data.config.id)}" does not match "${id}"` });
84 + setBusy(false);
85 + return;
86 + }
87 + push({ step: "validate", ok: true, detail: `${v.data.warnings.length} warning(s)` });
88 + const sv = await adminFetch<SaveConfigResult>("devtool/save-config", { method: "POST", body: { id, yaml: draft.yaml, version: version.trim() || undefined } });
89 + if (!sv.ok) {
90 + push({ step: "save-config", ok: false, detail: sv.error });
91 + setBusy(false);
92 + toast({ tone: "danger", title: "Deploy failed at save", description: sv.error });
93 + return;
94 + }
95 + setSaveResult(sv.data);
96 + push({ step: "save-config", ok: true, detail: `${sv.data.path} · parser ${sv.data.parserVersion}${sv.data.backup ? ` · backup ${sv.data.backup.split("/").pop()}` : ""}` });
97 + // reflect the bumped version in the editor
98 + update((d) => ({ ...d, yaml: /^parserVersion:/m.test(d.yaml) ? d.yaml.replace(/^parserVersion:.*$/m, `parserVersion: ${sv.data.parserVersion}`) : `${d.yaml.trimEnd()}\nparserVersion: ${sv.data.parserVersion}\n`, meta: { ...d.meta, id } }));
99 + if (reprocess) {
100 + const run = await adminFetch<{ enqueued: boolean; job?: { id?: string } }>(`connectors/${encodeURIComponent(id)}/run`, { method: "POST", body: { task: "reprocess" } });
101 + if (run.ok) push({ step: "run reprocess", ok: true, detail: run.data.job?.id ? `job ${run.data.job.id}` : "enqueued" });
102 + else push({ step: "run reprocess", ok: false, detail: run.error });
103 + }
104 + setBusy(false);
105 + setReloadToken((t) => t + 1);
106 + setVersionInput(null);
107 + onReloadConfigs();
108 + toast({ tone: out.every((l) => l.ok) ? "ok" : "warn", title: `Deployed ${id} @ ${sv.data.parserVersion}`, description: out.map((l) => `${l.step}: ${l.ok ? "ok" : "failed"}`).join(" · ") });
109 + };
110 +
111 + return (
112 + <div className="space-y-3">
113 + <Panel title="6 · Compare with the saved connector" description="Left = config/connectors/<id>.yaml on disk, right = the YAML in your editor (step 4).">
114 + <div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
115 + <Field label="Connector id">
116 + <div className="flex gap-2">
117 + <Input value={id} onChange={(e) => changeId(e.target.value)} mono placeholder="connector id" className="flex-1" />
118 + <Select value="" onChange={(e) => { if (e.target.value) changeId(e.target.value); }} className="w-[200px]" aria-label="Pick saved connector">
119 + <option value="">pick saved…</option>
120 + {configs.map((c) => (
121 + <option key={c.id} value={c.id}>
122 + {c.id} · {c.parserVersion}
123 + </option>
124 + ))}
125 + </Select>
126 + </div>
127 + </Field>
128 + <Button size="md" variant="secondary" onClick={() => setReloadToken((t) => t + 1)} disabled={!id || savedQ.loading}>
129 + {savedQ.loading ? <Spinner /> : null} Reload saved
130 + </Button>
131 + </div>
132 + <ErrorNote error={savedQ.error} className="mt-3" />
133 + {saved && (
134 + <div className="mt-3">
135 + <KV
136 + cols={3}
137 + items={[
138 + { k: "Saved parser version", v: <Mono>{savedVersion}</Mono> },
139 + { k: "Editor parser version", v: <Mono>{draftVersion ?? "(none)"}</Mono> },
140 + {
141 + k: "Listed",
142 + v: listed ? (
143 + <span>
144 + {listed.valid ? (
145 + <Badge size="xs" tone="ok">
146 + valid
147 + </Badge>
148 + ) : (
149 + <Badge size="xs" tone="danger">
150 + invalid
151 + </Badge>
152 + )}{" "}
153 + · enabled {String(listed.enabled)}
154 + </span>
155 + ) : (
156 + "not on disk list"
157 + ),
158 + },
159 + ]}
160 + />
161 + <div className="mt-3">
162 + <DiffView before={saved.text} after={draft.yaml} context={3} labels={[`${saved.id}.yaml (saved)`, "editor"]} maxHeight={520} />
163 + </div>
164 + </div>
165 + )}
166 + {!saved && !savedQ.error && !savedQ.loading && !id && <EmptyState compact title="Pick or type a connector id" />}
167 + </Panel>
168 +
169 + <Panel title="Deploy parser version" description="Bumps parserVersion in the YAML, saves it (backup kept), upserts the connectors row, then enqueues `reprocess` so stored bodies are re-extracted with the new rules — no refetch, no premium credits.">
170 + <div className="grid gap-3 sm:grid-cols-[180px_1fr_auto] sm:items-end">
171 + <Field label="New parserVersion" hint={`saved: ${savedVersion}`}>
172 + <Input value={version} onChange={(e) => setVersionInput(e.target.value)} mono placeholder={bumpVersion(savedVersion)} />
173 + </Field>
174 + <Checkbox label="enqueue reprocess run after saving" checked={reprocess} onChange={(e) => setReprocess(e.target.checked)} />
175 + <Button variant="accent" onClick={() => void deploy()} disabled={busy || !id || !draft.yaml.trim()}>
176 + {busy ? <Spinner className="text-white" /> : null} Deploy
177 + </Button>
178 + </div>
179 + {validation && !validation.ok && <ErrorNote error={`YAML invalid: ${validation.errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`} className="mt-3" />}
180 + {log.length > 0 && (
181 + <ol className="mt-3 space-y-1 text-[12.5px]">
182 + {log.map((l, i) => (
183 + <li key={i} className="flex flex-wrap items-center gap-2">
184 + <Badge size="xs" tone={l.ok ? "ok" : "danger"}>
185 + {l.ok ? "ok" : "failed"}
186 + </Badge>
187 + <Mono className="text-ink">{l.step}</Mono>
188 + <span className="text-ink-2">{l.detail}</span>
189 + </li>
190 + ))}
191 + {log.some((l) => l.step === "save-config" && l.ok) && (
192 + <li className="pt-1 text-[12px] text-ink-3">
193 + Follow the run on{" "}
194 + <Link href={`/admin/connectors/${encodeURIComponent(id)}`} className="text-accent underline">
195 + the connector page
196 + </Link>
197 + . Remember to set <Mono>enabled: true</Mono> and document the source in docs/connectors/{id}.md before scheduling it.
198 + </li>
199 + )}
200 + </ol>
201 + )}
202 + </Panel>
203 + </div>
204 + );
205 +}
added apps/web/src/components/admin/devtool/step-fetch.tsx +244 −0
@@ -0,0 +1,244 @@
1 +"use client";
2 +
3 +import { useMemo, useState } from "react";
4 +import { Badge, Button, EmptyState, SearchInput, Tab, TabList, TabPanel, Tabs } from "@/components/ui";
5 +import { cn } from "@/components/ui/cn";
6 +import { DASH, fmtInt } from "@/lib/format";
7 +import { adminFetch, type DevtoolFetchResult } from "@/lib/admin-api";
8 +import { htmlToTextClient, srcDocFor } from "../document-inspect";
9 +import { Checkbox, Code, countMatches, ErrorNote, ExtLink, Field, fmtBytes, fmtMs, Input, JsonView, KV, LevelBadge, Mono, Panel, Select, Spinner } from "../primitives";
10 +import { useToast } from "../toast";
11 +import { domainOf, slugify, type DevtoolShared } from "./state";
12 +
13 +const SOURCE_CAP = 300_000;
14 +
15 +export function StepFetch({ s }: { s: DevtoolShared }) {
16 + const { draft, update, fetchResult: r, setFetchResult } = s;
17 + const f = draft.fetch;
18 + const [busy, setBusy] = useState(false);
19 + const [error, setError] = useState<string | null>(null);
20 + const [search, setSearch] = useState("");
21 + const [tab, setTab] = useState("rendered");
22 + const toast = useToast();
23 +
24 + const run = async () => {
25 + if (!f.url.trim()) return setError("Enter a URL");
26 + setBusy(true);
27 + setError(null);
28 + const res = await adminFetch<DevtoolFetchResult>("devtool/fetch", { method: "POST", body: { url: f.url.trim(), level: f.level, maxLevel: Math.max(f.level, f.maxLevel), renderJs: f.renderJs || undefined, userAgent: f.userAgent, waitForSelector: f.waitForSelector.trim() || undefined }, timeoutMs: 120_000 });
29 + setBusy(false);
30 + if (!res.ok) {
31 + setError(res.error);
32 + toast({ tone: "danger", title: "Fetch failed", description: res.error });
33 + return;
34 + }
35 + setFetchResult(res.data);
36 + s.setPreviewResult(null);
37 + const d = domainOf(res.data.finalUrl || f.url);
38 + update((cur) => ({ ...cur, meta: { ...cur.meta, domain: cur.meta.domain || d, id: cur.meta.id || slugify(d), name: cur.meta.name || d.replace(/^www\./, ""), homepage: cur.meta.homepage || (d ? `https://${d}/` : "") } }));
39 + if (res.data.error) toast({ tone: "warn", title: `Fetched with error (HTML ${res.data.status})`, description: res.data.error.message ?? JSON.stringify(res.data.error).slice(0, 160) });
40 + else toast({ tone: "ok", title: `Fetched ${res.data.status} via ${res.data.fetcher} (L${res.data.level})`, description: `${fmtBytes(res.data.bytes)} in ${fmtMs(res.data.durationMs)}${res.data.credits ? ` · ${res.data.credits} credits` : ""}` });
41 + };
42 +
43 + const html = r?.html ?? "";
44 + const isHtml = /html|xml/i.test(r?.contentType ?? "") || /<html/i.test(html.slice(0, 2000));
45 + const finalUrl = r?.finalUrl ?? null;
46 + const rText = r?.text ?? "";
47 + const srcDoc = useMemo(() => (isHtml && html ? srcDocFor(html, finalUrl) : ""), [html, isHtml, finalUrl]);
48 + const text = useMemo(() => (rText.length > 0 ? rText : html ? htmlToTextClient(html) : ""), [rText, html]);
49 + const cls = (r?.classification ?? null) as Record<string, unknown> | null;
50 +
51 + return (
52 + <div className="space-y-3">
53 + <Panel title="1 · Source URL and fetch options" description="Fetched through the same escalation stack as the worker (L1 direct → L2 browser identity → L3 Firecrawl → L4 Scrapfly). Robots and SSRF policies apply; premium levels spend credits.">
54 + <div className="grid gap-3 lg:grid-cols-[1fr_120px_120px_150px_auto] lg:items-end">
55 + <Field label="URL" error={error}>
56 + <Input value={f.url} onChange={(e) => update({ fetch: { ...f, url: e.target.value } })} onKeyDown={(e) => e.key === "Enter" && void run()} placeholder="https://www.example.com/data-centers/…" mono autoFocus={!f.url} />
57 + </Field>
58 + <Field label="Start level">
59 + <Select value={f.level} onChange={(e) => update({ fetch: { ...f, level: Number(e.target.value) } })}>
60 + <option value={1}>L1 direct</option>
61 + <option value={2}>L2 browser UA</option>
62 + <option value={3}>L3 Firecrawl</option>
63 + <option value={4}>L4 Scrapfly</option>
64 + </Select>
65 + </Field>
66 + <Field label="Max level">
67 + <Select value={f.maxLevel} onChange={(e) => update({ fetch: { ...f, maxLevel: Number(e.target.value) } })}>
68 + <option value={1}>L1</option>
69 + <option value={2}>L2</option>
70 + <option value={3}>L3</option>
71 + <option value={4}>L4</option>
72 + </Select>
73 + </Field>
74 + <Field label="User agent">
75 + <Select value={f.userAgent} onChange={(e) => update({ fetch: { ...f, userAgent: e.target.value as "bot" | "browser" } })}>
76 + <option value="bot">DataCenterIndexBot</option>
77 + <option value="browser">Browser (Safari)</option>
78 + </Select>
79 + </Field>
80 + <Button variant="accent" onClick={() => void run()} disabled={busy}>
81 + {busy ? <Spinner className="text-white" /> : null}
82 + {busy ? "Fetching…" : "Fetch sample"}
83 + </Button>
84 + </div>
85 + <div className="mt-2 flex flex-wrap items-end gap-3">
86 + <Checkbox label="render JavaScript (premium levels)" checked={f.renderJs} onChange={(e) => update({ fetch: { ...f, renderJs: e.target.checked } })} />
87 + <Field label="Wait for selector" className="w-[260px]" hint="premium levels only">
88 + <Input value={f.waitForSelector} onChange={(e) => update({ fetch: { ...f, waitForSelector: e.target.value } })} placeholder=".facility-list" mono />
89 + </Field>
90 + {f.maxLevel >= 3 && (
91 + <Badge tone="warn" size="sm">
92 + maxLevel ≥ 3 may spend Firecrawl / Scrapfly credits
93 + </Badge>
94 + )}
95 + </div>
96 + </Panel>
97 +
98 + {r && (
99 + <>
100 + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">
101 + <Stat label="HTTP" value={<span className={cn(r.status >= 400 && "text-danger")}>{r.status}</span>} />
102 + <Stat label="Fetcher" value={r.fetcher} />
103 + <Stat label="Level" value={<LevelBadge level={r.level} />} />
104 + <Stat label="Credits" value={<span className={cn(r.credits > 0 && "text-accent")}>{r.credits}</span>} />
105 + <Stat label="Duration" value={fmtMs(r.durationMs)} />
106 + <Stat label="Bytes" value={fmtBytes(r.bytes)} />
107 + <Stat label="Content type" value={<span className="truncate text-[12px]">{r.contentType ?? DASH}</span>} />
108 + <Stat label="Page type" value={<span className="text-[12px]">{cls && typeof cls.pageType === "string" ? cls.pageType : DASH}</span>} />
109 + </div>
110 + {r.error && <ErrorNote error={`Fetch error: ${r.error.message ?? JSON.stringify(r.error)}`} />}
111 + {r.finalUrl !== r.url && (
112 + <p className="text-[12px] text-ink-3">
113 + Redirected to <ExtLink href={r.finalUrl} />
114 + </p>
115 + )}
116 +
117 + <Panel title={r.title ?? "Untitled page"} description={r.description ?? undefined} padded={false}>
118 + <Tabs value={tab} onValueChange={setTab}>
119 + <div className="flex flex-col gap-2 border-b border-hair px-3 pt-1 sm:flex-row sm:items-center">
120 + <TabList label="Fetch result" className="border-b-0">
121 + {isHtml && <Tab value="rendered">Rendered</Tab>}
122 + <Tab value="source">HTML source</Tab>
123 + <Tab value="text">Text</Tab>
124 + <Tab value="jsonld" count={r.jsonLd.length}>
125 + JSON-LD
126 + </Tab>
127 + <Tab value="embedded" count={r.embeddedJson.length}>
128 + Embedded JSON
129 + </Tab>
130 + <Tab value="classification">Classification</Tab>
131 + <Tab value="geo">Geo / address</Tab>
132 + <Tab value="links" count={r.links.length}>
133 + Links
134 + </Tab>
135 + </TabList>
136 + {(tab === "source" || tab === "text") && (
137 + <div className="flex items-center gap-2 pb-2 sm:ml-auto sm:pb-0">
138 + <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Find (e.g. MW, itemprop)" wrapperClassName="w-[240px]" />
139 + {search && <span className="figure text-[11.5px] text-ink-3">{fmtInt(countMatches(tab === "source" ? html : text, search))} hits</span>}
140 + </div>
141 + )}
142 + </div>
143 + {isHtml && (
144 + <TabPanel value="rendered">
145 + <iframe title="Rendered sample (sandboxed, scripts stripped)" sandbox="" srcDoc={srcDoc} className="h-[65vh] w-full bg-white" />
146 + </TabPanel>
147 + )}
148 + <TabPanel value="source" className="p-2">
149 + {r.htmlTruncated && <p className="mb-1 text-[11.5px] text-warn">HTML truncated by the API at 1.5 MB.</p>}
150 + <Code text={html.length > SOURCE_CAP ? `${html.slice(0, SOURCE_CAP)}\n… showing first ${fmtBytes(SOURCE_CAP)} of ${fmtBytes(html.length)}` : html || "(empty body)"} lang="html" search={search} maxHeight={560} wrap />
151 + </TabPanel>
152 + <TabPanel value="text" className="p-2">
153 + <Code text={text.slice(0, SOURCE_CAP) || "(no text)"} lang="text" search={search} maxHeight={560} wrap />
154 + </TabPanel>
155 + <TabPanel value="jsonld" className="space-y-2 p-3">
156 + {r.jsonLd.length === 0 ? (
157 + <EmptyState compact title="No JSON-LD objects" />
158 + ) : (
159 + r.jsonLd.map((o, i) => {
160 + const t = o && typeof o === "object" ? (o as Record<string, unknown>)["@type"] : undefined;
161 + return (
162 + <div key={i}>
163 + <div className="mb-1 flex items-center gap-2 text-[12px] text-ink-3">
164 + <Badge size="xs" mono tone="accent">
165 + {Array.isArray(t) ? t.join(", ") : String(t ?? "object")}
166 + </Badge>
167 + <span>use rule type “JSON-LD” with this @type and a JSON path like $.address.addressLocality</span>
168 + </div>
169 + <JsonView value={o} maxHeight={280} />
170 + </div>
171 + );
172 + })
173 + )}
174 + </TabPanel>
175 + <TabPanel value="embedded" className="space-y-2 p-3">
176 + {r.embeddedJson.length === 0 ? (
177 + <EmptyState compact title="No embedded JSON blobs" description="Looks for __NEXT_DATA__, drupal-settings-json, application/json scripts and window.__STATE__-style assignments." />
178 + ) : (
179 + r.embeddedJson.map((b) => (
180 + <div key={b.id}>
181 + <div className="mb-1 text-[12px] text-ink-3">
182 + <Mono className="text-ink">{b.id}</Mono> · preview (2 kB)
183 + </div>
184 + <pre className="max-h-[240px] overflow-auto rounded-xs border border-hair bg-paper px-2.5 py-2 font-mono text-[11.5px] leading-[1.5] text-ink-2">{b.preview}</pre>
185 + </div>
186 + ))
187 + )}
188 + </TabPanel>
189 + <TabPanel value="classification" className="p-3">
190 + <div className="grid gap-3 lg:grid-cols-2">
191 + <KV items={[{ k: "Title", v: r.title }, { k: "Description", v: r.description }, { k: "Published", v: r.published }, { k: "Final URL", v: <ExtLink href={r.finalUrl} /> }, { k: "Attempts", v: r.attempts != null ? <Mono>{JSON.stringify(r.attempts)}</Mono> : DASH }]} />
192 + <JsonView value={r.classification} maxHeight={260} />
193 + </div>
194 + </TabPanel>
195 + <TabPanel value="geo" className="p-3">
196 + <div className="grid gap-3 lg:grid-cols-2">
197 + <div>
198 + <div className="label-caps mb-1">Detected geo</div>
199 + {r.geo ? <JsonView value={r.geo} maxHeight={220} /> : <p className="text-[12.5px] text-ink-4">No coordinates detected (geo meta tags, JSON-LD GeoCoordinates, map embeds).</p>}
200 + </div>
201 + <div>
202 + <div className="label-caps mb-1">Detected address</div>
203 + {r.address ? <JsonView value={r.address} maxHeight={220} /> : <p className="text-[12.5px] text-ink-4">No postal address detected.</p>}
204 + </div>
205 + </div>
206 + </TabPanel>
207 + <TabPanel value="links" className="p-0">
208 + {r.links.length === 0 ? (
209 + <EmptyState compact title="No links" />
210 + ) : (
211 + <ul className="max-h-[480px] divide-y divide-hair overflow-auto">
212 + {r.links.map((l) => (
213 + <li key={l.href} className="flex items-center gap-2 px-3 py-1.5 text-[12px]">
214 + <span className="w-[200px] shrink-0 truncate text-ink-2" title={l.text}>
215 + {l.text || <span className="text-ink-4">(no text)</span>}
216 + </span>
217 + <ExtLink href={l.href} className="min-w-0 flex-1" />
218 + <button type="button" className="shrink-0 text-[11.5px] text-ink-3 hover:text-accent" onClick={() => update({ fetch: { ...f, url: l.href } })}>
219 + use as URL
220 + </button>
221 + <button type="button" className="shrink-0 text-[11.5px] text-ink-3 hover:text-accent" onClick={() => update((d) => ({ ...d, sampleUrls: d.sampleUrls.includes(l.href) ? d.sampleUrls : `${d.sampleUrls.trim()}${d.sampleUrls.trim() ? "\n" : ""}${l.href}` }))}>
222 + + sample
223 + </button>
224 + </li>
225 + ))}
226 + </ul>
227 + )}
228 + </TabPanel>
229 + </Tabs>
230 + </Panel>
231 + </>
232 + )}
233 + </div>
234 + );
235 +}
236 +
237 +function Stat({ label, value }: { label: string; value: React.ReactNode }) {
238 + return (
239 + <div className="min-w-0 rounded-sm border border-hair bg-surface px-2.5 py-1.5">
240 + <div className="label-caps">{label}</div>
241 + <div className="figure truncate text-[14px] text-ink">{value}</div>
242 + </div>
243 + );
244 +}
added apps/web/src/components/admin/devtool/step-preview.tsx +221 −0
@@ -0,0 +1,221 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import { Badge, Button, EmptyState, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
5 +import { cn } from "@/components/ui/cn";
6 +import { DASH, fmtInt, fmtValue } from "@/lib/format";
7 +import { adminFetch, type DevtoolPreviewResult } from "@/lib/admin-api";
8 +import { ErrorNote, Field, fmtMs, Input, JsonView, LevelBadge, Mono, Panel, Spinner } from "../primitives";
9 +import { useToast } from "../toast";
10 +import { defaultsToConfig, extractorToConfig, fieldUnion, isEmptyValue, type DevtoolShared } from "./state";
11 +
12 +export function StepPreview({ s }: { s: DevtoolShared }) {
13 + const { draft, update, fetchResult, previewResult: r, setPreviewResult } = s;
14 + const [busy, setBusy] = useState(false);
15 + const [error, setError] = useState<string | null>(null);
16 + const [details, setDetails] = useState<unknown>(null);
17 + const toast = useToast();
18 + const d = draft.defaults;
19 + const setDefaults = (patch: Partial<typeof d>) => update({ defaults: { ...d, ...patch } });
20 + const canReuseHtml = Boolean(fetchResult?.html && fetchResult.url === draft.fetch.url && !fetchResult.error);
21 +
22 + const run = async () => {
23 + const url = draft.fetch.url.trim();
24 + if (!url) return setError("No URL — go back to step 1.");
25 + const extractor = extractorToConfig(draft.extractor);
26 + if (!extractor.fields && !extractor.parser) return setError("Define at least one field rule (or a parser) in step 2.");
27 + setBusy(true);
28 + setError(null);
29 + setDetails(null);
30 + const body: Record<string, unknown> = { url, extractor, defaults: defaultsToConfig(d), connectorId: draft.meta.id || undefined, kind: draft.extractor.kind, level: draft.fetch.level, maxLevel: Math.max(draft.fetch.level, draft.fetch.maxLevel) };
31 + if (canReuseHtml) body.html = fetchResult!.html;
32 + const res = await adminFetch<DevtoolPreviewResult>("devtool/preview", { method: "POST", body, timeoutMs: 120_000 });
33 + setBusy(false);
34 + if (!res.ok) {
35 + setError(res.error);
36 + setDetails(res.details ?? null);
37 + toast({ tone: "danger", title: "Preview failed", description: res.error });
38 + return;
39 + }
40 + setPreviewResult(res.data);
41 + const v = res.data.validation;
42 + toast({ tone: v.rejected > 0 ? "warn" : res.data.entities.length ? "ok" : "warn", title: `${fmtInt(res.data.records.length)} record(s) → ${fmtInt(res.data.entities.length)} entit${res.data.entities.length === 1 ? "y" : "ies"}`, description: `${fmtInt(v.valid)} valid · ${fmtInt(v.rejected)} rejected · ${fmtInt(v.issues.length)} issue(s)` });
43 + };
44 +
45 + const entityFields = r ? fieldUnion(r.entities, ["entityType", "key", "name", "operatorName", "city", "countryIso2", "lat", "lng", "status", "facilityType", "itCapacityMw", "totalPowerMw", "plannedPowerMw"]) : [];
46 + const recordFields = r ? fieldUnion(r.records) : [];
47 + const ruleNames = draft.extractor.fields.map((f) => f.name.trim()).filter(Boolean);
48 + const emptyRules = r && r.records.length ? ruleNames.filter((n) => r.records.every((rec) => isEmptyValue(rec[n]))) : [];
49 +
50 + return (
51 + <div className="space-y-3">
52 + <Panel title="3 · Preview extraction" description={canReuseHtml ? "Runs the extractor on the HTML fetched in step 1 (no new request), then normalize → validate through GenericConnector." : "The page will be fetched again (step 1 result is for another URL or failed)."}>
53 + <div className="grid gap-3 lg:grid-cols-[repeat(5,1fr)_auto] lg:items-end">
54 + <Field label="Default operator" hint="merged into every entity">
55 + <Input value={d.operatorName} onChange={(e) => setDefaults({ operatorName: e.target.value })} placeholder="Digital Realty" />
56 + </Field>
57 + <Field label="Operator kind">
58 + <Input value={d.operatorKind} onChange={(e) => setDefaults({ operatorKind: e.target.value })} placeholder="colocation" mono />
59 + </Field>
60 + <Field label="Country (iso2)">
61 + <Input value={d.countryIso2} onChange={(e) => setDefaults({ countryIso2: e.target.value.toUpperCase().slice(0, 2) })} placeholder="US" mono />
62 + </Field>
63 + <Field label="Facility type">
64 + <Input value={d.facilityType} onChange={(e) => setDefaults({ facilityType: e.target.value })} placeholder="colocation" mono />
65 + </Field>
66 + <Field label="Status">
67 + <Input value={d.status} onChange={(e) => setDefaults({ status: e.target.value })} placeholder="operational" mono />
68 + </Field>
69 + <Button variant="accent" onClick={() => void run()} disabled={busy}>
70 + {busy ? <Spinner className="text-white" /> : null}
71 + {busy ? "Running…" : "Preview fields"}
72 + </Button>
73 + </div>
74 + <ErrorNote error={error} className="mt-3" />
75 + {details ? <JsonView value={details} maxHeight={160} className="mt-2" /> : null}
76 + </Panel>
77 +
78 + {r && (
79 + <>
80 + <div className="flex flex-wrap items-center gap-2 text-[12.5px]">
81 + <Badge size="sm" tone="outline" mono>
82 + HTTP {r.fetch.status}
83 + </Badge>
84 + <Badge size="sm" tone="outline">
85 + {r.fetch.fetcher}
86 + </Badge>
87 + <LevelBadge level={r.fetch.level} />
88 + <span className="text-ink-3">{fmtMs(r.fetch.durationMs)}</span>
89 + <span className="figure text-ink">{fmtInt(r.records.length)} records</span>
90 + <span className="text-ink-4">→</span>
91 + <span className="figure text-ink">{fmtInt(r.entities.length)} entities</span>
92 + <span className="text-ink-4">→</span>
93 + <span className={cn("figure", r.validation.rejected ? "text-danger" : "text-ok")}>
94 + {fmtInt(r.validation.valid)} valid / {fmtInt(r.validation.rejected)} rejected
95 + </span>
96 + {emptyRules.length > 0 && (
97 + <Badge size="sm" tone="warn">
98 + empty on every record: {emptyRules.join(", ")}
99 + </Badge>
100 + )}
101 + </div>
102 +
103 + <div className="grid gap-3 xl:grid-cols-2">
104 + <Panel title="Records (raw extraction)" description="Field values as produced by the rules, before normalization. Empty cells are highlighted." padded={false}>
105 + {r.records.length === 0 ? (
106 + <EmptyState compact title="No records" description="The rules matched nothing — check the selectors against the HTML source in step 1 (or `each` selects no element)." />
107 + ) : (
108 + <div className="max-h-[440px] overflow-auto">
109 + <Table responsive={false} className="text-[12px]">
110 + <THead>
111 + <Tr>
112 + <Th className="pl-3">Field</Th>
113 + {r.records.slice(0, 6).map((_, i) => (
114 + <Th key={i}>#{i + 1}</Th>
115 + ))}
116 + </Tr>
117 + </THead>
118 + <TBody>
119 + {[...new Set([...ruleNames, ...recordFields])].map((k) => (
120 + <Tr key={k}>
121 + <Td className="pl-3">
122 + <Mono className={cn(ruleNames.includes(k) ? "text-ink" : "text-ink-3")}>{k}</Mono>
123 + </Td>
124 + {r.records.slice(0, 6).map((rec, i) => (
125 + <Td key={i} className={cn("max-w-[260px] truncate", isEmptyValue(rec[k]) && "bg-warn/10 text-warn")} title={typeof rec[k] === "object" ? JSON.stringify(rec[k]) : String(rec[k] ?? "")}>
126 + {isEmptyValue(rec[k]) ? "empty" : fmtValue(rec[k])}
127 + </Td>
128 + ))}
129 + </Tr>
130 + ))}
131 + </TBody>
132 + </Table>
133 + {r.records.length > 6 && <p className="px-3 py-1.5 text-[11.5px] text-ink-4">Showing 6 of {fmtInt(r.records.length)} records.</p>}
134 + </div>
135 + )}
136 + </Panel>
137 +
138 + <Panel title="Normalized entities" description="After normalize(): units parsed, statuses mapped, defaults merged. Empty fields highlighted." padded={false}>
139 + {r.entities.length === 0 ? (
140 + <EmptyState compact title="No entities" description="Records did not survive normalization (missing name / key?). Check the validation issues." />
141 + ) : (
142 + <div className="max-h-[440px] overflow-auto">
143 + <Table responsive={false} className="text-[12px]">
144 + <THead>
145 + <Tr>
146 + <Th className="pl-3">Field</Th>
147 + {r.entities.slice(0, 6).map((_, i) => (
148 + <Th key={i}>#{i + 1}</Th>
149 + ))}
150 + </Tr>
151 + </THead>
152 + <TBody>
153 + {entityFields.map((k) => (
154 + <Tr key={k}>
155 + <Td className="pl-3">
156 + <Mono className="text-ink">{k}</Mono>
157 + </Td>
158 + {r.entities.slice(0, 6).map((e, i) => (
159 + <Td key={i} className={cn("max-w-[260px] truncate", isEmptyValue(e[k]) && "bg-warn/10 text-warn")} title={typeof e[k] === "object" ? JSON.stringify(e[k]) : String(e[k] ?? "")}>
160 + {isEmptyValue(e[k]) ? "empty" : fmtValue(e[k])}
161 + </Td>
162 + ))}
163 + </Tr>
164 + ))}
165 + </TBody>
166 + </Table>
167 + </div>
168 + )}
169 + </Panel>
170 + </div>
171 +
172 + <div className="grid gap-3 xl:grid-cols-2">
173 + <Panel title="Validation issues" description={`${fmtInt(r.validation.issues.length)} issue(s) — rejected entities never reach the index`} padded={false}>
174 + {r.validation.issues.length === 0 ? (
175 + <EmptyState compact title="No validation issues" />
176 + ) : (
177 + <ul className="max-h-[320px] divide-y divide-hair overflow-auto text-[12.5px]">
178 + {r.validation.issues.map((i, k) => {
179 + const level = String(i.level ?? i.severity ?? "error");
180 + return (
181 + <li key={k} className="flex flex-wrap items-center gap-2 px-3 py-1.5">
182 + <Badge size="xs" tone={level === "warn" || level === "warning" ? "warn" : "danger"}>
183 + {level}
184 + </Badge>
185 + {(i.field ?? i.path) && <Mono className="text-ink">{String(i.field ?? i.path)}</Mono>}
186 + <span className="text-ink-2">{String(i.message ?? JSON.stringify(i))}</span>
187 + {(i.entity ?? i.key) != null && <Mono className="ml-auto text-ink-4">{String(i.entity ?? i.key)}</Mono>}
188 + </li>
189 + );
190 + })}
191 + </ul>
192 + )}
193 + </Panel>
194 + <Panel title="Pipeline log" padded={false}>
195 + {r.logs.length === 0 ? (
196 + <EmptyState compact title="No log lines" />
197 + ) : (
198 + <div className="max-h-[320px] overflow-auto font-mono text-[11.5px] leading-[1.5]">
199 + {r.logs.map((l, i) => (
200 + <div key={i} className={cn("flex gap-2 border-b border-hair/60 px-3 py-0.5 last:border-0", l.level === "error" && "text-danger", l.level === "warn" && "text-warn")}>
201 + <span className="w-10 shrink-0 text-ink-4">{l.level}</span>
202 + <span className="min-w-0 break-all text-ink-2">{l.msg}</span>
203 + </div>
204 + ))}
205 + </div>
206 + )}
207 + </Panel>
208 + </div>
209 + {r.entities.length > 0 && (
210 + <details className="rounded-sm border border-hair bg-surface">
211 + <summary className="cursor-pointer px-3 py-2 text-[12.5px] text-ink-2">Raw JSON (first entity)</summary>
212 + <div className="px-3 pb-3">
213 + <JsonView value={r.entities[0] ?? DASH} maxHeight={360} />
214 + </div>
215 + </details>
216 + )}
217 + </>
218 + )}
219 + </div>
220 + );
221 +}
added apps/web/src/components/admin/devtool/step-rules.tsx +241 −0
@@ -0,0 +1,241 @@
1 +"use client";
2 +
3 +import { Plus, Trash2 } from "lucide-react";
4 +import { useMemo, useState } from "react";
5 +import { Badge, Button } from "@/components/ui";
6 +import { cn } from "@/components/ui/cn";
7 +import { Checkbox, Code, ErrorNote, Field, Input, Select, Textarea } from "../primitives";
8 +import { toYaml } from "../yaml";
9 +import { COMMON_FIELDS, configToRule, EXTRACT_KINDS, extractorToConfig, newFieldRule, RULE_TYPES, TRANSFORMS, type DevtoolShared, type FieldRule } from "./state";
10 +
11 +export function StepRules({ s }: { s: DevtoolShared }) {
12 + const { draft, update } = s;
13 + const ex = draft.extractor;
14 + const [advanced, setAdvanced] = useState(false);
15 + const [jsonText, setJsonText] = useState("");
16 + const [jsonError, setJsonError] = useState<string | null>(null);
17 + const config = useMemo(() => extractorToConfig(ex, { includeMatch: true }), [ex]);
18 + const yamlPreview = useMemo(() => toYaml({ extractors: { [ex.name.trim() || ex.kind]: config } }), [config, ex.name, ex.kind]);
19 + const setEx = (patch: Partial<typeof ex>) => update({ extractor: { ...ex, ...patch }, yamlDirty: draft.yamlDirty });
20 + const setField = (id: string, patch: Partial<FieldRule>) => setEx({ fields: ex.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)) });
21 + const suggestions = COMMON_FIELDS[ex.kind] ?? [];
22 + const usedNames = new Set(ex.fields.map((f) => f.name.trim()).filter(Boolean));
23 + const duplicates = ex.fields.map((f) => f.name.trim()).filter((n, i, a) => n && a.indexOf(n) !== i);
24 +
25 + const openAdvanced = () => {
26 + setJsonText(JSON.stringify(config, null, 2));
27 + setJsonError(null);
28 + setAdvanced(true);
29 + };
30 + const applyAdvanced = () => {
31 + try {
32 + const o = JSON.parse(jsonText) as Record<string, unknown>;
33 + const fields = Object.entries((o.fields ?? {}) as Record<string, unknown>).map(([n, r]) => configToRule(n, r));
34 + setEx({ kind: typeof o.kind === "string" ? o.kind : ex.kind, key: typeof o.key === "string" ? o.key : "", each: typeof o.each === "string" ? o.each : "", parser: typeof o.parser === "string" ? o.parser : "", minCertainty: o.minCertainty != null ? String(o.minCertainty) : "", match: Array.isArray(o.match) ? (o.match as string[]).join("\n") : ex.match, fields });
35 + setAdvanced(false);
36 + setJsonError(null);
37 + } catch (e) {
38 + setJsonError((e as Error).message);
39 + }
40 + };
41 +
42 + return (
43 + <div className="grid gap-3 xl:grid-cols-[1fr_380px]">
44 + <div className="space-y-3">
45 + <div className="rounded-sm border border-hair bg-surface p-3">
46 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
47 + <Field label="Extractor name" hint="key under extractors:">
48 + <Input value={ex.name} onChange={(e) => setEx({ name: e.target.value.replace(/[^a-z0-9_-]/gi, "") })} mono placeholder="facility" />
49 + </Field>
50 + <Field label="Entity kind">
51 + <Select value={ex.kind} onChange={(e) => setEx({ kind: e.target.value })}>
52 + {EXTRACT_KINDS.map((k) => (
53 + <option key={k} value={k}>
54 + {k}
55 + </option>
56 + ))}
57 + </Select>
58 + </Field>
59 + <Field label="Key template" hint="stable id: {url}, {code}, {name}">
60 + <Input value={ex.key} onChange={(e) => setEx({ key: e.target.value })} mono placeholder="{url}" />
61 + </Field>
62 + <Field label="each (list container)" hint="one record per match">
63 + <Input value={ex.each} onChange={(e) => setEx({ each: e.target.value })} mono placeholder=".facility-card" />
64 + </Field>
65 + <Field label="Min certainty" hint="0–1, optional">
66 + <Input value={ex.minCertainty} onChange={(e) => setEx({ minCertainty: e.target.value })} mono placeholder="0.5" inputMode="decimal" />
67 + </Field>
68 + </div>
69 + <div className="mt-3 grid gap-3 sm:grid-cols-2">
70 + <Field label="URL match patterns" hint="regex per line — which pages this extractor handles (used when saving; preview ignores it)">
71 + <Textarea rows={2} value={ex.match} onChange={(e) => setEx({ match: e.target.value })} mono placeholder={"/data-centers/[a-z-]+/[a-z-]+/[a-z0-9-]+$"} />
72 + </Field>
73 + <Field label="Code parser (optional)" hint="registered parser name (apps/worker/src/parsers) — overrides declarative fields">
74 + <Input value={ex.parser} onChange={(e) => setEx({ parser: e.target.value })} mono placeholder="digitalrealty_facility_v1" />
75 + </Field>
76 + </div>
77 + </div>
78 +
79 + <div className="rounded-sm border border-hair bg-surface">
80 + <div className="flex flex-wrap items-center justify-between gap-2 border-b border-hair px-3 py-2">
81 + <div className="text-[13px] font-semibold text-ink">
82 + Fields <span className="ml-1 text-[12px] font-normal text-ink-3">{ex.fields.length}</span>
83 + </div>
84 + <div className="flex flex-wrap items-center gap-1.5">
85 + {suggestions
86 + .filter((n) => !usedNames.has(n))
87 + .slice(0, 12)
88 + .map((n) => (
89 + <button key={n} type="button" onClick={() => setEx({ fields: [...ex.fields, newFieldRule({ name: n, type: n === "lat" || n === "lng" ? "jsonld" : "selector", jsonld: n === "lat" || n === "lng" ? "Place" : "", jsonPath: n === "lat" ? "$.geo.latitude" : n === "lng" ? "$.geo.longitude" : "", transform: /Mw$/.test(n) || n === "mw" ? "mw" : n === "openedOn" || n === "launchedOn" || n === "announcedOn" ? "date" : n === "status" ? "status" : n === "countryIso2" ? "country" : n === "buildingSqm" ? "sqm" : n === "siteAreaHa" ? "ha" : n === "lat" || n === "lng" ? "float" : "" })] })} className="rounded-xs border border-dashed border-hair-2 px-1.5 py-0.5 font-mono text-[11px] text-ink-3 hover:border-ink-4 hover:text-ink">
90 + + {n}
91 + </button>
92 + ))}
93 + <Button size="xs" variant="ghost" onClick={openAdvanced}>
94 + Edit as JSON
95 + </Button>
96 + </div>
97 + </div>
98 + {duplicates.length > 0 && <ErrorNote error={`Duplicate field names: ${[...new Set(duplicates)].join(", ")}`} className="m-3" />}
99 + <ul className="divide-y divide-hair">
100 + {ex.fields.map((f) => (
101 + <FieldRow key={f.id} f={f} onChange={(p) => setField(f.id, p)} onRemove={() => setEx({ fields: ex.fields.filter((x) => x.id !== f.id) })} />
102 + ))}
103 + </ul>
104 + <div className="px-3 py-2">
105 + <Button size="sm" variant="secondary" onClick={() => setEx({ fields: [...ex.fields, newFieldRule()] })}>
106 + <Plus className="size-3.5" /> Add field
107 + </Button>
108 + </div>
109 + </div>
110 +
111 + {advanced && (
112 + <div className="rounded-sm border border-hair bg-surface p-3">
113 + <div className="mb-2 flex items-center justify-between">
114 + <span className="text-[13px] font-semibold text-ink">Extractor as JSON (schema: ExtractorConfig)</span>
115 + <div className="flex gap-1.5">
116 + <Button size="xs" variant="ghost" onClick={() => setAdvanced(false)}>
117 + Cancel
118 + </Button>
119 + <Button size="xs" variant="primary" onClick={applyAdvanced}>
120 + Apply
121 + </Button>
122 + </div>
123 + </div>
124 + <Textarea rows={14} mono value={jsonText} onChange={(e) => setJsonText(e.target.value)} />
125 + <ErrorNote error={jsonError} className="mt-2" />
126 + </div>
127 + )}
128 + </div>
129 +
130 + <div className="space-y-2 xl:sticky xl:top-3 xl:self-start">
131 + <div className="flex items-center justify-between">
132 + <span className="label-caps">YAML preview · extractors block</span>
133 + <Badge size="xs" tone="outline" mono>
134 + {Object.keys((config.fields as object) ?? {}).length} rules
135 + </Badge>
136 + </div>
137 + <Code text={yamlPreview} lang="yaml" maxHeight={520} />
138 + <p className="text-[11.5px] leading-snug text-ink-4">
139 + Rule types map to the schema: <span className="font-mono">selector / attr / all / regex / group / jsonPath / jsonld / meta / transform / default</span>. Transforms normalize units (mw, sqm, ha, money), dates, statuses, types and countries — never invent a value: leave a field unset rather than defaulting to 0.
140 + </p>
141 + </div>
142 + </div>
143 + );
144 +}
145 +
146 +function FieldRow({ f, onChange, onRemove }: { f: FieldRule; onChange: (p: Partial<FieldRule>) => void; onRemove: () => void }) {
147 + const t = f.type;
148 + const hint = RULE_TYPES.find((r) => r.value === t)?.hint;
149 + return (
150 + <li className="grid gap-2 px-3 py-2.5 lg:grid-cols-[150px_150px_1fr_130px_auto] lg:items-start">
151 + <Field label="Field">
152 + <Input value={f.name} onChange={(e) => onChange({ name: e.target.value.trim() })} mono placeholder="name" className={cn(!f.name && "border-warn")} />
153 + </Field>
154 + <Field label="Rule type" hint={hint}>
155 + <Select value={t} onChange={(e) => onChange({ type: e.target.value as FieldRule["type"] })}>
156 + {RULE_TYPES.map((r) => (
157 + <option key={r.value} value={r.value}>
158 + {r.label}
159 + </option>
160 + ))}
161 + </Select>
162 + </Field>
163 + <div className="grid gap-2 sm:grid-cols-2">
164 + {(t === "selector" || t === "attr" || t === "regex") && (
165 + <Field label={t === "regex" ? "Selector (optional scope)" : "CSS selector"} className={t === "selector" && !f.regex ? "sm:col-span-2" : ""}>
166 + <Input value={f.selector} onChange={(e) => onChange({ selector: e.target.value })} mono placeholder={t === "regex" ? "body" : "h1, .facility-title"} className={cn(t !== "regex" && !f.selector && "border-warn")} />
167 + </Field>
168 + )}
169 + {t === "attr" && (
170 + <Field label="Attribute">
171 + <Input value={f.attr} onChange={(e) => onChange({ attr: e.target.value })} mono placeholder="href / content / data-lat" className={cn(!f.attr && "border-warn")} />
172 + </Field>
173 + )}
174 + {t === "jsonld" && (
175 + <>
176 + <Field label="JSON-LD @type">
177 + <Input value={f.jsonld} onChange={(e) => onChange({ jsonld: e.target.value })} mono placeholder="Place, LocalBusiness, Organization" />
178 + </Field>
179 + <Field label="JSON path">
180 + <Input value={f.jsonPath} onChange={(e) => onChange({ jsonPath: e.target.value })} mono placeholder="$.address.addressLocality" />
181 + </Field>
182 + </>
183 + )}
184 + {t === "meta" && (
185 + <Field label="Meta name / property" className="sm:col-span-2">
186 + <Input value={f.meta} onChange={(e) => onChange({ meta: e.target.value })} mono placeholder="og:title, description, geo.position" className={cn(!f.meta && "border-warn")} />
187 + </Field>
188 + )}
189 + {t === "jsonPath" && (
190 + <Field label="JSON path over embedded JSON" className="sm:col-span-2">
191 + <Input value={f.jsonPath} onChange={(e) => onChange({ jsonPath: e.target.value })} mono placeholder="$.props.pageProps.node.field_power_mw" className={cn(!f.jsonPath && "border-warn")} />
192 + </Field>
193 + )}
194 + {(t === "regex" || (t === "selector" && f.regex !== undefined)) && (
195 + <>
196 + <Field label={t === "selector" ? "Regex on text (optional)" : "Regex"}>
197 + <Input value={f.regex} onChange={(e) => onChange({ regex: e.target.value })} mono placeholder="(\d+(?:\.\d+)?)\s*MW" className={cn(t === "regex" && !f.regex && "border-warn")} />
198 + </Field>
199 + {(f.regex || t === "regex") && (
200 + <div className="grid grid-cols-2 gap-2">
201 + <Field label="Flags">
202 + <Input value={f.regexFlags} onChange={(e) => onChange({ regexFlags: e.target.value })} mono placeholder="i" />
203 + </Field>
204 + <Field label="Group">
205 + <Input value={f.group} onChange={(e) => onChange({ group: e.target.value })} mono placeholder="1" inputMode="numeric" />
206 + </Field>
207 + </div>
208 + )}
209 + </>
210 + )}
211 + {t === "constant" && (
212 + <Field label="Value" className="sm:col-span-2">
213 + <Input value={f.default} onChange={(e) => onChange({ default: e.target.value })} mono placeholder="colocation" className={cn(!f.default && "border-warn")} />
214 + </Field>
215 + )}
216 + {(t === "selector" || t === "attr" || t === "regex") && <Checkbox label="all matches (array)" checked={f.all} onChange={(e) => onChange({ all: e.target.checked })} className="sm:col-span-2 -mt-1" />}
217 + </div>
218 + <div className="grid gap-2">
219 + <Field label="Transform">
220 + <Select value={f.transform} onChange={(e) => onChange({ transform: e.target.value })}>
221 + {TRANSFORMS.map((x) => (
222 + <option key={x} value={x}>
223 + {x || "none"}
224 + </option>
225 + ))}
226 + </Select>
227 + </Field>
228 + {t !== "constant" && (
229 + <Field label="Default">
230 + <Input value={f.default} onChange={(e) => onChange({ default: e.target.value })} mono placeholder="—" />
231 + </Field>
232 + )}
233 + </div>
234 + <div className="flex justify-end lg:pt-5">
235 + <Button size="sm" variant="ghost" onClick={onRemove} aria-label="Remove field">
236 + <Trash2 className="size-3.5 text-ink-3" />
237 + </Button>
238 + </div>
239 + </li>
240 + );
241 +}
added apps/web/src/components/admin/devtool/step-sample.tsx +203 −0
@@ -0,0 +1,203 @@
1 +"use client";
2 +
3 +import { useMemo, useState } from "react";
4 +import { Badge, Button, EmptyState, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
5 +import { cn } from "@/components/ui/cn";
6 +import { DASH, fmtInt, fmtValue } from "@/lib/format";
7 +import { adminFetch, type RunSampleResult } from "@/lib/admin-api";
8 +import { Disclosure, ErrorNote, ExtLink, Field, fmtMs, JsonView, LevelBadge, Mono, Panel, Select, Spinner, Textarea } from "../primitives";
9 +import { useToast } from "../toast";
10 +import { fieldUnion, isEmptyValue, parseUrls, type DevtoolShared } from "./state";
11 +
12 +const HIDDEN = new Set(["provenance", "entityType", "key", "sourceUrl", "url"]);
13 +
14 +export function StepSample({ s }: { s: DevtoolShared }) {
15 + const { draft, update, sampleResult: r, setSampleResult } = s;
16 + const [busy, setBusy] = useState(false);
17 + const [error, setError] = useState<string | null>(null);
18 + const [level, setLevel] = useState<string>("");
19 + const toast = useToast();
20 + const urls = useMemo(() => parseUrls(draft.sampleUrls), [draft.sampleUrls]);
21 +
22 + const run = async () => {
23 + if (!draft.yaml.trim()) return setError("No YAML — generate or paste it in step 4 first.");
24 + if (urls.length === 0) return setError("Enter at least one http(s) URL (one per line).");
25 + if (urls.length > 10) return setError("At most 10 URLs per run.");
26 + setBusy(true);
27 + setError(null);
28 + const res = await adminFetch<RunSampleResult>("devtool/run-sample", { method: "POST", body: { yaml: draft.yaml, urls, level: level ? Number(level) : undefined }, timeoutMs: 290_000 });
29 + setBusy(false);
30 + if (!res.ok) {
31 + setError(res.error);
32 + toast({ tone: "danger", title: "Sample run failed", description: res.error });
33 + return;
34 + }
35 + setSampleResult(res.data);
36 + toast({ tone: res.data.summary.ok === res.data.summary.urls ? "ok" : "warn", title: `${res.data.summary.ok}/${res.data.summary.urls} pages produced valid entities`, description: res.data.summary.credits ? `${res.data.summary.credits} premium credits spent` : "no premium credits" });
37 + };
38 +
39 + const firstEntities = useMemo(() => (r ? r.results.map((x) => x.entities?.[0] ?? null) : []), [r]);
40 + const fields = useMemo(() => fieldUnion(firstEntities.filter((e): e is Record<string, unknown> => Boolean(e)), ["name", "operatorName", "city", "countryIso2", "lat", "lng", "status", "facilityType", "itCapacityMw", "totalPowerMw", "plannedPowerMw", "openedOn"]).filter((f) => !HIDDEN.has(f)), [firstEntities]);
41 +
42 + return (
43 + <div className="space-y-3">
44 + <Panel title="5 · Run on multiple pages" description="Fetch + extract + normalize + validate each URL with the YAML from step 4 (test context: nothing is persisted; premium credits are counted). Up to 10 URLs.">
45 + <div className="grid gap-3 lg:grid-cols-[1fr_160px_auto] lg:items-end">
46 + <Field label={`URLs (${urls.length} valid)`} hint="one per line — use “+ sample” in the step-1 links tab to collect them">
47 + <Textarea rows={6} mono value={draft.sampleUrls} onChange={(e) => update({ sampleUrls: e.target.value })} placeholder={"https://www.example.com/data-centers/a\nhttps://www.example.com/data-centers/b"} />
48 + </Field>
49 + <Field label="Min fetch level" hint="override the YAML start level">
50 + <Select value={level} onChange={(e) => setLevel(e.target.value)}>
51 + <option value="">from YAML</option>
52 + <option value="1">L1 direct</option>
53 + <option value="2">L2 browser UA</option>
54 + <option value="3">L3 Firecrawl</option>
55 + <option value="4">L4 Scrapfly</option>
56 + </Select>
57 + </Field>
58 + <Button variant="accent" onClick={() => void run()} disabled={busy || urls.length === 0}>
59 + {busy ? <Spinner className="text-white" /> : null}
60 + {busy ? `Running ${urls.length} page(s)…` : "Run sample"}
61 + </Button>
62 + </div>
63 + <ErrorNote error={error} className="mt-3" />
64 + </Panel>
65 +
66 + {r && (
67 + <>
68 + <div className="flex flex-wrap items-center gap-2 text-[12.5px]">
69 + <Badge size="sm" tone={r.summary.ok === r.summary.urls ? "ok" : r.summary.ok ? "warn" : "danger"}>
70 + {r.summary.ok} / {r.summary.urls} ok
71 + </Badge>
72 + <span className="text-ink-3">
73 + credits <span className={cn("figure", r.summary.credits > 0 && "text-accent")}>{fmtInt(r.summary.credits)}</span>
74 + </span>
75 + <span className="text-ink-3">field coverage:</span>
76 + {Object.entries(r.summary.fieldCoverage)
77 + .filter(([k]) => !HIDDEN.has(k))
78 + .sort((a, b) => b[1] - a[1])
79 + .map(([k, n]) => (
80 + <Badge key={k} size="xs" mono tone={n === r.summary.urls ? "ok" : n === 0 ? "danger" : "outline"}>
81 + {k} {n}/{r.summary.urls}
82 + </Badge>
83 + ))}
84 + </div>
85 +
86 + <Panel title="Comparison — first entity per URL" description="Empty cells highlighted: a rule that works on one page and not another usually means the markup differs." padded={false}>
87 + {fields.length === 0 ? (
88 + <EmptyState compact title="No entities extracted on any page" />
89 + ) : (
90 + <div className="overflow-auto">
91 + <Table responsive={false} className="min-w-[720px] text-[12px]">
92 + <THead>
93 + <Tr>
94 + <Th className="pl-3">URL</Th>
95 + <Th>OK</Th>
96 + {fields.map((f) => (
97 + <Th key={f}>
98 + <Mono className="text-ink-3">{f}</Mono>
99 + </Th>
100 + ))}
101 + </Tr>
102 + </THead>
103 + <TBody>
104 + {r.results.map((x, i) => {
105 + const e = firstEntities[i];
106 + return (
107 + <Tr key={x.url}>
108 + <Td className="max-w-[260px] pl-3">
109 + <ExtLink href={x.url} />
110 + </Td>
111 + <Td>{x.ok ? <span className="text-ok">✓</span> : <span className="text-danger">✗</span>}</Td>
112 + {fields.map((f) => (
113 + <Td key={f} className={cn("max-w-[200px] truncate", (!e || isEmptyValue(e[f])) && "bg-warn/10 text-warn")} title={e ? (typeof e[f] === "object" ? JSON.stringify(e[f]) : String(e[f] ?? "")) : ""}>
114 + {!e || isEmptyValue(e[f]) ? "empty" : fmtValue(e[f])}
115 + </Td>
116 + ))}
117 + </Tr>
118 + );
119 + })}
120 + </TBody>
121 + </Table>
122 + </div>
123 + )}
124 + </Panel>
125 +
126 + <div className="space-y-1.5">
127 + {r.results.map((x) => (
128 + <Disclosure
129 + key={x.url}
130 + summary={
131 + <span className="flex flex-wrap items-center gap-2">
132 + {x.ok ? (
133 + <Badge size="xs" tone="ok">
134 + ok
135 + </Badge>
136 + ) : (
137 + <Badge size="xs" tone="danger">
138 + failed
139 + </Badge>
140 + )}
141 + <span className="min-w-0 max-w-[420px] truncate font-mono text-[12px] text-ink">{x.url.replace(/^https?:\/\//, "")}</span>
142 + {x.fetch && (
143 + <>
144 + <Mono>HTTP {x.fetch.status}</Mono>
145 + <LevelBadge level={x.fetch.level} />
146 + <span className="text-ink-3">{x.fetch.fetcher}</span>
147 + </>
148 + )}
149 + <span className="figure text-ink-3">{fmtMs(x.durationMs)}</span>
150 + {x.entities && <span className="figure text-ink-3">{fmtInt(x.records?.length ?? 0)} rec → {fmtInt(x.entities.length)} ent</span>}
151 + {x.validation && (
152 + <span className={cn("figure", x.validation.rejected ? "text-danger" : "text-ink-3")}>
153 + {fmtInt(x.validation.valid)} valid / {fmtInt(x.validation.rejected)} rejected
154 + </span>
155 + )}
156 + {x.error && <span className="truncate text-danger">{x.error}</span>}
157 + </span>
158 + }
159 + >
160 + <div className="grid gap-3 lg:grid-cols-2">
161 + <div>
162 + <div className="label-caps mb-1">Validation issues</div>
163 + {!x.validation || x.validation.issues.length === 0 ? (
164 + <p className="text-[12px] text-ink-4">{x.validation ? "none" : DASH}</p>
165 + ) : (
166 + <ul className="space-y-0.5 text-[12px]">
167 + {x.validation.issues.map((i, k) => (
168 + <li key={k} className="flex flex-wrap gap-2">
169 + <Badge size="xs" tone={String(i.level ?? i.severity ?? "error").startsWith("warn") ? "warn" : "danger"}>
170 + {String(i.level ?? i.severity ?? "error")}
171 + </Badge>
172 + {(i.field ?? i.path) && <Mono className="text-ink">{String(i.field ?? i.path)}</Mono>}
173 + <span className="text-ink-2">{String(i.message ?? JSON.stringify(i))}</span>
174 + </li>
175 + ))}
176 + </ul>
177 + )}
178 + {x.logs && x.logs.length > 0 && (
179 + <>
180 + <div className="label-caps mb-1 mt-3">Log</div>
181 + <div className="max-h-[160px] overflow-auto font-mono text-[11.5px] text-ink-3">
182 + {x.logs.map((l, k) => (
183 + <div key={k} className={cn(l.level === "error" && "text-danger", l.level === "warn" && "text-warn")}>
184 + [{l.level}] {l.msg}
185 + </div>
186 + ))}
187 + </div>
188 + </>
189 + )}
190 + </div>
191 + <div>
192 + <div className="label-caps mb-1">Entities</div>
193 + {x.entities && x.entities.length ? <JsonView value={x.entities} maxHeight={300} /> : <p className="text-[12px] text-ink-4">none</p>}
194 + </div>
195 + </div>
196 + </Disclosure>
197 + ))}
198 + </div>
199 + </>
200 + )}
201 + </div>
202 + );
203 +}
added apps/web/src/components/admin/devtool/step-save.tsx +253 −0
@@ -0,0 +1,253 @@
1 +"use client";
2 +
3 +import { useEffect, useMemo, useRef, useState } from "react";
4 +import { Badge, Button } from "@/components/ui";
5 +import { cn } from "@/components/ui/cn";
6 +import { adminFetch, type SaveConfigResult, type ValidateConfigResult } from "@/lib/admin-api";
7 +import { Code, ErrorNote, Field, Input, Mono, Panel, Select, Spinner, Textarea } from "../primitives";
8 +import { useToast } from "../toast";
9 +import { buildYaml, SOURCE_KINDS_LIST, type DevtoolShared } from "./state";
10 +
11 +/** Returns the 1-based line of the first occurrence of a dotted config path in the YAML, best effort. */
12 +function lineForPath(yaml: string, path: string): number | null {
13 + if (!path) return null;
14 + const parts = path.split(".");
15 + const lines = yaml.split("\n");
16 + let depth = 0;
17 + let from = 0;
18 + let found: number | null = null;
19 + for (const part of parts) {
20 + let hit = -1;
21 + for (let i = from; i < lines.length; i++) {
22 + const l = lines[i]!;
23 + const indent = l.length - l.trimStart().length;
24 + if (indent < depth * 2 && i > from && depth > 0) break;
25 + if (/^\d+$/.test(part)) {
26 + if (/^\s*-\s/.test(l)) {
27 + hit = i;
28 + break;
29 + }
30 + } else if (new RegExp(`^\\s*"?${part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"?\\s*:`).test(l)) {
31 + hit = i;
32 + break;
33 + }
34 + }
35 + if (hit < 0) break;
36 + found = hit + 1;
37 + from = hit + 1;
38 + depth++;
39 + }
40 + return found;
41 +}
42 +
43 +export function StepSave({ s }: { s: DevtoolShared }) {
44 + const { draft, update, validation, setValidation, saveResult, setSaveResult } = s;
45 + const [busy, setBusy] = useState<"validate" | "save" | null>(null);
46 + const [error, setError] = useState<string | null>(null);
47 + const [view, setView] = useState<"edit" | "highlight">("edit");
48 + const toast = useToast();
49 + const meta = draft.meta;
50 + const ta = useRef<HTMLTextAreaElement>(null);
51 +
52 + // first entry: generate the template from the steps when the editor is empty
53 + useEffect(() => {
54 + if (!draft.yaml.trim()) update((d) => ({ ...d, yaml: buildYaml(d), yamlDirty: false }));
55 + }, [draft.yaml, update]);
56 +
57 + const setMeta = (patch: Partial<typeof meta>) => update({ meta: { ...meta, ...patch } });
58 + const regenerate = () => {
59 + if (draft.yamlDirty && !window.confirm("Replace the hand-edited YAML with a template regenerated from steps 1–3?")) return;
60 + update((d) => ({ ...d, yaml: buildYaml(d), yamlDirty: false }));
61 + setValidation(null);
62 + setSaveResult(null);
63 + };
64 +
65 + const validate = async (): Promise<ValidateConfigResult | null> => {
66 + setBusy("validate");
67 + setError(null);
68 + const r = await adminFetch<ValidateConfigResult>("devtool/validate-config", { method: "POST", body: { yaml: draft.yaml } });
69 + setBusy(null);
70 + if (!r.ok) {
71 + setError(r.error);
72 + return null;
73 + }
74 + setValidation(r.data);
75 + if (r.data.ok) {
76 + const cfg = r.data.config;
77 + if (typeof cfg.id === "string" && cfg.id !== meta.id) setMeta({ id: cfg.id });
78 + toast({ tone: r.data.warnings.length ? "warn" : "ok", title: "YAML is valid", description: r.data.warnings.length ? `${r.data.warnings.length} warning(s)` : `connector ${String(cfg.id)} · parser ${String(cfg.parserVersion)}` });
79 + } else toast({ tone: "danger", title: `${r.data.errors.length} schema error(s)` });
80 + return r.data;
81 + };
82 +
83 + const save = async () => {
84 + const v = await validate();
85 + if (!v || !v.ok) return;
86 + const id = String(v.config.id);
87 + if (id.startsWith("_") && !id.startsWith("_devtool")) return setError("ids starting with _ are reserved (except _devtool-* test ids)");
88 + if (!window.confirm(`Write config/connectors/${id}.yaml (previous file backed up) and upsert the connectors row?`)) return;
89 + setBusy("save");
90 + const r = await adminFetch<SaveConfigResult>("devtool/save-config", { method: "POST", body: { id, yaml: draft.yaml } });
91 + setBusy(null);
92 + if (!r.ok) {
93 + setError(r.error);
94 + toast({ tone: "danger", title: "Save failed", description: r.error });
95 + return;
96 + }
97 + setSaveResult(r.data);
98 + setMeta({ id });
99 + toast({ tone: "ok", title: `Saved ${id}.yaml`, description: `parser ${r.data.parserVersion}${r.data.backup ? " · backup kept" : ""}${r.data.enabled ? "" : " · enabled: false"}` });
100 + };
101 +
102 + const errors = validation && !validation.ok ? validation.errors : [];
103 + const errorLines = useMemo(() => new Set(errors.map((e) => lineForPath(draft.yaml, e.path)).filter((n): n is number => n != null)), [errors, draft.yaml]);
104 + const jumpTo = (line: number | null) => {
105 + if (!line || !ta.current) return;
106 + setView("edit");
107 + const el = ta.current;
108 + const idx = draft.yaml.split("\n").slice(0, line - 1).join("\n").length + (line > 1 ? 1 : 0);
109 + el.focus();
110 + el.setSelectionRange(idx, idx + (draft.yaml.split("\n")[line - 1]?.length ?? 0));
111 + const lh = 18.2;
112 + el.scrollTop = Math.max(0, (line - 4) * lh);
113 + };
114 +
115 + return (
116 + <div className="space-y-3">
117 + <Panel title="4 · Connector metadata" description="Used by the template generator; the YAML below is what gets saved. License and attribution are recorded on the source (legal crawling rule).">
118 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
119 + <Field label="Connector id" hint="lowercase, digits, dashes — file name">
120 + <Input value={meta.id} onChange={(e) => setMeta({ id: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-") })} mono placeholder="digitalrealty" />
121 + </Field>
122 + <Field label="Source name">
123 + <Input value={meta.name} onChange={(e) => setMeta({ name: e.target.value })} placeholder="Digital Realty" />
124 + </Field>
125 + <Field label="Domain">
126 + <Input value={meta.domain} onChange={(e) => setMeta({ domain: e.target.value })} mono placeholder="www.digitalrealty.com" />
127 + </Field>
128 + <Field label="Source kind">
129 + <Select value={meta.kind} onChange={(e) => setMeta({ kind: e.target.value })}>
130 + {SOURCE_KINDS_LIST.map((k) => (
131 + <option key={k} value={k}>
132 + {k}
133 + </option>
134 + ))}
135 + </Select>
136 + </Field>
137 + <Field label="License" className="sm:col-span-2">
138 + <Input value={meta.license} onChange={(e) => setMeta({ license: e.target.value })} placeholder="Publicly accessible operator pages; factual data only; © …" />
139 + </Field>
140 + <Field label="Attribution">
141 + <Input value={meta.attribution} onChange={(e) => setMeta({ attribution: e.target.value })} placeholder="Digital Realty" />
142 + </Field>
143 + <Field label="Homepage">
144 + <Input value={meta.homepage} onChange={(e) => setMeta({ homepage: e.target.value })} mono placeholder="https://…" />
145 + </Field>
146 + <Field label="Schedule group">
147 + <Input value={meta.scheduleGroup} onChange={(e) => setMeta({ scheduleGroup: e.target.value })} mono placeholder="facility_pages" />
148 + </Field>
149 + <Field label="Interval">
150 + <Input value={meta.scheduleInterval} onChange={(e) => setMeta({ scheduleInterval: e.target.value })} mono placeholder="weekly" list="dci-intervals" />
151 + </Field>
152 + <Field label="Requests / min">
153 + <Input value={meta.rpm} onChange={(e) => setMeta({ rpm: e.target.value })} mono inputMode="numeric" />
154 + </Field>
155 + <Field label="Max credits / run">
156 + <Input value={meta.maxCreditsPerRun} onChange={(e) => setMeta({ maxCreditsPerRun: e.target.value })} mono inputMode="numeric" />
157 + </Field>
158 + </div>
159 + <datalist id="dci-intervals">
160 + {["hourly", "6h", "12h", "daily", "weekly", "biweekly", "monthly", "quarterly", "never"].map((v) => (
161 + <option key={v} value={v} />
162 + ))}
163 + </datalist>
164 + </Panel>
165 +
166 + <Panel
167 + title="Connector YAML"
168 + description={draft.yamlDirty ? "Edited by hand — regenerate to rebuild from steps 1–3 (your edits would be lost)." : "Generated from steps 1–3. Edit freely; validate before saving."}
169 + padded={false}
170 + aside={
171 + <div className="flex flex-wrap items-center gap-1.5">
172 + <Button size="xs" variant={view === "edit" ? "primary" : "ghost"} onClick={() => setView("edit")}>
173 + Edit
174 + </Button>
175 + <Button size="xs" variant={view === "highlight" ? "primary" : "ghost"} onClick={() => setView("highlight")}>
176 + Highlight
177 + </Button>
178 + <Button size="xs" variant="ghost" onClick={regenerate}>
179 + Regenerate from steps
180 + </Button>
181 + <Button size="sm" variant="secondary" onClick={() => void validate()} disabled={busy !== null}>
182 + {busy === "validate" ? <Spinner /> : null} Validate
183 + </Button>
184 + <Button size="sm" variant="accent" onClick={() => void save()} disabled={busy !== null || !draft.yaml.trim()}>
185 + {busy === "save" ? <Spinner className="text-white" /> : null} Save connector
186 + </Button>
187 + </div>
188 + }
189 + >
190 + {view === "edit" ? (
191 + <Textarea ref={ta} rows={28} mono value={draft.yaml} onChange={(e) => { update({ yaml: e.target.value, yamlDirty: true }); setSaveResult(null); }} className="rounded-none border-0 leading-[1.4] focus:border-0" spellCheck={false} />
192 + ) : (
193 + <Code text={draft.yaml} lang="yaml" maxHeight={620} />
194 + )}
195 + <ErrorNote error={error} className="m-3" />
196 + {validation && (
197 + <div className="border-t border-hair px-3 py-2">
198 + {validation.ok ? (
199 + <div className="space-y-1 text-[12.5px]">
200 + <div className="flex flex-wrap items-center gap-2">
201 + <Badge tone="ok" size="sm">
202 + valid
203 + </Badge>
204 + <Mono>
205 + {String(validation.config.id)} · {String(validation.config.kind)} · parser {String(validation.config.parserVersion)} · {Object.keys((validation.config.extractors as object) ?? {}).length} extractor(s) · enabled {String(validation.config.enabled)}
206 + </Mono>
207 + </div>
208 + {validation.warnings.map((w, i) => (
209 + <div key={i} className="text-warn">
210 + ⚠ {w}
211 + </div>
212 + ))}
213 + </div>
214 + ) : (
215 + <ul className="space-y-1 text-[12.5px]">
216 + {validation.errors.map((e, i) => {
217 + const line = lineForPath(draft.yaml, e.path);
218 + return (
219 + <li key={i} className="flex flex-wrap items-center gap-2">
220 + <Badge tone="danger" size="xs">
221 + error
222 + </Badge>
223 + <Mono className="text-ink">{e.path || "(root)"}</Mono>
224 + <span className="text-ink-2">{e.message}</span>
225 + {line && (
226 + <button type="button" className="text-[11.5px] text-accent hover:underline" onClick={() => jumpTo(line)}>
227 + line {line}
228 + </button>
229 + )}
230 + </li>
231 + );
232 + })}
233 + {errorLines.size > 0 && <li className="text-[11.5px] text-ink-4">Lines referenced: {[...errorLines].sort((a, b) => a - b).join(", ")}</li>}
234 + </ul>
235 + )}
236 + </div>
237 + )}
238 + {saveResult && (
239 + <div className={cn("border-t border-hair px-3 py-2 text-[12.5px]", "bg-ok/5")}>
240 + Saved <Mono className="text-ink">{saveResult.path}</Mono>
241 + {saveResult.backup && (
242 + <>
243 + {" "}
244 + · backup <Mono>{saveResult.backup}</Mono>
245 + </>
246 + )}{" "}
247 + · parser <Mono>{saveResult.parserVersion}</Mono> · enabled <Mono>{String(saveResult.enabled)}</Mono>. The connectors row was upserted; the worker picks the file up on its next config sync.
248 + </div>
249 + )}
250 + </Panel>
251 + </div>
252 + );
253 +}
added apps/web/src/components/admin/document-inspect.tsx +571 −0
@@ -0,0 +1,571 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useCallback, useEffect, useMemo, useState } from "react";
5 +import { Badge, Button, ConfidenceBadge, EmptyState, RelativeTime, SearchInput, SkeletonBlock, SkeletonRows, Tab, TabList, TabPanel, Tabs, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
6 +import { cn } from "@/components/ui/cn";
7 +import { DASH, fmtDateTimeUtc, fmtInt, fmtValue } from "@/lib/format";
8 +import { ENTITY_TYPE_LABEL } from "@/lib/labels";
9 +import { entityHref } from "@/lib/routes";
10 +import { adminFetchText, adminProxyUrl, type AdminDocumentDetail, type AdminDocVersion } from "@/lib/admin-api";
11 +import { Code, countMatches, DiffView, ErrorNote, ExtLink, fmtBytes, JsonView, KV, LevelBadge, Mono, Panel, RefreshButton, Spinner, Stat } from "./primitives";
12 +import { AdminPageHeader } from "./shell";
13 +import { useToast } from "./toast";
14 +import { useAdminMutation, useAdminQuery } from "./use-admin-query";
15 +
16 +const HTML_PREVIEW_CAP = 400_000;
17 +
18 +function isHtml(ct: string | null | undefined, body: string): boolean {
19 + return /html|xml/i.test(ct ?? "") || /<html|<!doctype html/i.test(body.slice(0, 2000));
20 +}
21 +
22 +/** Text projection of an HTML body (browser DOMParser; scripts/styles dropped, block elements → newlines). */
23 +export function htmlToTextClient(html: string): string {
24 + try {
25 + const doc = new DOMParser().parseFromString(html, "text/html");
26 + doc.querySelectorAll("script,style,noscript,template,svg").forEach((n) => n.remove());
27 + const blocks = new Set(["P", "DIV", "LI", "TR", "H1", "H2", "H3", "H4", "H5", "H6", "BR", "SECTION", "ARTICLE", "HEADER", "FOOTER", "TD", "TH", "UL", "OL", "TABLE", "BLOCKQUOTE", "PRE", "DD", "DT", "FIGCAPTION", "NAV", "ASIDE", "MAIN"]);
28 + const out: string[] = [];
29 + const walk = (node: Node) => {
30 + if (node.nodeType === Node.TEXT_NODE) out.push(node.textContent ?? "");
31 + else if (node.nodeType === Node.ELEMENT_NODE) {
32 + const el = node as Element;
33 + const block = blocks.has(el.tagName);
34 + if (block) out.push("\n");
35 + el.childNodes.forEach(walk);
36 + if (block) out.push("\n");
37 + }
38 + };
39 + walk(doc.body ?? doc);
40 + return out
41 + .join("")
42 + .split("\n")
43 + .map((l) => l.replace(/\s+/g, " ").trim())
44 + .filter((l, i, arr) => l || (i > 0 && arr[i - 1]))
45 + .join("\n")
46 + .trim();
47 + } catch {
48 + return html;
49 + }
50 +}
51 +
52 +/** Injects <base> so relative assets resolve; scripts are stripped and the frame is sandboxed. */
53 +export function srcDocFor(html: string, baseUrl: string | null): string {
54 + const clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "").replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
55 + const base = baseUrl ? `<base href="${baseUrl.replace(/"/g, "&quot;")}" target="_blank">` : "";
56 + const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data: blob:; style-src * 'unsafe-inline'; font-src * data:; media-src *;">`;
57 + if (/<head[^>]*>/i.test(clean)) return clean.replace(/<head[^>]*>/i, (m) => `${m}${csp}${base}`);
58 + return `<!doctype html><html><head>${csp}${base}</head><body>${clean}</body></html>`;
59 +}
60 +
61 +interface RawBody {
62 + loading: boolean;
63 + text: string;
64 + contentType: string | null;
65 + truncated: boolean;
66 + error: string | null;
67 +}
68 +
69 +/** Raw body for (document, version) through the proxy. `loading` is derived from the result key (no setState in the effect body). */
70 +function useRawBody(docId: string, version: string | null, enabled: boolean): RawBody {
71 + const key = `${docId}|${version ?? ""}`;
72 + const [slot, setSlot] = useState<{ key: string; text: string; contentType: string | null; truncated: boolean; error: string | null }>({ key: "", text: "", contentType: null, truncated: false, error: null });
73 + useEffect(() => {
74 + if (!enabled) return;
75 + let alive = true;
76 + void adminFetchText(`documents/${encodeURIComponent(docId)}/raw`, version ? { version } : undefined).then((r) => {
77 + if (!alive) return;
78 + setSlot({ key, text: r.ok ? r.text : "", contentType: r.contentType, truncated: r.truncated, error: r.ok ? null : (r.error ?? "failed") });
79 + });
80 + return () => {
81 + alive = false;
82 + };
83 + }, [docId, version, enabled, key]);
84 + const current = slot.key === key;
85 + return { loading: enabled && !current, text: current ? slot.text : "", contentType: current ? slot.contentType : null, truncated: current && slot.truncated, error: current ? slot.error : null };
86 +}
87 +
88 +export function DocumentInspectView({ id }: { id: string }) {
89 + const q = useAdminQuery<AdminDocumentDetail>(`documents/${encodeURIComponent(id)}`, { timeoutMs: 30_000 });
90 + const { busy, run } = useAdminMutation();
91 + const toast = useToast();
92 + const d = q.data?.document;
93 + const versions = useMemo(() => q.data?.versions ?? [], [q.data]);
94 +
95 + // A defaults to the newest version, B to the previous one, until the user picks
96 + const [pickA, setPickA] = useState<string | null>(null);
97 + const [pickB, setPickB] = useState<string | null>(null);
98 + const selA = pickA ?? versions[0]?.id ?? null;
99 + const selB = pickB ?? versions[1]?.id ?? null;
100 + const [diffMode, setDiffMode] = useState<"text" | "html">("text");
101 + const rawA = useRawBody(id, selA, Boolean(selA && selB));
102 + const rawB = useRawBody(id, selB, Boolean(selA && selB));
103 + const newer = useMemo(() => versions.find((v) => v.id === selA) ?? null, [versions, selA]);
104 + const older = useMemo(() => versions.find((v) => v.id === selB) ?? null, [versions, selB]);
105 + const [aText, bText] = useMemo(() => {
106 + const proj = (raw: typeof rawA) => (raw.text ? (diffMode === "text" && isHtml(raw.contentType, raw.text) ? htmlToTextClient(raw.text) : raw.text) : "");
107 + return [proj(rawA), proj(rawB)];
108 + }, [rawA, rawB, diffMode]);
109 + // order the diff old → new regardless of which radio was picked
110 + const [oldText, newText, oldLabel, newLabel] = useMemo(() => {
111 + const ta = newer?.fetchedAt ? new Date(newer.fetchedAt).getTime() : 0;
112 + const tb = older?.fetchedAt ? new Date(older.fetchedAt).getTime() : 0;
113 + return ta >= tb ? [bText, aText, older?.fetchedAt ?? "", newer?.fetchedAt ?? ""] : [aText, bText, newer?.fetchedAt ?? "", older?.fetchedAt ?? ""];
114 + }, [aText, bText, newer, older]);
115 + const changesVersion = useMemo(() => {
116 + if (!newer && !older) return null;
117 + const ta = newer?.fetchedAt ? new Date(newer.fetchedAt).getTime() : 0;
118 + const tb = older?.fetchedAt ? new Date(older.fetchedAt).getTime() : 0;
119 + return ta >= tb ? newer : older;
120 + }, [newer, older]);
121 +
122 + // raw viewer
123 + const [viewVersion, setViewVersion] = useState<string | null>(null);
124 + const [viewTab, setViewTab] = useState("rendered");
125 + const [search, setSearch] = useState("");
126 + const raw = useRawBody(id, viewVersion, Boolean(d));
127 + const rawIsHtml = isHtml(raw.contentType ?? d?.contentType, raw.text);
128 + const rawIsPdf = /pdf/i.test(raw.contentType ?? d?.contentType ?? "");
129 + const rawIsJson = /json/i.test(raw.contentType ?? d?.contentType ?? "");
130 + const textProjection = useMemo(() => (rawIsHtml ? htmlToTextClient(raw.text) : raw.text), [raw.text, rawIsHtml]);
131 + const srcDoc = useMemo(() => (rawIsHtml ? srcDocFor(raw.text.slice(0, 2_000_000), d?.canonicalUrl ?? d?.url ?? null) : ""), [raw.text, rawIsHtml, d]);
132 + const jsonValue = useMemo(() => {
133 + if (!rawIsJson) return null;
134 + try {
135 + return JSON.parse(raw.text) as unknown;
136 + } catch {
137 + return null;
138 + }
139 + }, [raw.text, rawIsJson]);
140 +
141 + const act = useCallback(
142 + async (action: "reprocess" | "refetch" | "quarantine", body?: unknown) => {
143 + const r = await run<{ enqueued?: boolean; job?: { id?: string }; quarantined?: boolean }>(action, `documents/${encodeURIComponent(id)}/${action}`, { body: body ?? {} });
144 + if (r.ok) {
145 + if (action === "quarantine") {
146 + q.mutate((prev) => (prev ? { ...prev, document: { ...prev.document, quarantined: Boolean(r.data.quarantined) } } : prev));
147 + toast({ tone: "ok", title: r.data.quarantined ? "Document quarantined" : "Quarantine cleared" });
148 + } else toast({ tone: "ok", title: action === "reprocess" ? "Reprocess enqueued" : "Forced refetch enqueued", description: r.data.job?.id ? `job ${r.data.job.id}` : undefined });
149 + } else toast({ tone: "danger", title: `${action} failed`, description: r.error });
150 + },
151 + [id, run, q, toast],
152 + );
153 +
154 + if (q.error && !q.data) {
155 + return (
156 + <div>
157 + <AdminPageHeader eyebrow="Document" title={<span className="font-mono">{id}</span>} />
158 + <ErrorNote error={q.status === 404 ? "Document not found" : q.error} onRetry={() => void q.refresh()} />
159 + </div>
160 + );
161 + }
162 +
163 + return (
164 + <div>
165 + <AdminPageHeader
166 + eyebrow={
167 + <span className="inline-flex flex-wrap items-center gap-1.5">
168 + <Link href="/admin/documents" className="hover:text-ink">
169 + Documents
170 + </Link>
171 + {d && (
172 + <>
173 + <span>/</span>
174 + <Link href={`/admin/connectors/${encodeURIComponent(d.connectorId)}`} className="font-mono hover:text-ink">
175 + {d.connectorId}
176 + </Link>
177 + </>
178 + )}
179 + </span>
180 + }
181 + title={d ? <span className="break-all font-mono text-[15px] sm:text-[17px]">{d.url.replace(/^https?:\/\//, "")}</span> : <span className="font-mono">{id}</span>}
182 + description={
183 + d ? (
184 + <span className="inline-flex flex-wrap items-center gap-1.5">
185 + <ExtLink href={d.url}>open</ExtLink>
186 + <Badge size="xs" mono tone="outline">
187 + {d.pageType}
188 + </Badge>
189 + <LevelBadge level={d.fetchLevel} />
190 + {d.statusCode != null && (
191 + <Badge size="xs" mono tone={d.statusCode >= 400 ? "danger" : "neutral"}>
192 + HTTP {d.statusCode}
193 + </Badge>
194 + )}
195 + {d.quarantined && (
196 + <Badge size="xs" tone="warn">
197 + quarantined
198 + </Badge>
199 + )}
200 + {d.extractOk === false && (
201 + <Badge size="xs" tone="danger">
202 + extraction failed
203 + </Badge>
204 + )}
205 + <Mono className="text-ink-4">{d.id}</Mono>
206 + </span>
207 + ) : undefined
208 + }
209 + aside={
210 + d ? (
211 + <>
212 + <RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />
213 + <Button size="sm" onClick={() => void act("reprocess")} disabled={busy !== null}>
214 + Reprocess
215 + </Button>
216 + <Button size="sm" onClick={() => void act("refetch")} disabled={busy !== null}>
217 + Refetch (force)
218 + </Button>
219 + <Button size="sm" variant={d.quarantined ? "secondary" : "danger"} onClick={() => void act("quarantine", { quarantined: !d.quarantined })} disabled={busy !== null}>
220 + {d.quarantined ? "Unquarantine" : "Quarantine"}
221 + </Button>
222 + </>
223 + ) : undefined
224 + }
225 + />
226 + {d?.error && <ErrorNote error={`Last error (×${d.errorCount}): ${d.error}`} className="mb-3" />}
227 +
228 + {!d ? (
229 + <SkeletonBlock className="mb-3 h-16" />
230 + ) : (
231 + <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
232 + <Stat label="Versions" value={fmtInt(d.versionCount ?? versions.length)} />
233 + <Stat label="Fetches" value={fmtInt(d.fetchCount)} note={`${fmtInt(d.changeCount)} changed`} />
234 + <Stat label="Extracted" value={d.extractOk == null ? DASH : d.extractOk ? fmtInt(d.extractCount) : "failed"} tone={d.extractOk === false ? "danger" : undefined} note={d.extractorVersion ? `extractor ${d.extractorVersion}` : undefined} />
235 + <Stat label="Size" value={fmtBytes(d.sizeBytes)} note={d.contentType ?? undefined} />
236 + <Stat label="Last fetched" value={<RelativeTime iso={d.lastFetched} />} note={d.lastChanged ? <span>changed <RelativeTime iso={d.lastChanged} /></span> : undefined} />
237 + <Stat label="Next check" value={d.nextCheck ? <RelativeTime iso={d.nextCheck} /> : d.quarantined ? "quarantined" : DASH} note={d.changeFrequencyScore != null ? `change freq ${d.changeFrequencyScore.toFixed(2)}` : undefined} />
238 + </div>
239 + )}
240 +
241 + <div className="grid gap-3 lg:grid-cols-3">
242 + <Panel title="Metadata" className="lg:col-span-1">
243 + {!d ? (
244 + <SkeletonRows rows={8} />
245 + ) : (
246 + <KV
247 + items={[
248 + { k: "Canonical URL", v: <ExtLink href={d.canonicalUrl} /> },
249 + { k: "Title", v: d.title, title: d.title ?? undefined },
250 + { k: "Classifier", v: d.classifier ? <Mono title={d.classifier}>{d.classifier}</Mono> : DASH },
251 + { k: "Priority", v: fmtInt(d.priority) },
252 + { k: "Content hash", v: d.contentHash ? <Mono title={d.contentHash}>{d.contentHash.slice(0, 16)}…</Mono> : DASH },
253 + { k: "ETag", v: d.etag ? <Mono>{d.etag}</Mono> : DASH },
254 + { k: "Last-Modified", v: d.lastModified },
255 + { k: "Storage key", v: d.storageKey ? <Mono title={d.storageKey}>{d.storageKey}</Mono> : DASH },
256 + { k: "Source", v: <Mono>{d.sourceId}</Mono> },
257 + { k: "Discovered from", v: d.discoveredFrom ? <Mono title={d.discoveredFrom}>{d.discoveredFrom}</Mono> : DASH },
258 + { k: "First seen", v: fmtDateTimeUtc(d.firstSeen) },
259 + { k: "Last checked", v: fmtDateTimeUtc(d.lastChecked) },
260 + { k: "Updated", v: fmtDateTimeUtc(d.updatedAt) },
261 + ]}
262 + />
263 + )}
264 + </Panel>
265 +
266 + <Panel title="Versions" description="Pick two versions to diff (A = newer by default)" className="lg:col-span-2" padded={false}>
267 + {!q.data ? (
268 + <div className="p-3">
269 + <SkeletonRows rows={3} />
270 + </div>
271 + ) : versions.length === 0 ? (
272 + <EmptyState compact title="No stored versions" description="The body has not been archived yet." />
273 + ) : (
274 + <div className="max-h-[320px] overflow-auto">
275 + <Table responsive={false} className="text-[12.5px]">
276 + <THead>
277 + <Tr>
278 + <Th className="pl-3">A</Th>
279 + <Th>B</Th>
280 + <Th>Fetched</Th>
281 + <Th>Lvl</Th>
282 + <Th numeric>HTTP</Th>
283 + <Th numeric>Size</Th>
284 + <Th numeric>Signif.</Th>
285 + <Th numeric>Changes</Th>
286 + <Th className="pr-3">Hash</Th>
287 + </Tr>
288 + </THead>
289 + <TBody>
290 + {versions.map((v) => (
291 + <Tr key={v.id} className={cn((v.id === selA || v.id === selB) && "bg-surface-2/60")}>
292 + <Td className="pl-3">
293 + <input type="radio" name="verA" aria-label="Select as A" checked={selA === v.id} onChange={() => setPickA(v.id)} className="size-4 accent-[var(--accent)]" />
294 + </Td>
295 + <Td>
296 + <input type="radio" name="verB" aria-label="Select as B" checked={selB === v.id} onChange={() => setPickB(v.id)} className="size-4 accent-[var(--ink)]" />
297 + </Td>
298 + <Td>
299 + <span title={fmtDateTimeUtc(v.fetchedAt)}>{fmtDateTimeUtc(v.fetchedAt)}</span>
300 + </Td>
301 + <Td>
302 + <LevelBadge level={v.fetchLevel} />
303 + </Td>
304 + <Td numeric>{v.statusCode ?? DASH}</Td>
305 + <Td numeric>{fmtBytes(v.sizeBytes)}</Td>
306 + <Td numeric className={cn(v.significance >= 60 && "text-accent")}>
307 + {v.significance}
308 + </Td>
309 + <Td numeric>{fmtInt(v.detectedChanges?.length ?? 0)}</Td>
310 + <Td className="pr-3">
311 + <Mono title={v.contentHash}>{v.contentHash.slice(0, 10)}</Mono>
312 + <button type="button" className="ml-2 text-[11px] text-ink-3 hover:text-accent" onClick={() => { setViewVersion(v.id); setViewTab("source"); }}>
313 + view
314 + </button>
315 + </Td>
316 + </Tr>
317 + ))}
318 + </TBody>
319 + </Table>
320 + </div>
321 + )}
322 + </Panel>
323 + </div>
324 +
325 + {selA && selB && selA !== selB && (
326 + <Panel
327 + title="Diff"
328 + className="mt-3"
329 + description={`${newLabel ? fmtDateTimeUtc(oldLabel) : ""} → ${newLabel ? fmtDateTimeUtc(newLabel) : ""}`}
330 + aside={
331 + <div className="flex items-center gap-1">
332 + <Button size="xs" variant={diffMode === "text" ? "primary" : "ghost"} onClick={() => setDiffMode("text")}>
333 + Text
334 + </Button>
335 + <Button size="xs" variant={diffMode === "html" ? "primary" : "ghost"} onClick={() => setDiffMode("html")}>
336 + Raw
337 + </Button>
338 + </div>
339 + }
340 + >
341 + {rawA.loading || rawB.loading ? (
342 + <div className="flex items-center gap-2 py-6 text-[12.5px] text-ink-3">
343 + <Spinner /> Loading both bodies from storage…
344 + </div>
345 + ) : rawA.error || rawB.error ? (
346 + <ErrorNote error={rawA.error ?? rawB.error} />
347 + ) : (
348 + <div className="space-y-4">
349 + <DetectedChanges version={changesVersion} />
350 + <DiffView before={oldText} after={newText} context={3} />
351 + </div>
352 + )}
353 + </Panel>
354 + )}
355 +
356 + <Panel
357 + title="Body"
358 + className="mt-3"
359 + description={raw.contentType ? <span>{raw.contentType}{raw.truncated ? " · truncated at 5 MB" : ""}{viewVersion ? ` · version ${viewVersion}` : " · current"}</span> : undefined}
360 + aside={
361 + <div className="flex flex-wrap items-center gap-2">
362 + {versions.length > 0 && (
363 + <select value={viewVersion ?? ""} onChange={(e) => setViewVersion(e.target.value || null)} className="h-8 rounded-sm border border-hair-2 bg-surface px-2 font-mono text-[12px] text-ink" aria-label="Body version">
364 + <option value="">current</option>
365 + {versions.map((v) => (
366 + <option key={v.id} value={v.id}>
367 + {fmtDateTimeUtc(v.fetchedAt)} · {v.contentHash.slice(0, 8)}
368 + </option>
369 + ))}
370 + </select>
371 + )}
372 + <a href={adminProxyUrl(`documents/${encodeURIComponent(id)}/raw`, viewVersion ? { version: viewVersion } : undefined)} target="_blank" rel="noopener" className="text-[12px] text-ink-2 underline-offset-2 hover:text-accent hover:underline">
373 + download
374 + </a>
375 + </div>
376 + }
377 + padded={false}
378 + >
379 + {raw.loading ? (
380 + <div className="flex items-center gap-2 p-4 text-[12.5px] text-ink-3">
381 + <Spinner /> Loading body…
382 + </div>
383 + ) : raw.error ? (
384 + <div className="p-3">
385 + <ErrorNote error={`Raw body unavailable: ${raw.error}`} />
386 + </div>
387 + ) : rawIsPdf ? (
388 + <iframe title="PDF" src={adminProxyUrl(`documents/${encodeURIComponent(id)}/raw`, viewVersion ? { version: viewVersion } : undefined)} className="h-[70vh] w-full bg-white" />
389 + ) : (
390 + <Tabs value={viewTab} onValueChange={setViewTab}>
391 + <div className="flex flex-col gap-2 border-b border-hair px-3 pt-1 sm:flex-row sm:items-center">
392 + <TabList label="Body views" className="border-b-0">
393 + {rawIsHtml && <Tab value="rendered">Rendered</Tab>}
394 + <Tab value="source">Source</Tab>
395 + <Tab value="text">Text</Tab>
396 + {jsonValue !== null && <Tab value="json">JSON</Tab>}
397 + </TabList>
398 + {(viewTab === "source" || viewTab === "text") && (
399 + <div className="flex items-center gap-2 pb-2 sm:ml-auto sm:pb-0">
400 + <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Find in body" wrapperClassName="w-[220px]" />
401 + {search && <span className="figure text-[11.5px] text-ink-3">{fmtInt(countMatches(viewTab === "source" ? raw.text : textProjection, search))} hits</span>}
402 + </div>
403 + )}
404 + </div>
405 + {rawIsHtml && (
406 + <TabPanel value="rendered">
407 + <iframe title="Rendered document (sandboxed)" sandbox="" srcDoc={srcDoc} className="h-[70vh] w-full bg-white" />
408 + </TabPanel>
409 + )}
410 + <TabPanel value="source" className="p-2">
411 + <Code text={raw.text.length > HTML_PREVIEW_CAP ? `${raw.text.slice(0, HTML_PREVIEW_CAP)}\n… (${fmtBytes(raw.text.length)} total, showing first ${fmtBytes(HTML_PREVIEW_CAP)})` : raw.text} lang={rawIsHtml ? "html" : "text"} search={search} maxHeight={560} wrap />
412 + </TabPanel>
413 + <TabPanel value="text" className="p-2">
414 + <Code text={textProjection.slice(0, HTML_PREVIEW_CAP)} lang="text" search={search} maxHeight={560} wrap />
415 + </TabPanel>
416 + {jsonValue !== null && (
417 + <TabPanel value="json" className="p-2">
418 + <JsonView value={jsonValue} maxHeight={560} />
419 + </TabPanel>
420 + )}
421 + </Tabs>
422 + )}
423 + </Panel>
424 +
425 + <div className="mt-3 grid gap-3 lg:grid-cols-3">
426 + <Panel title="Extracted entities" description="entity_refs on the document" padded={false}>
427 + {!q.data ? (
428 + <div className="p-3">
429 + <SkeletonRows rows={2} />
430 + </div>
431 + ) : q.data.entities.length === 0 ? (
432 + <EmptyState compact title="No entities linked" />
433 + ) : (
434 + <ul className="divide-y divide-hair">
435 + {q.data.entities.map((e) => (
436 + <li key={`${e.type}:${e.id}`} className="flex items-center justify-between gap-2 px-3 py-2 text-[12.5px]">
437 + <div className="min-w-0">
438 + <Link href={entityHref(e.type, e.slug, e.name ?? undefined)} className="block truncate font-medium text-ink hover:text-accent hover:underline">
439 + {e.name ?? e.id}
440 + </Link>
441 + <div className="text-[11.5px] text-ink-3">
442 + {ENTITY_TYPE_LABEL[e.type] ?? e.type} · <Mono>{e.id}</Mono>
443 + </div>
444 + </div>
445 + {e.type === "facility" && (
446 + <Button size="xs" variant="ghost" href={`/admin/facilities/${encodeURIComponent(e.id)}/edit`}>
447 + Curate
448 + </Button>
449 + )}
450 + </li>
451 + ))}
452 + </ul>
453 + )}
454 + </Panel>
455 + <Panel title="Provenance rows produced" description="Fields whose value was observed on this document" className="lg:col-span-2" padded={false}>
456 + {!q.data ? (
457 + <div className="p-3">
458 + <SkeletonRows rows={3} />
459 + </div>
460 + ) : q.data.provenance.length === 0 ? (
461 + <EmptyState compact title="No provenance rows reference this document" />
462 + ) : (
463 + <div className="max-h-[420px] overflow-auto">
464 + <Table responsive={false} className="text-[12.5px]">
465 + <THead>
466 + <Tr>
467 + <Th className="pl-3">Entity</Th>
468 + <Th>Field</Th>
469 + <Th>Value</Th>
470 + <Th>Confidence</Th>
471 + <Th>Method</Th>
472 + <Th>Observed</Th>
473 + <Th className="pr-3">Current</Th>
474 + </Tr>
475 + </THead>
476 + <TBody>
477 + {q.data.provenance.map((p, i) => (
478 + <Tr key={i}>
479 + <Td className="pl-3">
480 + <Mono title={p.entityId}>
481 + {p.entityType}:{p.entityId.slice(0, 14)}
482 + </Mono>
483 + </Td>
484 + <Td>
485 + <Mono className="text-ink">{p.field}</Mono>
486 + </Td>
487 + <Td className="max-w-[260px] truncate" title={JSON.stringify(p.value)}>
488 + {fmtValue(p.value)}
489 + {p.isEstimate && <span className="ml-1 text-warn">≈</span>}
490 + </Td>
491 + <Td>
492 + <ConfidenceBadge level={p.confidence} size="xs" />
493 + </Td>
494 + <Td muted>{p.method ?? DASH}</Td>
495 + <Td muted>
496 + <RelativeTime iso={p.lastObserved} />
497 + </Td>
498 + <Td className="pr-3">{p.isCurrent ? <span className="text-ok">yes</span> : <span className="text-ink-4">no</span>}</Td>
499 + </Tr>
500 + ))}
501 + </TBody>
502 + </Table>
503 + </div>
504 + )}
505 + </Panel>
506 + </div>
507 + </div>
508 + );
509 +}
510 +
511 +function DetectedChanges({ version }: { version: AdminDocVersion | null }) {
512 + if (!version) return null;
513 + const changes = version.detectedChanges ?? [];
514 + const s = version.diffSummary;
515 + return (
516 + <div className="space-y-2">
517 + <div className="flex flex-wrap items-center gap-3 text-[12.5px] text-ink-3">
518 + <span>
519 + Stored diff summary for version <Mono>{version.id}</Mono>:
520 + </span>
521 + {s ? (
522 + <span className="figure">
523 + +{fmtInt(s.addedCount ?? s.added ?? 0)} −{fmtInt(s.removedCount ?? s.removed ?? 0)} · ratio {typeof s.ratio === "number" ? s.ratio.toFixed(3) : DASH}
524 + </span>
525 + ) : (
526 + <span>none (first version)</span>
527 + )}
528 + <span>
529 + significance <span className={cn("figure", version.significance >= 60 ? "text-accent" : "text-ink-2")}>{version.significance}</span>
530 + </span>
531 + </div>
532 + {changes.length > 0 && (
533 + <Table responsive={false} className="text-[12.5px]">
534 + <THead>
535 + <Tr>
536 + <Th>Field</Th>
537 + <Th>Old</Th>
538 + <Th>New</Th>
539 + <Th>Event</Th>
540 + <Th numeric>Significance</Th>
541 + </Tr>
542 + </THead>
543 + <TBody>
544 + {changes.map((c, i) => (
545 + <Tr key={i}>
546 + <Td>
547 + <Mono className="text-ink">{c.field}</Mono>
548 + {c.description && <div className="text-[11.5px] text-ink-3">{c.description}</div>}
549 + </Td>
550 + <Td className="max-w-[220px] truncate text-danger" title={JSON.stringify(c.oldValue)}>
551 + {fmtValue(c.oldValue)}
552 + </Td>
553 + <Td className="max-w-[220px] truncate text-st-operational" title={JSON.stringify(c.newValue)}>
554 + {fmtValue(c.newValue)}
555 + </Td>
556 + <Td>
557 + <Badge size="xs" mono tone="outline">
558 + {c.eventType}
559 + </Badge>
560 + </Td>
561 + <Td numeric className={cn(c.significance >= 60 && "text-accent")}>
562 + {c.significance}
563 + </Td>
564 + </Tr>
565 + ))}
566 + </TBody>
567 + </Table>
568 + )}
569 + </div>
570 + );
571 +}
added apps/web/src/components/admin/documents-table.tsx +157 −0
@@ -0,0 +1,157 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useEffect, useMemo, useState } from "react";
5 +import { PAGE_TYPES } from "@dci/core";
6 +import { Badge, EmptyState, RelativeTime, SearchInput, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
7 +import { cn } from "@/components/ui/cn";
8 +import { DASH, fmtInt } from "@/lib/format";
9 +import type { AdminDocument, ConnectorHealthDTO } from "@/lib/admin-api";
10 +import { ErrorNote, fmtBytes, LevelBadge, Pager, RefreshButton, Segmented, Select } from "./primitives";
11 +import { AdminPageHeader } from "./shell";
12 +import { useAdminQuery } from "./use-admin-query";
13 +import { useUrlState } from "./use-url-state";
14 +
15 +const STATUS = ["all", "error", "changed", "quarantined"] as const;
16 +type Status = (typeof STATUS)[number];
17 +const PER_PAGE = 50;
18 +
19 +export function DocumentsTable() {
20 + const [params, setParams] = useUrlState();
21 + const connector = params.connector ?? "";
22 + const pageType = params.pageType ?? "";
23 + const status = (STATUS.includes(params.status as Status) ? params.status : "all") as Status;
24 + const page = Math.max(1, Number(params.page ?? 1) || 1);
25 + const [search, setSearch] = useState(params.q ?? "");
26 + useEffect(() => {
27 + const t = setTimeout(() => {
28 + if ((params.q ?? "") !== search) setParams({ q: search || null, page: null });
29 + }, 350);
30 + return () => clearTimeout(t);
31 + }, [search, params.q, setParams]);
32 +
33 + const connectors = useAdminQuery<ConnectorHealthDTO[]>("connectors", { timeoutMs: 30_000 });
34 + const q = useAdminQuery<AdminDocument[]>("documents", { query: { connector: connector || undefined, pageType: pageType || undefined, status: status === "all" ? undefined : status, q: params.q || undefined, page, per_page: PER_PAGE }, refreshMs: 60_000 });
35 + const total = Number(q.meta?.total ?? 0);
36 + const connectorIds = useMemo(() => (connectors.data ?? []).map((c) => c.id).sort(), [connectors.data]);
37 +
38 + return (
39 + <div>
40 + <AdminPageHeader title="Documents" description={q.data ? `${fmtInt(total)} documents in the URL registry${connector ? ` · ${connector}` : ""}` : "URL registry: every page the crawler knows, with versions and extraction state"} aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />
41 + <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />
42 + <div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center">
43 + <Segmented label="Status" value={status} onChange={(v) => setParams({ status: v === "all" ? null : v, page: null })} options={STATUS.map((s) => ({ value: s, label: s === "all" ? "All" : s[0]!.toUpperCase() + s.slice(1) }))} />
44 + <div className="flex flex-1 flex-col gap-2 sm:flex-row">
45 + <Select value={connector} onChange={(e) => setParams({ connector: e.target.value || null, page: null })} className="sm:w-[200px]" aria-label="Connector">
46 + <option value="">All connectors</option>
47 + {connector && !connectorIds.includes(connector) && <option value={connector}>{connector}</option>}
48 + {connectorIds.map((id) => (
49 + <option key={id} value={id}>
50 + {id}
51 + </option>
52 + ))}
53 + </Select>
54 + <Select value={pageType} onChange={(e) => setParams({ pageType: e.target.value || null, page: null })} className="sm:w-[190px]" aria-label="Page type">
55 + <option value="">All page types</option>
56 + {PAGE_TYPES.map((t) => (
57 + <option key={t} value={t}>
58 + {t}
59 + </option>
60 + ))}
61 + </Select>
62 + <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Search URL or title" wrapperClassName="flex-1" />
63 + </div>
64 + </div>
65 +
66 + {q.loading && !q.data ? (
67 + <SkeletonRows rows={10} />
68 + ) : !q.data || q.data.length === 0 ? (
69 + <EmptyState compact title="No documents match" />
70 + ) : (
71 + <div className="rounded-sm border border-hair bg-surface px-3">
72 + <Table className="text-[12.5px]">
73 + <THead>
74 + <Tr>
75 + <Th>URL</Th>
76 + <Th>Connector</Th>
77 + <Th>Page type</Th>
78 + <Th>Lvl</Th>
79 + <Th numeric>HTTP</Th>
80 + <Th>Last fetched</Th>
81 + <Th>Last changed</Th>
82 + <Th numeric>Changes</Th>
83 + <Th>Extract</Th>
84 + <Th numeric>Size</Th>
85 + </Tr>
86 + </THead>
87 + <TBody>
88 + {q.data.map((d) => (
89 + <Tr key={d.id} className={cn(d.quarantined && "opacity-70")}>
90 + <Td primary label="URL" className="max-w-[420px]">
91 + <Link href={`/admin/documents/${encodeURIComponent(d.id)}`} className="block truncate font-mono text-[12px] font-medium text-ink hover:text-accent hover:underline" title={d.url}>
92 + {d.url.replace(/^https?:\/\//, "")}
93 + </Link>
94 + <div className="flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">
95 + <span className="truncate">{d.title ?? d.id}</span>
96 + {d.quarantined && (
97 + <Badge size="xs" tone="warn">
98 + quarantined
99 + </Badge>
100 + )}
101 + {d.error && (
102 + <span className="truncate text-danger" title={d.error}>
103 + {d.error}
104 + </span>
105 + )}
106 + </div>
107 + </Td>
108 + <Td label="Connector">
109 + <Link href={`/admin/connectors/${encodeURIComponent(d.connectorId)}`} className="font-mono text-[12px] text-ink-2 hover:text-accent hover:underline">
110 + {d.connectorId}
111 + </Link>
112 + </Td>
113 + <Td label="Page type">
114 + <Badge size="xs" mono tone="outline">
115 + {d.pageType}
116 + </Badge>
117 + </Td>
118 + <Td label="Level">
119 + <LevelBadge level={d.fetchLevel} />
120 + </Td>
121 + <Td label="HTTP" numeric className={cn(d.statusCode != null && d.statusCode >= 400 && "text-danger")}>
122 + {d.statusCode ?? DASH}
123 + </Td>
124 + <Td label="Last fetched" muted>
125 + <RelativeTime iso={d.lastFetched} />
126 + </Td>
127 + <Td label="Last changed" muted>
128 + <RelativeTime iso={d.lastChanged} />
129 + </Td>
130 + <Td label="Changes" numeric>
131 + {fmtInt(d.changeCount)}
132 + <span className="text-ink-4"> / {fmtInt(d.fetchCount)}</span>
133 + </Td>
134 + <Td label="Extract">
135 + {d.extractOk == null ? (
136 + <span className="text-ink-4">{DASH}</span>
137 + ) : d.extractOk ? (
138 + <span className="text-ok">
139 + ok <span className="figure text-ink-3">{fmtInt(d.extractCount)}</span>
140 + </span>
141 + ) : (
142 + <span className="text-danger">failed{d.errorCount ? <span className="figure"> ×{d.errorCount}</span> : null}</span>
143 + )}
144 + </Td>
145 + <Td label="Size" numeric muted>
146 + {fmtBytes(d.sizeBytes)}
147 + </Td>
148 + </Tr>
149 + ))}
150 + </TBody>
151 + </Table>
152 + <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />
153 + </div>
154 + )}
155 + </div>
156 + );
157 +}
added apps/web/src/components/admin/events-review.tsx +129 −0
@@ -0,0 +1,129 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo } from "react";
5 +import type { EventDTO } from "@dci/core";
6 +import { Badge, Button, EmptyState, EventBadge, RelativeTime, SkeletonRows } from "@/components/ui";
7 +import { cn } from "@/components/ui/cn";
8 +import { fmtInt, fmtValue } from "@/lib/format";
9 +import { EVENT_LABEL } from "@/lib/labels";
10 +import { entityHref } from "@/lib/routes";
11 +import { ErrorNote, ExtLink, Input, Pager, RefreshButton, ReviewBadge, Segmented, Select } from "./primitives";
12 +import { AdminPageHeader } from "./shell";
13 +import { useToast } from "./toast";
14 +import { useAdminMutation, useAdminQuery } from "./use-admin-query";
15 +import { useUrlState } from "./use-url-state";
16 +
17 +const REVIEW = ["all", "pending", "approved", "auto"] as const;
18 +type Review = (typeof REVIEW)[number];
19 +const PER_PAGE = 50;
20 +
21 +export function EventsReview() {
22 + const [params, setParams] = useUrlState();
23 + const review = (REVIEW.includes(params.review as Review) ? params.review : "all") as Review;
24 + const page = Math.max(1, Number(params.page ?? 1) || 1);
25 + const type = params.type ?? "";
26 + const minSig = params.min_significance ?? "";
27 + const q = useAdminQuery<EventDTO[]>("v1/events", { query: { type: type || undefined, min_significance: minSig || undefined, page, per_page: PER_PAGE }, refreshMs: 60_000 });
28 + const { busy, run } = useAdminMutation();
29 + const toast = useToast();
30 + const total = Number(q.meta?.total ?? 0);
31 + const rows = useMemo(() => (q.data ?? []).filter((e) => review === "all" || e.reviewStatus === review), [q.data, review]);
32 + const counts = useMemo(() => {
33 + const c: Record<string, number> = {};
34 + for (const e of q.data ?? []) c[e.reviewStatus] = (c[e.reviewStatus] ?? 0) + 1;
35 + return c;
36 + }, [q.data]);
37 +
38 + const setStatus = async (e: EventDTO, reviewStatus: "approved" | "rejected" | "pending") => {
39 + const prev = e.reviewStatus;
40 + q.mutate((list) => list?.map((x) => (x.id === e.id ? { ...x, reviewStatus } : x)) ?? list);
41 + const r = await run<{ id: string; reviewStatus: string }>(`${e.id}:${reviewStatus}`, `events/${encodeURIComponent(e.id)}`, { method: "PATCH", body: { reviewStatus } });
42 + if (r.ok) toast({ tone: "ok", title: `Event ${reviewStatus}`, description: e.title });
43 + else {
44 + q.mutate((list) => list?.map((x) => (x.id === e.id ? { ...x, reviewStatus: prev } : x)) ?? list);
45 + toast({ tone: "danger", title: "Review not saved", description: r.error });
46 + }
47 + };
48 +
49 + return (
50 + <div>
51 + <AdminPageHeader title="Events review" description="Recent change-feed events with their review state. Rejected events disappear from the public feed (and from this list, which reads the public endpoint)." aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />
52 + <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />
53 + <div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center">
54 + <Segmented label="Review" value={review} onChange={(v) => setParams({ review: v === "all" ? null : v })} options={REVIEW.map((r) => ({ value: r, label: r === "all" ? "All (page)" : r[0]!.toUpperCase() + r.slice(1), count: r === "all" ? (q.data?.length ?? null) : (counts[r] ?? 0) }))} />
55 + <div className="flex flex-1 gap-2">
56 + <Select value={type} onChange={(e) => setParams({ type: e.target.value || null, page: null })} className="sm:w-[220px]" aria-label="Event type">
57 + <option value="">All event types</option>
58 + {Object.entries(EVENT_LABEL).map(([k, v]) => (
59 + <option key={k} value={k}>
60 + {v}
61 + </option>
62 + ))}
63 + </Select>
64 + <Input type="number" min={0} max={100} value={minSig} onChange={(e) => setParams({ min_significance: e.target.value || null, page: null })} placeholder="min significance" className="w-[150px]" mono />
65 + </div>
66 + </div>
67 +
68 + {q.loading && !q.data ? (
69 + <SkeletonRows rows={10} />
70 + ) : rows.length === 0 ? (
71 + <EmptyState compact title="No events" description={review !== "all" ? "No events with this review state on the current page." : undefined} />
72 + ) : (
73 + <div className="rounded-sm border border-hair bg-surface">
74 + <ul className="divide-y divide-hair">
75 + {rows.map((e) => (
76 + <li key={e.id} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start">
77 + <div className="flex w-full shrink-0 flex-wrap items-center gap-1.5 sm:w-[210px] sm:flex-col sm:items-start">
78 + <EventBadge type={e.eventType} size="xs" />
79 + <ReviewBadge status={e.reviewStatus} />
80 + <span className="text-[11.5px] text-ink-3">
81 + <RelativeTime iso={e.detectedAt} />
82 + </span>
83 + <span className={cn("figure text-[11.5px]", e.significance >= 60 ? "text-accent" : "text-ink-4")}>sig {e.significance}</span>
84 + </div>
85 + <div className="min-w-0 flex-1 text-[13px]">
86 + <div className="font-medium text-ink">{e.title}</div>
87 + {e.summary && <p className="mt-0.5 line-clamp-2 text-[12.5px] text-ink-2">{e.summary}</p>}
88 + <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-ink-3">
89 + {e.entity && (
90 + <Link href={entityHref(e.entityType, e.entity.slug, e.entity.name)} target="_blank" className="text-ink-2 hover:text-accent hover:underline">
91 + {e.entity.name}
92 + </Link>
93 + )}
94 + {e.operator && <span>{e.operator.name}</span>}
95 + {e.countryIso2 && <Badge size="xs">{e.countryIso2}</Badge>}
96 + <span>{e.sourceName}</span>
97 + <ExtLink href={e.url} />
98 + {(e.oldValue !== null && e.oldValue !== undefined) || (e.newValue !== null && e.newValue !== undefined) ? (
99 + <span className="figure">
100 + {e.oldValue !== null && e.oldValue !== undefined && <span className="text-danger">{fmtValue(e.oldValue)}</span>}
101 + {e.oldValue !== null && e.oldValue !== undefined && " → "}
102 + <span className="text-st-operational">{fmtValue(e.newValue)}</span>
103 + </span>
104 + ) : null}
105 + </div>
106 + </div>
107 + <div className="flex shrink-0 gap-1">
108 + <Button size="xs" variant={e.reviewStatus === "approved" ? "primary" : "secondary"} onClick={() => void setStatus(e, "approved")} disabled={busy !== null || e.reviewStatus === "approved"}>
109 + Approve
110 + </Button>
111 + <Button size="xs" variant="ghost" onClick={() => void setStatus(e, "pending")} disabled={busy !== null || e.reviewStatus === "pending"}>
112 + Pending
113 + </Button>
114 + <Button size="xs" variant="danger" onClick={() => void setStatus(e, "rejected")} disabled={busy !== null}>
115 + Reject
116 + </Button>
117 + </div>
118 + </li>
119 + ))}
120 + </ul>
121 + <div className="px-3">
122 + <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />
123 + </div>
124 + </div>
125 + )}
126 + <p className="mt-2 text-[11.5px] text-ink-4">{fmtInt(total)} events match the type / significance filters. The review filter applies to the current page.</p>
127 + </div>
128 + );
129 +}
added apps/web/src/components/admin/facility-edit.tsx +449 −0
@@ -0,0 +1,449 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo, useState } from "react";
5 +import type { FacilityDetail, OperatorSummary, SearchResponse } from "@dci/core";
6 +import { FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS } from "@dci/core";
7 +import { Badge, Button, ConfidenceBadge, EmptyState, PrecisionBadge, SkeletonBlock, StatusBadge } from "@/components/ui";
8 +import { cn } from "@/components/ui/cn";
9 +import { DASH, fmtDateTimeUtc, fmtValue, placeLine } from "@/lib/format";
10 +import { PRECISION_LABEL, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";
11 +import { routes } from "@/lib/routes";
12 +import { adminFetch, type FacilityPatch, type FacilityPatchResult } from "@/lib/admin-api";
13 +import { useDebounced } from "@/lib/hooks";
14 +import { Checkbox, ErrorNote, Field, Input, KV, Mono, Panel, Select, Textarea } from "./primitives";
15 +import { AdminPageHeader } from "./shell";
16 +import { useToast } from "./toast";
17 +import { useAdminMutation, useAdminQuery } from "./use-admin-query";
18 +
19 +interface FormState {
20 + name: string;
21 + status: string;
22 + facility_type: string;
23 + lat: string;
24 + lng: string;
25 + geo_precision: string;
26 + it_capacity_mw: string;
27 + total_power_mw: string;
28 + planned_power_mw: string;
29 + mw_is_estimate: boolean;
30 + opened_on: string;
31 + operator_id: string;
32 + operator_name: string;
33 + is_ai: boolean;
34 + is_hyperscale: boolean;
35 + description: string;
36 + note: string;
37 +}
38 +
39 +function fromDetail(f: FacilityDetail): FormState {
40 + return {
41 + name: f.name,
42 + status: f.status,
43 + facility_type: f.facilityType,
44 + lat: f.lat != null ? String(f.lat) : "",
45 + lng: f.lng != null ? String(f.lng) : "",
46 + geo_precision: f.geoPrecision,
47 + it_capacity_mw: f.itCapacityMw != null ? String(f.itCapacityMw) : "",
48 + total_power_mw: f.totalPowerMw != null ? String(f.totalPowerMw) : "",
49 + planned_power_mw: f.plannedPowerMw != null ? String(f.plannedPowerMw) : "",
50 + mw_is_estimate: f.mwIsEstimate ?? false,
51 + opened_on: f.openedOn ?? "",
52 + operator_id: f.operator?.id ?? "",
53 + operator_name: f.operator?.name ?? "",
54 + is_ai: f.isAi,
55 + is_hyperscale: f.isHyperscale,
56 + description: f.description ?? "",
57 + note: "",
58 + };
59 +}
60 +
61 +const numOrNull = (s: string): number | null | "bad" => {
62 + if (s.trim() === "") return null;
63 + const n = Number(s);
64 + return Number.isFinite(n) ? n : "bad";
65 +};
66 +
67 +/** Build the PATCH body from the diff between the initial and the current form. Returns validation errors per field. */
68 +function buildPatch(init: FormState, cur: FormState): { patch: FacilityPatch; errors: Record<string, string> } {
69 + const patch: FacilityPatch = {};
70 + const errors: Record<string, string> = {};
71 + if (cur.name !== init.name) {
72 + if (cur.name.trim().length < 2) errors.name = "at least 2 characters";
73 + else patch.name = cur.name.trim();
74 + }
75 + if (cur.status !== init.status) patch.status = cur.status;
76 + if (cur.facility_type !== init.facility_type) patch.facility_type = cur.facility_type;
77 + const lat = numOrNull(cur.lat);
78 + const lng = numOrNull(cur.lng);
79 + if (lat === "bad") errors.lat = "not a number";
80 + if (lng === "bad") errors.lng = "not a number";
81 + if (lat !== "bad" && lng !== "bad" && (cur.lat !== init.lat || cur.lng !== init.lng)) {
82 + if ((lat == null) !== (lng == null)) errors.lat = "lat and lng must be set together";
83 + else if (lat != null && (lat < -90 || lat > 90)) errors.lat = "−90 … 90";
84 + else if (lng != null && (lng < -180 || lng > 180)) errors.lng = "−180 … 180";
85 + else {
86 + patch.lat = lat;
87 + patch.lng = lng;
88 + }
89 + }
90 + if (cur.geo_precision !== init.geo_precision || (patch.lat != null && cur.geo_precision === "exact")) patch.geo_precision = cur.geo_precision;
91 + for (const k of ["it_capacity_mw", "total_power_mw", "planned_power_mw"] as const) {
92 + if (cur[k] === init[k]) continue;
93 + const v = numOrNull(cur[k]);
94 + if (v === "bad") errors[k] = "not a number";
95 + else if (v != null && v <= 0) errors[k] = "must be positive";
96 + else if (v != null && v > (k === "planned_power_mw" ? 20000 : 10000)) errors[k] = "too large";
97 + else patch[k] = v;
98 + }
99 + if (cur.mw_is_estimate !== init.mw_is_estimate || (cur.mw_is_estimate && (patch.it_capacity_mw !== undefined || patch.total_power_mw !== undefined || patch.planned_power_mw !== undefined))) patch.mw_is_estimate = cur.mw_is_estimate;
100 + if (cur.opened_on !== init.opened_on) {
101 + const v = cur.opened_on.trim();
102 + if (v && !/^\d{4}(-\d{2}(-\d{2})?|-[QH]\d)?$/.test(v)) errors.opened_on = "YYYY, YYYY-MM, YYYY-MM-DD, YYYY-Qn or YYYY-Hn";
103 + else patch.opened_on = v || null;
104 + }
105 + if (cur.operator_id !== init.operator_id) patch.operator_id = cur.operator_id || null;
106 + if (cur.is_ai !== init.is_ai) patch.is_ai = cur.is_ai;
107 + if (cur.is_hyperscale !== init.is_hyperscale) patch.is_hyperscale = cur.is_hyperscale;
108 + if (cur.description !== init.description) patch.description = cur.description.trim() || null;
109 + if (cur.note.trim()) patch.note = cur.note.trim();
110 + return { patch, errors };
111 +}
112 +
113 +function OperatorPicker({ value, name, onPick }: { value: string; name: string; onPick: (id: string, name: string) => void }) {
114 + const [q, setQ] = useState("");
115 + const dq = useDebounced(q, 250);
116 + const res = useAdminQuery<OperatorSummary[]>(dq.trim().length >= 2 ? "v1/operators" : null, { query: { q: dq.trim(), per_page: 8 } });
117 + return (
118 + <div className="space-y-1.5">
119 + <div className="flex items-center gap-2">
120 + {value ? (
121 + <Badge tone="accent" size="sm">
122 + {name || value}
123 + </Badge>
124 + ) : (
125 + <span className="text-[12.5px] text-ink-4">no operator</span>
126 + )}
127 + {value && (
128 + <Button size="xs" variant="ghost" onClick={() => onPick("", "")}>
129 + clear
130 + </Button>
131 + )}
132 + </div>
133 + <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search operators…" />
134 + {dq.trim().length >= 2 && (
135 + <ul className="max-h-48 overflow-auto rounded-xs border border-hair bg-surface text-[12.5px]">
136 + {res.loading && <li className="px-2.5 py-1.5 text-ink-4">Searching…</li>}
137 + {res.data?.map((o) => (
138 + <li key={o.id}>
139 + <button type="button" onClick={() => { onPick(o.id, o.name); setQ(""); }} className={cn("flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-surface-2", o.id === value && "bg-surface-2")}>
140 + <span className="truncate text-ink">{o.name}</span>
141 + <span className="shrink-0 text-ink-4">
142 + {o.kind ?? ""} · {o.facilityCount} fac. · <Mono>{o.id}</Mono>
143 + </span>
144 + </button>
145 + </li>
146 + ))}
147 + {res.data && res.data.length === 0 && <li className="px-2.5 py-1.5 text-ink-4">No operator matches.</li>}
148 + </ul>
149 + )}
150 + </div>
151 + );
152 +}
153 +
154 +function MergePicker({ facilityId, onMerged }: { facilityId: string; onMerged: (survivor: string) => void }) {
155 + const [q, setQ] = useState("");
156 + const dq = useDebounced(q, 250);
157 + const res = useAdminQuery<SearchResponse>(dq.trim().length >= 2 ? "v1/search" : null, { query: { q: dq.trim() } });
158 + const [target, setTarget] = useState<{ id: string; slug: string; title: string; subtitle: string | null } | null>(null);
159 + const { busy, run } = useAdminMutation();
160 + const toast = useToast();
161 + const hits = (res.data?.hits ?? []).filter((h) => h.type === "facility" && h.id !== facilityId).slice(0, 8);
162 + const merge = async () => {
163 + if (!target) return;
164 + if (!window.confirm(`Merge this facility INTO "${target.title}"? Aliases, provenance, tenants, keys, projects and events move to the survivor; this record becomes a redirect. This cannot be undone from the console.`)) return;
165 + const r = await run<{ survivor?: string; into?: string; merged?: string; [k: string]: unknown }>("merge", `facilities/${encodeURIComponent(facilityId)}/merge`, { body: { into: target.id } });
166 + if (r.ok) {
167 + toast({ tone: "ok", title: "Merged", description: `→ ${target.title}` });
168 + onMerged(target.id);
169 + } else toast({ tone: "danger", title: "Merge failed", description: r.error });
170 + };
171 + return (
172 + <div className="space-y-2">
173 + <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search the surviving facility…" />
174 + {dq.trim().length >= 2 && !target && (
175 + <ul className="max-h-48 overflow-auto rounded-xs border border-hair bg-surface text-[12.5px]">
176 + {res.loading && <li className="px-2.5 py-1.5 text-ink-4">Searching…</li>}
177 + {hits.map((h) => (
178 + <li key={h.id}>
179 + <button type="button" onClick={() => setTarget({ id: h.id, slug: h.slug, title: h.title, subtitle: h.subtitle })} className="flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-surface-2">
180 + <span className="truncate text-ink">{h.title}</span>
181 + <span className="shrink-0 truncate text-ink-4">{h.subtitle}</span>
182 + </button>
183 + </li>
184 + ))}
185 + {res.data && hits.length === 0 && <li className="px-2.5 py-1.5 text-ink-4">No other facility matches.</li>}
186 + </ul>
187 + )}
188 + {target && (
189 + <div className="flex flex-wrap items-center gap-2 rounded-xs border border-danger/30 bg-danger/5 px-2.5 py-2 text-[12.5px]">
190 + <span>
191 + Merge into <strong>{target.title}</strong> <span className="text-ink-3">{target.subtitle}</span> <Mono>{target.id}</Mono>
192 + </span>
193 + <Link href={routes.facility(target.slug)} target="_blank" className="text-ink-2 underline">
194 + view
195 + </Link>
196 + <Button size="xs" variant="ghost" onClick={() => setTarget(null)}>
197 + change
198 + </Button>
199 + <Button size="xs" variant="danger" onClick={() => void merge()} disabled={busy !== null}>
200 + Merge now
201 + </Button>
202 + </div>
203 + )}
204 + </div>
205 + );
206 +}
207 +
208 +export function FacilityEditView({ id }: { id: string }) {
209 + const q = useAdminQuery<FacilityDetail>(`v1/datacenters/${encodeURIComponent(id)}`);
210 + const f = q.data;
211 + // initial form = derived from the API record; `edits` holds the user's changes (null = untouched)
212 + const init = useMemo(() => (f ? fromDetail(f) : null), [f]);
213 + const [edits, setEdits] = useState<FormState | null>(null);
214 + const form = edits ?? init;
215 + const [result, setResult] = useState<FacilityPatchResult | null>(null);
216 + const [savedNote, setSavedNote] = useState<string | null>(null);
217 + const [serverError, setServerError] = useState<string | null>(null);
218 + const [saving, setSaving] = useState(false);
219 + const toast = useToast();
220 + const { patch, errors } = useMemo(() => (init && form ? buildPatch(init, form) : { patch: {}, errors: {} }), [init, form]);
221 + const dirty = Object.keys(patch).filter((k) => k !== "note").length > 0;
222 + const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
223 + setEdits((p) => {
224 + const base = p ?? init;
225 + return base ? { ...base, [k]: v } : p;
226 + });
227 +
228 + const save = async () => {
229 + if (!form || !f) return;
230 + if (Object.keys(errors).length) return;
231 + setSaving(true);
232 + setServerError(null);
233 + const r = await adminFetch<FacilityPatchResult>(`facilities/${encodeURIComponent(f.id)}`, { method: "PATCH", body: patch });
234 + setSaving(false);
235 + if (r.ok) {
236 + setResult(r.data);
237 + setSavedNote(patch.note ?? null);
238 + toast({ tone: "ok", title: r.data.changed.length ? `Saved ${r.data.changed.length} field${r.data.changed.length > 1 ? "s" : ""}` : "No changes", description: r.data.changed.map((c) => c.field).join(", ") || r.data.message });
239 + setEdits(null);
240 + if (r.data.facility) q.mutate(() => r.data.facility);
241 + else void q.refresh();
242 + } else {
243 + const details = Array.isArray(r.details) ? (r.details as Array<{ path?: string; message?: string }>).map((d) => `${d.path ?? ""}: ${d.message ?? ""}`).join("; ") : "";
244 + setServerError(`${r.error}${details ? ` — ${details}` : ""}`);
245 + toast({ tone: "danger", title: "Save failed", description: r.error });
246 + }
247 + };
248 +
249 + if (q.error && !f) {
250 + return (
251 + <div>
252 + <AdminPageHeader eyebrow="Curate" title={<span className="font-mono">{id}</span>} />
253 + <ErrorNote error={q.status === 404 ? "Facility not found" : q.error} onRetry={() => void q.refresh()} />
254 + </div>
255 + );
256 + }
257 + if (!f || !form || !init) return <SkeletonBlock className="h-64" />;
258 +
259 + return (
260 + <div>
261 + <AdminPageHeader
262 + eyebrow="Curate facility"
263 + title={f.name}
264 + description={
265 + <span className="inline-flex flex-wrap items-center gap-1.5">
266 + <StatusBadge status={f.status} size="xs" />
267 + <PrecisionBadge precision={f.geoPrecision} size="xs" />
268 + <ConfidenceBadge level={f.confidence} size="xs" />
269 + <span>{placeLine({ city: f.city, regionName: f.regionName, countryIso2: f.countryIso2, countryName: f.countryName ?? undefined })}</span>
270 + <Mono className="text-ink-4">{f.id}</Mono>
271 + <Link href={routes.facility(f.slug)} target="_blank" className="text-ink-2 underline underline-offset-2 hover:text-accent">
272 + public page
273 + </Link>
274 + </span>
275 + }
276 + aside={
277 + <>
278 + <Button size="sm" variant="ghost" onClick={() => setEdits(null)} disabled={!edits}>
279 + Reset
280 + </Button>
281 + <Button size="sm" variant="primary" onClick={() => void save()} disabled={saving || !dirty || Object.keys(errors).length > 0}>
282 + {saving ? "Saving…" : dirty ? `Save ${Object.keys(patch).filter((k) => k !== "note").length} change${Object.keys(patch).filter((k) => k !== "note").length > 1 ? "s" : ""}` : "No changes"}
283 + </Button>
284 + </>
285 + }
286 + />
287 + <ErrorNote error={serverError} className="mb-3" />
288 + {result && result.changed.length > 0 && (
289 + <Panel title="Provenance recorded" description="Each saved field got a src_manual provenance row (method manual, confidence high) and one event was emitted." className="mb-3 border-ok/40">
290 + <ul className="space-y-1 text-[12.5px]">
291 + {result.changed.map((c) => (
292 + <li key={c.field} className="flex flex-wrap items-baseline gap-2">
293 + <Mono className="text-ink">{c.field}</Mono>
294 + <span className="text-danger line-through">{fmtValue(c.oldValue)}</span>
295 + <span>→</span>
296 + <span className="text-st-operational">{fmtValue(c.newValue)}</span>
297 + </li>
298 + ))}
299 + </ul>
300 + <p className="mt-2 text-[12px] text-ink-3">
301 + source <Mono>src_manual</Mono> · url <Mono>https://www.datacenterindex.io/admin/facilities/{result.id}</Mono> ·{" "}
302 + {savedNote ? <>note “{savedNote}”</> : <span className="text-warn">no note given</span>} · last_verified set to now · confidence raised to high when status or MW changed.
303 + </p>
304 + </Panel>
305 + )}
306 +
307 + <div className="grid gap-3 lg:grid-cols-3">
308 + <div className="space-y-3 lg:col-span-2">
309 + <Panel title="Identity">
310 + <div className="grid gap-3 sm:grid-cols-2">
311 + <Field label="Name" error={errors.name} className="sm:col-span-2">
312 + <Input value={form.name} onChange={(e) => set("name", e.target.value)} />
313 + </Field>
314 + <Field label="Status">
315 + <Select value={form.status} onChange={(e) => set("status", e.target.value)}>
316 + {FACILITY_STATUSES.map((s) => (
317 + <option key={s} value={s}>
318 + {STATUS_LABEL[s]}
319 + </option>
320 + ))}
321 + </Select>
322 + </Field>
323 + <Field label="Type">
324 + <Select value={form.facility_type} onChange={(e) => set("facility_type", e.target.value)}>
325 + {FACILITY_TYPES.map((t) => (
326 + <option key={t} value={t}>
327 + {TYPE_LABEL[t]}
328 + </option>
329 + ))}
330 + </Select>
331 + </Field>
332 + <Field label="Operator" className="sm:col-span-2">
333 + <OperatorPicker value={form.operator_id} name={form.operator_name} onPick={(oid, oname) =>
334 + setEdits((p) => {
335 + const base = p ?? init;
336 + return base ? { ...base, operator_id: oid, operator_name: oname } : p;
337 + })
338 + } />
339 + </Field>
340 + <div className="flex flex-wrap gap-4 sm:col-span-2">
341 + <Checkbox label="AI infrastructure" checked={form.is_ai} onChange={(e) => set("is_ai", e.target.checked)} />
342 + <Checkbox label="Hyperscale" checked={form.is_hyperscale} onChange={(e) => set("is_hyperscale", e.target.checked)} />
343 + </div>
344 + <Field label="Opened" hint="partial dates allowed: 2024, 2024-06, 2024-Q2" error={errors.opened_on}>
345 + <Input value={form.opened_on} onChange={(e) => set("opened_on", e.target.value)} placeholder="YYYY-MM" mono />
346 + </Field>
347 + </div>
348 + </Panel>
349 +
350 + <Panel title="Location" description="Hand-entered coordinates default to precision “approximate” unless you pick one. City-level points must never be marked exact.">
351 + <div className="grid gap-3 sm:grid-cols-3">
352 + <Field label="Latitude" error={errors.lat}>
353 + <Input value={form.lat} onChange={(e) => set("lat", e.target.value)} placeholder="45.5017" mono inputMode="decimal" />
354 + </Field>
355 + <Field label="Longitude" error={errors.lng}>
356 + <Input value={form.lng} onChange={(e) => set("lng", e.target.value)} placeholder="-73.5673" mono inputMode="decimal" />
357 + </Field>
358 + <Field label="Precision">
359 + <Select value={form.geo_precision} onChange={(e) => set("geo_precision", e.target.value)}>
360 + {GEO_PRECISIONS.map((p) => (
361 + <option key={p} value={p}>
362 + {PRECISION_LABEL[p]}
363 + </option>
364 + ))}
365 + </Select>
366 + </Field>
367 + </div>
368 + {form.lat && form.lng && !errors.lat && !errors.lng && (
369 + <p className="mt-2 text-[12px] text-ink-3">
370 + Check on{" "}
371 + <a className="underline" target="_blank" rel="noopener noreferrer" href={`https://www.openstreetmap.org/?mlat=${form.lat}&mlon=${form.lng}#map=16/${form.lat}/${form.lng}`}>
372 + OpenStreetMap
373 + </a>
374 + . Address on record: {f.address ?? DASH}
375 + </p>
376 + )}
377 + </Panel>
378 +
379 + <Panel title="Capacity (MW)" description="Leave empty for unknown — never 0. Tick “estimate” when the figure is derived rather than published.">
380 + <div className="grid gap-3 sm:grid-cols-3">
381 + <Field label="IT capacity" error={errors.it_capacity_mw}>
382 + <Input value={form.it_capacity_mw} onChange={(e) => set("it_capacity_mw", e.target.value)} mono inputMode="decimal" placeholder="—" />
383 + </Field>
384 + <Field label="Total power" error={errors.total_power_mw}>
385 + <Input value={form.total_power_mw} onChange={(e) => set("total_power_mw", e.target.value)} mono inputMode="decimal" placeholder="—" />
386 + </Field>
387 + <Field label="Planned power" error={errors.planned_power_mw}>
388 + <Input value={form.planned_power_mw} onChange={(e) => set("planned_power_mw", e.target.value)} mono inputMode="decimal" placeholder="—" />
389 + </Field>
390 + </div>
391 + <Checkbox className="mt-2" label="MW figures are estimates (flag isEstimate on the provenance)" checked={form.mw_is_estimate} onChange={(e) => set("mw_is_estimate", e.target.checked)} />
392 + </Panel>
393 +
394 + <Panel title="Description">
395 + <Textarea rows={5} value={form.description} onChange={(e) => set("description", e.target.value)} placeholder="Factual description (no marketing copy). Max 5 000 characters." maxLength={5000} />
396 + </Panel>
397 +
398 + <Panel title="Curation note" description="Stored on every provenance row and on the event summary — say where the fact comes from.">
399 + <Textarea rows={2} value={form.note} onChange={(e) => set("note", e.target.value)} placeholder="e.g. Operator press release 2026-05-03 states 48 MW IT at full build" maxLength={500} />
400 + </Panel>
401 + </div>
402 +
403 + <div className="space-y-3">
404 + <Panel title="Current record" description="As served by the public API">
405 + <KV
406 + items={[
407 + { k: "Operator", v: f.operator?.name },
408 + { k: "Owner", v: f.owner?.name },
409 + { k: "Campus", v: f.campus?.name },
410 + { k: "Metro", v: f.metro?.name },
411 + { k: "Address", v: f.address },
412 + { k: "Completeness", v: `${f.completeness}%` },
413 + { k: "Confidence", v: <ConfidenceBadge level={f.confidence} size="xs" /> },
414 + { k: "Last verified", v: fmtDateTimeUtc(f.lastVerified) },
415 + { k: "First seen", v: fmtDateTimeUtc(f.firstSeen) },
416 + { k: "Updated", v: fmtDateTimeUtc(f.updatedAt) },
417 + { k: "Provenance rows", v: String(f.provenance.length) },
418 + ]}
419 + />
420 + </Panel>
421 + <Panel title="Field provenance" description="Current source per field" padded={false}>
422 + {f.provenance.length === 0 ? (
423 + <EmptyState compact title="No provenance rows" />
424 + ) : (
425 + <ul className="max-h-[360px] divide-y divide-hair overflow-auto text-[12px]">
426 + {f.provenance.map((p, i) => (
427 + <li key={i} className="px-3 py-1.5">
428 + <div className="flex items-center justify-between gap-2">
429 + <Mono className="text-ink">{p.field}</Mono>
430 + <ConfidenceBadge level={p.confidence} size="xs" />
431 + </div>
432 + <div className="truncate text-ink-3">
433 + {fmtValue(p.value)}
434 + {p.isEstimate && <span className="text-warn"> ≈</span>} · {p.sourceName}
435 + {p.method ? ` · ${p.method}` : ""}
436 + </div>
437 + </li>
438 + ))}
439 + </ul>
440 + )}
441 + </Panel>
442 + <Panel title="Merge into another facility" description="Use when this record duplicates an existing one. The other facility survives." className="border-danger/30">
443 + <MergePicker facilityId={f.id} onMerged={() => { setEdits(null); void q.refresh(); }} />
444 + </Panel>
445 + </div>
446 + </div>
447 + </div>
448 + );
449 +}
added apps/web/src/components/admin/matches-queue.tsx +197 −0
@@ -0,0 +1,197 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo, useState } from "react";
5 +import type { FacilitySummary } from "@dci/core";
6 +import { Badge, Button, EmptyState, RelativeTime, SkeletonBlock, StatusBadge } from "@/components/ui";
7 +import { cn } from "@/components/ui/cn";
8 +import { DASH, fmtInt, fmtMw, placeLine } from "@/lib/format";
9 +import { routes } from "@/lib/routes";
10 +import type { AdminMatch, MatchCandidate } from "@/lib/admin-api";
11 +import { ErrorNote, ExtLink, Mono, Pager, RefreshButton, ReviewBadge, Segmented, Select } from "./primitives";
12 +import { AdminPageHeader } from "./shell";
13 +import { useToast } from "./toast";
14 +import { useAdminMutation, useAdminQuery } from "./use-admin-query";
15 +import { useUrlState } from "./use-url-state";
16 +
17 +const TABS = ["pending", "approved", "rejected", "auto_merged", "all"] as const;
18 +type Tab = (typeof TABS)[number];
19 +const PER_PAGE = 30;
20 +
21 +function coords(c: MatchCandidate): string {
22 + const lat = c.geo?.lat ?? c.lat;
23 + const lng = c.geo?.lng ?? c.lng;
24 + if (typeof lat !== "number" || typeof lng !== "number") return DASH;
25 + return `${lat.toFixed(4)}, ${lng.toFixed(4)}${c.geo?.precision ? ` (${c.geo.precision})` : ""}`;
26 +}
27 +
28 +function Row({ k, a, b, same }: { k: string; a: React.ReactNode; b: React.ReactNode; same?: boolean }) {
29 + return (
30 + <div className="grid grid-cols-[84px_1fr] gap-x-2 gap-y-0.5 border-b border-hair/70 py-1 text-[12.5px] last:border-0 sm:grid-cols-[84px_1fr_1fr]">
31 + <div className="text-ink-3">{k}</div>
32 + <div className={cn("min-w-0 truncate text-ink", same && "text-ok")}>{a ?? <span className="text-ink-4">{DASH}</span>}</div>
33 + <div className={cn("col-start-2 min-w-0 truncate text-ink sm:col-start-3", same && "text-ok")}>{b ?? <span className="text-ink-4">{DASH}</span>}</div>
34 + </div>
35 + );
36 +}
37 +
38 +function FacilityHead({ f, label, id }: { f: FacilitySummary | null; label: string; id: string | null }) {
39 + return (
40 + <div className="mb-1 flex items-start justify-between gap-2">
41 + <div className="min-w-0">
42 + <div className="label-caps">{label}</div>
43 + {f ? (
44 + <Link href={routes.facility(f.slug)} target="_blank" className="block truncate text-[13.5px] font-semibold text-ink hover:text-accent hover:underline">
45 + {f.name}
46 + </Link>
47 + ) : (
48 + <div className="text-[13px] text-ink-3">{id ? "facility not found" : "no linked facility"}</div>
49 + )}
50 + </div>
51 + {f && <StatusBadge status={f.status} short size="xs" />}
52 + </div>
53 + );
54 +}
55 +
56 +export function MatchesQueue() {
57 + const [params, setParams] = useUrlState();
58 + const tab = (TABS.includes(params.status as Tab) ? params.status : "pending") as Tab;
59 + const page = Math.max(1, Number(params.page ?? 1) || 1);
60 + const connector = params.connector ?? "";
61 + const q = useAdminQuery<AdminMatch[]>("matches", { query: { status: tab, connector: connector || undefined, page, per_page: PER_PAGE }, refreshMs: tab === "pending" ? 60_000 : null });
62 + const pendingCount = useAdminQuery<AdminMatch[]>("matches", { query: { status: "pending", per_page: 1 }, refreshMs: 60_000 });
63 + const { busy, run } = useAdminMutation();
64 + const toast = useToast();
65 + const [gone, setGone] = useState<Record<string, "approved" | "rejected">>({});
66 + const total = Number(q.meta?.total ?? 0);
67 + const connectors = useMemo(() => [...new Set((q.data ?? []).map((m) => m.connectorId))].sort(), [q.data]);
68 +
69 + const decide = async (m: AdminMatch, action: "approve" | "reject") => {
70 + setGone((g) => ({ ...g, [m.id]: action === "approve" ? "approved" : "rejected" }));
71 + const r = await run<{ status: string; facilityId?: string; merged?: unknown }>(`${action}:${m.id}`, `matches/${encodeURIComponent(m.id)}/${action}`, { body: {} });
72 + if (r.ok) {
73 + toast({ tone: "ok", title: action === "approve" ? "Match approved" : "Match rejected", description: action === "approve" ? `${m.candidate.name ?? m.candidateKey} → ${m.matched?.name ?? r.data.facilityId}${r.data.merged ? " (duplicate merged)" : ""}` : `${m.candidate.name ?? m.candidateKey} stays a separate facility` });
74 + setTimeout(() => {
75 + q.mutate((prev) => prev?.filter((x) => x.id !== m.id) ?? prev);
76 + void pendingCount.refresh();
77 + }, 600);
78 + } else {
79 + setGone((g) => {
80 + const n = { ...g };
81 + delete n[m.id];
82 + return n;
83 + });
84 + toast({ tone: "danger", title: `Could not ${action}`, description: r.error });
85 + }
86 + };
87 +
88 + return (
89 + <div>
90 + <AdminPageHeader title="Entity matches" description="Reconciliation queue: candidates from connectors that look like an existing facility. Approve = same site (keys repointed, duplicates merged); Reject = keep separate." aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />
91 + <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />
92 + <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center">
93 + <Segmented label="Status" value={tab} onChange={(v) => setParams({ status: v === "pending" ? null : v, page: null })} options={TABS.map((t) => ({ value: t, label: t === "auto_merged" ? "Auto-merged" : t[0]!.toUpperCase() + t.slice(1), count: t === "pending" ? Number(pendingCount.meta?.total ?? (tab === "pending" ? total : 0)) : t === tab ? total : null }))} />
94 + <Select value={connector} onChange={(e) => setParams({ connector: e.target.value || null, page: null })} className="sm:w-[200px]" aria-label="Connector">
95 + <option value="">All connectors</option>
96 + {connector && !connectors.includes(connector) && <option value={connector}>{connector}</option>}
97 + {connectors.map((c) => (
98 + <option key={c} value={c}>
99 + {c}
100 + </option>
101 + ))}
102 + </Select>
103 + </div>
104 +
105 + {q.loading && !q.data ? (
106 + <div className="space-y-3">
107 + <SkeletonBlock className="h-40" />
108 + <SkeletonBlock className="h-40" />
109 + </div>
110 + ) : !q.data || q.data.length === 0 ? (
111 + <EmptyState title={tab === "pending" ? "Queue is empty" : "No matches"} description={tab === "pending" ? "New candidates appear here after connector runs when a candidate scores close to an existing facility." : undefined} />
112 + ) : (
113 + <div className="space-y-3">
114 + {q.data.map((m) => {
115 + const c = m.candidate;
116 + const f = m.matched;
117 + const cf = m.candidateFacility;
118 + const decided = gone[m.id];
119 + const score = m.score ?? 0;
120 + return (
121 + <article key={m.id} className={cn("rounded-sm border border-hair bg-surface transition-opacity", decided && "opacity-40")}>
122 + <div className="flex flex-wrap items-center gap-2 border-b border-hair px-3 py-2">
123 + <ReviewBadge status={decided ?? m.status} />
124 + <div className="flex items-center gap-1.5" title={`score ${score.toFixed(3)}`}>
125 + <div className="h-1.5 w-20 rounded-full bg-surface-3">
126 + <div className={cn("h-full rounded-full", score >= 0.9 ? "bg-ok" : score >= 0.75 ? "bg-warn" : "bg-danger")} style={{ width: `${Math.min(100, score * 100)}%` }} />
127 + </div>
128 + <span className="figure text-[12.5px] text-ink">{(score * 100).toFixed(0)}%</span>
129 + </div>
130 + <div className="flex flex-wrap gap-1">
131 + {m.reasons.map((r) => (
132 + <Badge key={r} size="xs" mono tone="outline">
133 + {r}
134 + </Badge>
135 + ))}
136 + </div>
137 + <div className="ml-auto flex items-center gap-2 text-[11.5px] text-ink-3">
138 + <Link href={`/admin/connectors/${encodeURIComponent(m.connectorId)}`} className="font-mono hover:text-accent">
139 + {m.connectorId}
140 + </Link>
141 + <RelativeTime iso={m.createdAt} />
142 + {m.decidedBy && <span>by {m.decidedBy}</span>}
143 + </div>
144 + </div>
145 + <div className="grid gap-3 px-3 py-2.5 lg:grid-cols-[1fr_auto]">
146 + <div>
147 + <div className="grid grid-cols-[84px_1fr] gap-x-2 sm:grid-cols-[84px_1fr_1fr]">
148 + <div />
149 + <div className="min-w-0">
150 + <FacilityHead f={cf} label="Candidate (from source)" id={m.candidateFacilityId} />
151 + {!cf && <div className="truncate text-[13.5px] font-semibold text-ink">{c.name ?? DASH}</div>}
152 + </div>
153 + <div className="col-start-2 min-w-0 sm:col-start-3">
154 + <FacilityHead f={f} label="Matched facility (in index)" id={m.matchedFacilityId} />
155 + </div>
156 + </div>
157 + <Row k="Name" a={c.name} b={f?.name} same={Boolean(c.name && f?.name && c.name.toLowerCase() === f.name.toLowerCase())} />
158 + <Row k="Operator" a={c.operatorName} b={f?.operator?.name} same={Boolean(c.operatorName && f?.operator?.name && c.operatorName.toLowerCase() === f.operator.name.toLowerCase())} />
159 + <Row k="Address" a={c.address ?? (c.city ? placeLine({ city: c.city, countryIso2: c.countryIso2 ?? null }) : c.countryIso2)} b={f ? placeLine({ city: f.city, regionName: f.regionName, countryIso2: f.countryIso2, countryName: f.countryName ?? undefined }) : null} />
160 + <Row k="Coordinates" a={coords(c)} b={f && f.lat != null && f.lng != null ? `${f.lat.toFixed(4)}, ${f.lng.toFixed(4)} (${f.geoPrecision})` : null} />
161 + <Row k="Capacity" a={c.itCapacityMw ?? c.totalPowerMw ?? c.plannedPowerMw ? `${fmtMw(c.itCapacityMw ?? c.totalPowerMw ?? c.plannedPowerMw)}${c.itCapacityMw ? " IT" : c.totalPowerMw ? " total" : " planned"}` : null} b={f && (f.itCapacityMw ?? f.totalPowerMw ?? f.plannedPowerMw) != null ? `${fmtMw(f.itCapacityMw ?? f.totalPowerMw ?? f.plannedPowerMw)}${f.itCapacityMw ? " IT" : f.totalPowerMw ? " total" : " planned"}` : null} />
162 + <Row k="Status" a={c.status} b={f?.status} />
163 + <Row k="Source" a={c.url ? <ExtLink href={c.url} /> : <Mono>{c.sourceId ?? DASH}</Mono>} b={f ? <Mono>{f.id}</Mono> : null} />
164 + <Row k="Key" a={<Mono>{m.candidateKey}</Mono>} b={f ? <span className="text-ink-3">confidence {f.confidence} · {f.completeness}% complete</span> : null} />
165 + </div>
166 + <div className="flex flex-row gap-2 lg:w-[150px] lg:flex-col lg:justify-center">
167 + {m.status === "pending" && !decided ? (
168 + <>
169 + <Button variant="primary" size="md" className="flex-1" onClick={() => void decide(m, "approve")} disabled={busy !== null || !m.matchedFacilityId}>
170 + Approve
171 + </Button>
172 + <Button variant="danger" size="md" className="flex-1" onClick={() => void decide(m, "reject")} disabled={busy !== null}>
173 + Reject
174 + </Button>
175 + </>
176 + ) : (
177 + <div className="text-[12px] text-ink-3">
178 + {decided ?? m.status} {m.decidedAt && <RelativeTime iso={m.decidedAt} />}
179 + </div>
180 + )}
181 + {cf && (
182 + <Button size="xs" variant="ghost" href={`/admin/facilities/${encodeURIComponent(cf.id)}/edit`}>
183 + Curate candidate
184 + </Button>
185 + )}
186 + </div>
187 + </div>
188 + </article>
189 + );
190 + })}
191 + <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />
192 + <p className="text-[11.5px] text-ink-4">{fmtInt(total)} {tab} matches.</p>
193 + </div>
194 + )}
195 + </div>
196 + );
197 +}
added apps/web/src/components/admin/ops-overview.tsx +356 −0
@@ -0,0 +1,356 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { Badge, Button, EmptyState, RelativeTime, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
5 +import { cn } from "@/components/ui/cn";
6 +import { DASH, fmtInt } from "@/lib/format";
7 +import type { OpsOverview } from "@/lib/admin-api";
8 +import { durationBetween, ErrorNote, fmtBytes, fmtMs, HealthDot, JsonView, KV, Mono, Panel, RefreshButton, RunStatusBadge, Stat } from "./primitives";
9 +import { AdminPageHeader } from "./shell";
10 +import { useToast } from "./toast";
11 +import { useAdminMutation, useAdminQuery } from "./use-admin-query";
12 +
13 +const REFRESH_MS = 30_000;
14 +
15 +function num(v: unknown): number | null {
16 + return typeof v === "number" && Number.isFinite(v) ? v : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)) ? Number(v) : null;
17 +}
18 +function str(v: unknown): string | null {
19 + return typeof v === "string" ? v : null;
20 +}
21 +
22 +/** Best-effort reading of the worker heartbeat (shape is free-form JSON published by the worker). */
23 +function readWorker(w: unknown): { heartbeat: string | null; host: string | null; pid: number | null; running: Array<Record<string, unknown>> | number | null; version: string | null; rest: Record<string, unknown> } | null {
24 + if (!w || typeof w !== "object") return null;
25 + const o = w as Record<string, unknown>;
26 + const heartbeat = str(o.heartbeat) ?? str(o.at) ?? str(o.ts) ?? str(o.updatedAt) ?? str(o.time) ?? str(o.lastHeartbeat) ?? null;
27 + const host = str(o.host) ?? str(o.hostname) ?? null;
28 + const pid = num(o.pid);
29 + const runningRaw = o.running ?? o.activeRuns ?? o.jobs ?? o.active ?? null;
30 + const running = Array.isArray(runningRaw) ? (runningRaw as Array<Record<string, unknown>>) : num(runningRaw);
31 + const version = str(o.version) ?? null;
32 + const rest: Record<string, unknown> = {};
33 + for (const [k, v] of Object.entries(o)) if (!["heartbeat", "at", "ts", "updatedAt", "time", "lastHeartbeat", "host", "hostname", "pid", "running", "activeRuns", "jobs", "active", "version"].includes(k)) rest[k] = v;
34 + return { heartbeat, host, pid, running, version, rest };
35 +}
36 +
37 +function BudgetBar({ label, used, limit }: { label: string; used: number; limit: number }) {
38 + const share = limit > 0 ? Math.min(1, used / limit) : 0;
39 + const tone = share >= 0.9 ? "bg-danger" : share >= 0.6 ? "bg-warn" : "bg-ink-2";
40 + return (
41 + <div>
42 + <div className="flex items-baseline justify-between text-[12.5px]">
43 + <span className="text-ink-2">{label}</span>
44 + <span className="figure text-ink">
45 + {fmtInt(used)} <span className="text-ink-4">/ {fmtInt(limit)} credits</span>
46 + </span>
47 + </div>
48 + <div className="mt-1 h-1.5 w-full rounded-full bg-surface-3">
49 + <div className={cn("h-full rounded-full", tone)} style={{ width: `${share * 100}%` }} />
50 + </div>
51 + </div>
52 + );
53 +}
54 +
55 +export function OpsOverviewView() {
56 + const q = useAdminQuery<OpsOverview>("ops", { refreshMs: REFRESH_MS, timeoutMs: 20_000 });
57 + const { busy, run } = useAdminMutation();
58 + const toast = useToast();
59 + const d = q.data;
60 + const worker = readWorker(d?.worker);
61 + // age measured against the time the ops payload arrived (state) — keeps render pure
62 + const hbAge = worker?.heartbeat && q.updatedAt ? q.updatedAt - new Date(worker.heartbeat).getTime() : null;
63 + const workerOk = worker ? (hbAge == null ? true : hbAge < 90_000) : false;
64 + const budgets = d?.budgets;
65 + const used = budgets?.used ?? {};
66 + const scrapflyUsed = used.scrapfly?.credits ?? (typeof budgets?.["dci:budget:scrapfly"] === "string" ? Number(budgets["dci:budget:scrapfly"]) : 0) ?? 0;
67 + const firecrawlUsed = used.firecrawl?.credits ?? (typeof budgets?.["dci:budget:firecrawl"] === "string" ? Number(budgets["dci:budget:firecrawl"]) : 0) ?? 0;
68 +
69 + const maintenance = async (task: "rankings" | "metrics" | "refresh-stats") => {
70 + const r = await run<{ enqueued: boolean; job?: { id?: string } }>(`m:${task}`, `maintenance/${task}`, { body: {} });
71 + if (r.ok) toast({ tone: "ok", title: `Enqueued ${task}`, description: r.data.job?.id ? `job ${r.data.job.id}` : undefined });
72 + else toast({ tone: "danger", title: `Could not enqueue ${task}`, description: r.error });
73 + };
74 + const invalidate = async () => {
75 + const r = await run<{ invalidated: number }>("cache", "cache/invalidate", { body: { prefix: "" } });
76 + if (r.ok) toast({ tone: "ok", title: "API cache invalidated", description: `${fmtInt(r.data.invalidated)} entries` });
77 + else toast({ tone: "danger", title: "Cache invalidation failed", description: r.error });
78 + };
79 + const resolve = async (id: string) => {
80 + const r = await run<{ resolved: boolean }>(`alert:${id}`, `alerts/${id}/resolve`, { body: {} });
81 + if (r.ok) {
82 + q.mutate((prev) => (prev ? { ...prev, alerts: prev.alerts.filter((a) => a.id !== id) } : prev));
83 + toast({ tone: "ok", title: "Alert resolved" });
84 + } else toast({ tone: "danger", title: "Could not resolve alert", description: r.error });
85 + };
86 +
87 + return (
88 + <div>
89 + <AdminPageHeader title="Operations" description="Worker, queues, premium budgets, storage health and the latest runs. Auto-refreshes every 30 s." aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />
90 + <ErrorNote error={q.error && !d ? `Ops endpoint unavailable: ${q.error}` : null} onRetry={() => void q.refresh()} className="mb-3" />
91 +
92 + {/* health strip */}
93 + <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
94 + <Stat label="Worker" tone={d ? (workerOk ? "ok" : "danger") : undefined} value={d ? (worker ? (workerOk ? "alive" : "stale") : "offline") : "…"} note={worker?.heartbeat ? <RelativeTime iso={worker.heartbeat} /> : d ? "no heartbeat key" : undefined} />
95 + <Stat label="Postgres" tone={d ? "ok" : undefined} value={d?.db.sizePretty ?? "…"} note={d ? `${fmtInt(d.db.tables.reduce((a, t) => a + t.rows, 0))} rows (est.)` : undefined} />
96 + <Stat label="Redis" tone={d ? (d.redis.ok ? "ok" : "danger") : undefined} value={d ? (d.redis.ok ? "ok" : "down") : "…"} note={d ? `cache ${fmtInt(d.apiCache.entries)}/${fmtInt(d.apiCache.max)}` : undefined} />
97 + <Stat label="ClickHouse" tone={d ? (d.clickhouse.ok ? "ok" : "danger") : undefined} value={d ? (d.clickhouse.ok ? "ok" : "down") : "…"} note={d ? <Mono>{d.clickhouse.url.replace(/^https?:\/\//, "")}</Mono> : undefined} />
98 + <Stat label="MinIO" tone={d ? (d.minio.ok ? "ok" : "danger") : undefined} value={d ? (d.minio.ok ? "ok" : "down") : "…"} note={d ? <Mono>{d.minio.bucket}</Mono> : undefined} />
99 + <Stat label="Open alerts" tone={d ? (d.alerts.length ? "warn" : "ok") : undefined} value={d ? fmtInt(d.alerts.length) : "…"} note={d?.alerts.length ? `${d.alerts.filter((a) => a.level === "error" || a.level === "critical").length} error-level` : undefined} />
100 + </div>
101 +
102 + <div className="grid gap-3 lg:grid-cols-3">
103 + <Panel title="Worker" description="Heartbeat published in Redis (dci:worker:status)">
104 + {!d ? (
105 + <SkeletonRows rows={3} />
106 + ) : !worker ? (
107 + <EmptyState compact title="No worker heartbeat" description="The worker process is not publishing its status. Jobs will queue until it starts." />
108 + ) : (
109 + <div className="space-y-2">
110 + <KV
111 + items={[
112 + { k: "Heartbeat", v: worker.heartbeat ? <RelativeTime iso={worker.heartbeat} /> : DASH },
113 + { k: "Host", v: worker.host ? <Mono>{worker.host}</Mono> : DASH },
114 + { k: "PID", v: worker.pid != null ? <Mono>{worker.pid}</Mono> : DASH },
115 + { k: "Version", v: worker.version ? <Mono>{worker.version}</Mono> : DASH },
116 + { k: "Running jobs", v: Array.isArray(worker.running) ? fmtInt(worker.running.length) : worker.running != null ? fmtInt(worker.running) : DASH },
117 + ]}
118 + />
119 + {Array.isArray(worker.running) && worker.running.length > 0 && (
120 + <ul className="space-y-1 text-[12px]">
121 + {worker.running.slice(0, 8).map((j, i) => (
122 + <li key={i} className="flex items-center gap-2">
123 + <HealthDot health="running" />
124 + <Mono>{str(j.connectorId) ?? str(j.id) ?? JSON.stringify(j).slice(0, 60)}</Mono>
125 + {str(j.task) && <Badge size="xs">{str(j.task)}</Badge>}
126 + </li>
127 + ))}
128 + </ul>
129 + )}
130 + {Object.keys(worker.rest).length > 0 && <JsonView value={worker.rest} maxHeight={160} collapsible />}
131 + </div>
132 + )}
133 + </Panel>
134 +
135 + <Panel title="Queues" description="BullMQ counts (prefix dci)" padded={false}>
136 + {!d ? (
137 + <div className="p-3">
138 + <SkeletonRows rows={3} />
139 + </div>
140 + ) : (
141 + <Table responsive={false} className="text-[12.5px]">
142 + <THead>
143 + <Tr>
144 + <Th className="pl-3">Queue</Th>
145 + <Th numeric>Wait</Th>
146 + <Th numeric>Prio</Th>
147 + <Th numeric>Active</Th>
148 + <Th numeric>Delay</Th>
149 + <Th numeric>Done</Th>
150 + <Th numeric className="pr-3">
151 + Failed
152 + </Th>
153 + </Tr>
154 + </THead>
155 + <TBody>
156 + {Object.entries(d.queues).map(([name, c]) => (
157 + <Tr key={name}>
158 + <Td className="pl-3">
159 + <Mono>{name.replace(/^dci:/, "")}</Mono>
160 + </Td>
161 + <Td numeric>{fmtInt(c.waiting ?? 0)}</Td>
162 + <Td numeric>{fmtInt(c.prioritized ?? 0)}</Td>
163 + <Td numeric className={cn((c.active ?? 0) > 0 && "text-accent")}>
164 + {fmtInt(c.active ?? 0)}
165 + </Td>
166 + <Td numeric>{fmtInt(c.delayed ?? 0)}</Td>
167 + <Td numeric muted>
168 + {fmtInt(c.completed ?? 0)}
169 + </Td>
170 + <Td numeric className={cn("pr-3", (c.failed ?? 0) > 0 && "text-danger")}>
171 + {fmtInt(c.failed ?? 0)}
172 + </Td>
173 + </Tr>
174 + ))}
175 + {Object.keys(d.queues).length === 0 && (
176 + <Tr>
177 + <Td colSpan={7} className="px-3 py-4 text-center text-ink-3">
178 + No queue data (Redis unreachable?)
179 + </Td>
180 + </Tr>
181 + )}
182 + </TBody>
183 + </Table>
184 + )}
185 + </Panel>
186 +
187 + <Panel title="Premium budgets" description={d ? `Today ${budgets?.day ?? ""} · source ${budgets?.source ?? DASH}` : undefined}>
188 + {!d ? (
189 + <SkeletonRows rows={2} />
190 + ) : (
191 + <div className="space-y-3">
192 + <BudgetBar label="Scrapfly (L4)" used={scrapflyUsed} limit={budgets?.limits.scrapfly ?? 0} />
193 + <BudgetBar label="Firecrawl (L3)" used={firecrawlUsed} limit={budgets?.limits.firecrawl ?? 0} />
194 + <KV
195 + items={[
196 + { k: "Direct requests today", v: used.direct ? fmtInt(used.direct.requests) : DASH },
197 + { k: "Firecrawl requests", v: used.firecrawl ? fmtInt(used.firecrawl.requests) : DASH },
198 + { k: "Scrapfly requests", v: used.scrapfly ? fmtInt(used.scrapfly.requests) : DASH },
199 + ]}
200 + />
201 + </div>
202 + )}
203 + </Panel>
204 + </div>
205 +
206 + <div className="mt-3 grid gap-3 lg:grid-cols-3">
207 + <Panel
208 + title="System alerts"
209 + description="Open alerts from the worker doctor"
210 + className="lg:col-span-2"
211 + padded={false}
212 + aside={
213 + d && d.alerts.length > 0 ? (
214 + <Badge tone="warn" size="xs">
215 + {fmtInt(d.alerts.length)} open
216 + </Badge>
217 + ) : undefined
218 + }
219 + >
220 + {!d ? (
221 + <div className="p-3">
222 + <SkeletonRows rows={2} />
223 + </div>
224 + ) : d.alerts.length === 0 ? (
225 + <EmptyState compact title="No open alerts" />
226 + ) : (
227 + <ul className="divide-y divide-hair">
228 + {d.alerts.map((a) => (
229 + <li key={a.id} className="flex flex-col gap-1 px-3 py-2 sm:flex-row sm:items-start sm:gap-3">
230 + <div className="flex shrink-0 items-center gap-2 sm:w-[190px]">
231 + <Badge tone={a.level === "error" || a.level === "critical" ? "danger" : a.level === "warn" ? "warn" : "info"} size="xs">
232 + {a.level}
233 + </Badge>
234 + <Mono title={a.component}>{a.component}</Mono>
235 + </div>
236 + <div className="min-w-0 flex-1 text-[12.5px] text-ink-2">
237 + <div className="break-words">{a.message}</div>
238 + <div className="mt-0.5 text-[11px] text-ink-4">
239 + <RelativeTime iso={a.createdAt} />
240 + </div>
241 + </div>
242 + <Button size="xs" variant="ghost" onClick={() => void resolve(a.id)} disabled={busy === `alert:${a.id}`}>
243 + Resolve
244 + </Button>
245 + </li>
246 + ))}
247 + </ul>
248 + )}
249 + </Panel>
250 +
251 + <Panel title="Quick actions">
252 + <div className="flex flex-wrap gap-2">
253 + <Button size="sm" href="/admin/connectors">
254 + Connectors
255 + </Button>
256 + <Button size="sm" href="/admin/documents?status=error">
257 + Failed documents
258 + </Button>
259 + <Button size="sm" href="/admin/matches">
260 + Match queue
261 + </Button>
262 + <Button size="sm" href="/admin/devtool">
263 + Dev tool
264 + </Button>
265 + </div>
266 + <div className="label-caps mb-1.5 mt-4">Maintenance jobs</div>
267 + <div className="flex flex-wrap gap-2">
268 + {(["rankings", "metrics", "refresh-stats"] as const).map((t) => (
269 + <Button key={t} size="xs" variant="secondary" onClick={() => void maintenance(t)} disabled={busy === `m:${t}`}>
270 + {t}
271 + </Button>
272 + ))}
273 + <Button size="xs" variant="ghost" onClick={() => void invalidate()} disabled={busy === "cache"}>
274 + Invalidate API cache
275 + </Button>
276 + </div>
277 + {d && (
278 + <>
279 + <div className="label-caps mb-1.5 mt-4">Largest tables</div>
280 + <KV items={d.db.tables.slice(0, 8).map((t) => ({ k: <Mono>{t.table}</Mono>, v: <span className="figure">{fmtInt(t.rows)} rows · {fmtBytes(t.bytes)}</span> }))} />
281 + </>
282 + )}
283 + </Panel>
284 + </div>
285 +
286 + <Panel title="Last 20 runs" className="mt-3" padded={false} aside={<Button size="xs" variant="ghost" href="/admin/connectors">All connectors</Button>}>
287 + {!d ? (
288 + <div className="p-3">
289 + <SkeletonRows rows={6} />
290 + </div>
291 + ) : d.lastRuns.length === 0 ? (
292 + <EmptyState compact title="No runs yet" />
293 + ) : (
294 + <Table className="text-[12.5px]">
295 + <THead>
296 + <Tr>
297 + <Th className="pl-3">Connector</Th>
298 + <Th>Task</Th>
299 + <Th>Status</Th>
300 + <Th>Started</Th>
301 + <Th numeric>Duration</Th>
302 + <Th numeric>Fetched</Th>
303 + <Th numeric>Changed</Th>
304 + <Th numeric>Entities</Th>
305 + <Th numeric className="pr-3">
306 + Failed
307 + </Th>
308 + </Tr>
309 + </THead>
310 + <TBody>
311 + {d.lastRuns.map((r) => {
312 + const s = r.stats ?? {};
313 + return (
314 + <Tr key={r.id}>
315 + <Td primary label="Connector" className="pl-3">
316 + <Link href={`/admin/connectors/${encodeURIComponent(r.connectorId)}`} className="font-mono text-[12.5px] font-medium text-ink hover:text-accent hover:underline">
317 + {r.connectorId}
318 + </Link>
319 + {r.error && <div className="mt-0.5 truncate text-[11.5px] text-danger sm:max-w-[320px]" title={r.error}>{r.error}</div>}
320 + </Td>
321 + <Td label="Task">
322 + <Badge size="xs" mono>
323 + {r.task}
324 + </Badge>
325 + </Td>
326 + <Td label="Status">
327 + <RunStatusBadge status={r.status} />
328 + </Td>
329 + <Td label="Started" muted>
330 + <RelativeTime iso={r.startedAt} />
331 + </Td>
332 + <Td label="Duration" numeric>
333 + {fmtMs(durationBetween(r.startedAt, r.finishedAt))}
334 + </Td>
335 + <Td label="Fetched" numeric>
336 + {num(s.fetched) != null ? fmtInt(num(s.fetched)) : DASH}
337 + </Td>
338 + <Td label="Changed" numeric>
339 + {num(s.changed) != null ? fmtInt(num(s.changed)) : DASH}
340 + </Td>
341 + <Td label="Entities" numeric>
342 + {num(s.entities) != null ? fmtInt(num(s.entities)) : DASH}
343 + </Td>
344 + <Td label="Failed" numeric className={cn("pr-3", (num(s.failed) ?? 0) > 0 && "text-danger")}>
345 + {num(s.failed) != null ? fmtInt(num(s.failed)) : DASH}
346 + </Td>
347 + </Tr>
348 + );
349 + })}
350 + </TBody>
351 + </Table>
352 + )}
353 + </Panel>
354 + </div>
355 + );
356 +}
added apps/web/src/components/admin/primitives.tsx +444 −0
@@ -0,0 +1,444 @@
1 +"use client";
2 +
3 +import { ChevronLeft, ChevronRight, ExternalLink, Loader2, RefreshCw } from "lucide-react";
4 +import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
5 +import { useMemo, useState } from "react";
6 +import { Badge, Button, Card, CardBody, CardHeader, type BadgeTone } from "@/components/ui";
7 +import { cn } from "@/components/ui/cn";
8 +import { DASH, fmtInt } from "@/lib/format";
9 +import { collapseDiff, diffLinesLcs, yamlTokens, type YamlTokenKind } from "./yaml";
10 +
11 +/* ------------------------------------------------------------------ formatting helpers */
12 +
13 +export function fmtMs(ms: number | null | undefined): string {
14 + if (ms == null || !Number.isFinite(ms)) return DASH;
15 + if (ms < 1000) return `${Math.round(ms)} ms`;
16 + if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
17 + const m = Math.floor(ms / 60_000);
18 + const s = Math.round((ms % 60_000) / 1000);
19 + return `${m} min ${s.toString().padStart(2, "0")} s`;
20 +}
21 +
22 +export function fmtBytes(b: number | null | undefined): string {
23 + if (b == null || !Number.isFinite(b)) return DASH;
24 + if (b < 1024) return `${b} B`;
25 + if (b < 1024 * 1024) return `${(b / 1024).toFixed(b < 10 * 1024 ? 1 : 0)} kB`;
26 + if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
27 + return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
28 +}
29 +
30 +export function fmtPct01(v: number | null | undefined, digits = 0): string {
31 + if (v == null || !Number.isFinite(v)) return DASH;
32 + return `${(v * 100).toFixed(digits)}%`;
33 +}
34 +
35 +export function shortUrl(u: string | null | undefined, max = 72): string {
36 + if (!u) return DASH;
37 + const s = u.replace(/^https?:\/\//, "");
38 + return s.length > max ? `${s.slice(0, max - 1)}…` : s;
39 +}
40 +
41 +export function durationBetween(a: string | null | undefined, b: string | null | undefined): number | null {
42 + if (!a) return null;
43 + const t0 = new Date(a).getTime();
44 + const t1 = b ? new Date(b).getTime() : Date.now();
45 + if (Number.isNaN(t0) || Number.isNaN(t1)) return null;
46 + return Math.max(0, t1 - t0);
47 +}
48 +
49 +/* ------------------------------------------------------------------ status */
50 +
51 +export type HealthKey = "ok" | "degraded" | "failing" | "paused" | "never_run" | string;
52 +const HEALTH: Record<string, { color: string; label: string; hollow?: boolean }> = {
53 + ok: { color: "var(--ok)", label: "Healthy" },
54 + degraded: { color: "var(--warn)", label: "Degraded" },
55 + failing: { color: "var(--danger)", label: "Failing" },
56 + paused: { color: "var(--ink-4)", label: "Paused" },
57 + never_run: { color: "var(--ink-4)", label: "Never run", hollow: true },
58 + running: { color: "var(--accent)", label: "Running" },
59 +};
60 +
61 +export function HealthDot({ health, size = 8, className, withLabel }: { health: HealthKey; size?: number; className?: string; withLabel?: boolean }) {
62 + const h = HEALTH[health] ?? { color: "var(--ink-4)", label: health.replace(/_/g, " ") };
63 + return (
64 + <span className={cn("inline-flex items-center gap-1.5", className)} title={h.label}>
65 + <span aria-hidden className={cn("inline-block shrink-0 rounded-full", health === "running" && "animate-pulse-soft")} style={{ width: size, height: size, background: h.hollow ? "transparent" : h.color, border: h.hollow ? `1.5px solid ${h.color}` : undefined }} />
66 + {withLabel ? <span className="text-[12px] text-ink-2">{h.label}</span> : <span className="sr-only">{h.label}</span>}
67 + </span>
68 + );
69 +}
70 +
71 +const RUN_TONE: Record<string, BadgeTone> = { ok: "ok", success: "ok", completed: "ok", running: "accent", queued: "info", pending: "info", failed: "danger", error: "danger", aborted: "warn", partial: "warn", degraded: "warn" };
72 +export function RunStatusBadge({ status, size = "xs" }: { status: string | null | undefined; size?: "xs" | "sm" }) {
73 + if (!status) return <span className="text-ink-4">{DASH}</span>;
74 + return (
75 + <Badge tone={RUN_TONE[status] ?? "neutral"} size={size} mono>
76 + {status}
77 + </Badge>
78 + );
79 +}
80 +
81 +const REVIEW_TONE: Record<string, BadgeTone> = { approved: "ok", rejected: "danger", pending: "warn", auto: "neutral", auto_merged: "info", auto_created: "neutral" };
82 +export function ReviewBadge({ status, size = "xs" }: { status: string; size?: "xs" | "sm" }) {
83 + return (
84 + <Badge tone={REVIEW_TONE[status] ?? "neutral"} size={size}>
85 + {status.replace(/_/g, " ")}
86 + </Badge>
87 + );
88 +}
89 +
90 +export function LevelBadge({ level }: { level: number | null | undefined }) {
91 + if (level == null) return <span className="text-ink-4">{DASH}</span>;
92 + const label = ["", "L1 direct", "L2 browser UA", "L3 Firecrawl", "L4 Scrapfly"][level] ?? `L${level}`;
93 + return (
94 + <Badge tone={level >= 3 ? "accent" : "outline"} size="xs" mono title={label}>
95 + L{level}
96 + </Badge>
97 + );
98 +}
99 +
100 +/* ------------------------------------------------------------------ text */
101 +
102 +export function Mono({ children, className, title, wrap }: { children: ReactNode; className?: string; title?: string; wrap?: boolean }) {
103 + return (
104 + <span className={cn("font-mono text-[12px] text-ink-2", wrap ? "break-all" : "truncate", className)} title={title}>
105 + {children}
106 + </span>
107 + );
108 +}
109 +
110 +export function ExtLink({ href, children, className }: { href: string; children?: ReactNode; className?: string }) {
111 + return (
112 + <a href={href} target="_blank" rel="noopener noreferrer" className={cn("inline-flex min-w-0 items-center gap-1 font-mono text-[12px] text-ink-2 hover:text-accent hover:underline", className)} title={href}>
113 + <span className="truncate">{children ?? shortUrl(href)}</span>
114 + <ExternalLink className="size-3 shrink-0 text-ink-4" />
115 + </a>
116 + );
117 +}
118 +
119 +/** Key / value rows (definition list). */
120 +export function KV({ items, className, cols = 1 }: { items: Array<{ k: ReactNode; v: ReactNode; title?: string } | null | false | undefined>; className?: string; cols?: 1 | 2 | 3 }) {
121 + return (
122 + <dl className={cn("grid gap-x-4 gap-y-1.5 text-[12.5px]", cols === 2 && "sm:grid-cols-2", cols === 3 && "sm:grid-cols-3", className)}>
123 + {items.filter(Boolean).map((it, i) => {
124 + const item = it as { k: ReactNode; v: ReactNode; title?: string };
125 + return (
126 + <div key={i} className="flex min-w-0 items-baseline justify-between gap-3 border-b border-hair/70 py-1 last:border-0" title={item.title}>
127 + <dt className="shrink-0 text-ink-3">{item.k}</dt>
128 + <dd className="min-w-0 truncate text-right text-ink">{item.v ?? <span className="text-ink-4">{DASH}</span>}</dd>
129 + </div>
130 + );
131 + })}
132 + </dl>
133 + );
134 +}
135 +
136 +export function Stat({ label, value, note, tone, className }: { label: ReactNode; value: ReactNode; note?: ReactNode; tone?: "ok" | "warn" | "danger"; className?: string }) {
137 + return (
138 + <div className={cn("min-w-0 border-l-2 pl-2.5", tone === "ok" ? "border-ok" : tone === "warn" ? "border-warn" : tone === "danger" ? "border-danger" : "border-hair-2", className)}>
139 + <div className="label-caps">{label}</div>
140 + <div className="figure text-[18px] leading-tight text-ink">{value}</div>
141 + {note && <div className="mt-0.5 text-[11.5px] text-ink-3">{note}</div>}
142 + </div>
143 + );
144 +}
145 +
146 +/* ------------------------------------------------------------------ JSON / code */
147 +
148 +function jsonHighlight(json: string): ReactNode[] {
149 + const re = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;
150 + const out: ReactNode[] = [];
151 + let last = 0;
152 + let m: RegExpExecArray | null;
153 + let i = 0;
154 + while ((m = re.exec(json))) {
155 + if (m.index > last) out.push(json.slice(last, m.index));
156 + const t = m[0];
157 + const cls = /^"/.test(t) ? (/:$/.test(t) ? "text-ink" : "text-st-operational") : /true|false|null/.test(t) ? "text-st-permitting" : "text-st-planned";
158 + out.push(
159 + <span key={i++} className={cls}>
160 + {t}
161 + </span>,
162 + );
163 + last = m.index + t.length;
164 + }
165 + if (last < json.length) out.push(json.slice(last));
166 + return out;
167 +}
168 +
169 +export function JsonView({ value, className, maxHeight = 320, collapsible }: { value: unknown; className?: string; maxHeight?: number | null; collapsible?: boolean }) {
170 + const [open, setOpen] = useState(!collapsible);
171 + const text = useMemo(() => {
172 + try {
173 + return JSON.stringify(value, null, 2) ?? "undefined";
174 + } catch {
175 + return String(value);
176 + }
177 + }, [value]);
178 + if (collapsible && !open) {
179 + return (
180 + <button type="button" onClick={() => setOpen(true)} className="font-mono text-[12px] text-ink-3 hover:text-ink">
181 + {text.length > 90 ? `${text.slice(0, 90)}…` : text} <span className="text-accent">expand</span>
182 + </button>
183 + );
184 + }
185 + return (
186 + <pre className={cn("overflow-auto rounded-xs border border-hair bg-paper px-2.5 py-2 font-mono text-[11.5px] leading-[1.5] text-ink-2", className)} style={maxHeight ? { maxHeight } : undefined}>
187 + {jsonHighlight(text)}
188 + </pre>
189 + );
190 +}
191 +
192 +const YAML_CLS: Record<YamlTokenKind, string> = { key: "text-ink font-medium", string: "text-st-operational", number: "text-st-planned", bool: "text-st-permitting", comment: "text-ink-4 italic", punct: "text-ink-3", text: "text-ink-2", anchor: "text-accent" };
193 +
194 +/** Line-numbered code block. `lang="yaml"` highlights; `search` highlights matches (case-insensitive). */
195 +export function Code({ text, lang = "text", className, maxHeight = 480, search, wrap, startLine = 1 }: { text: string; lang?: "yaml" | "text" | "html"; className?: string; maxHeight?: number | null; search?: string; wrap?: boolean; startLine?: number }) {
196 + const lines = useMemo(() => text.split("\n"), [text]);
197 + const needle = search?.trim().toLowerCase() ?? "";
198 + const render = (line: string) => {
199 + if (needle) {
200 + const parts: ReactNode[] = [];
201 + let idx = 0;
202 + const lower = line.toLowerCase();
203 + let hit = lower.indexOf(needle, idx);
204 + let k = 0;
205 + while (hit >= 0) {
206 + if (hit > idx) parts.push(line.slice(idx, hit));
207 + parts.push(
208 + <mark key={k++} className="rounded-[1px] bg-accent/30 text-ink">
209 + {line.slice(hit, hit + needle.length)}
210 + </mark>,
211 + );
212 + idx = hit + needle.length;
213 + hit = lower.indexOf(needle, idx);
214 + }
215 + if (idx < line.length) parts.push(line.slice(idx));
216 + return parts;
217 + }
218 + if (lang === "yaml") return yamlTokens(line).map((t, i) => (
219 + <span key={i} className={YAML_CLS[t.kind]}>
220 + {t.text}
221 + </span>
222 + ));
223 + return line;
224 + };
225 + return (
226 + <div className={cn("overflow-auto rounded-xs border border-hair bg-paper", className)} style={maxHeight ? { maxHeight } : undefined}>
227 + <table className="w-full border-collapse font-mono text-[11.5px] leading-[1.55]">
228 + <tbody>
229 + {lines.map((l, i) => {
230 + const match = needle && l.toLowerCase().includes(needle);
231 + return (
232 + <tr key={i} className={cn(match && "bg-accent/5")}>
233 + <td className="select-none border-r border-hair px-2 text-right align-top text-ink-4 tnum" style={{ width: 1 }}>
234 + {i + startLine}
235 + </td>
236 + <td className={cn("px-2.5 align-top text-ink-2", wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre")}>{render(l) || " "}</td>
237 + </tr>
238 + );
239 + })}
240 + </tbody>
241 + </table>
242 + </div>
243 + );
244 +}
245 +
246 +export function countMatches(text: string, needle: string): number {
247 + const n = needle.trim().toLowerCase();
248 + if (!n) return 0;
249 + let c = 0;
250 + let i = text.toLowerCase().indexOf(n);
251 + while (i >= 0 && c < 100_000) {
252 + c++;
253 + i = text.toLowerCase().indexOf(n, i + n.length);
254 + }
255 + return c;
256 +}
257 +
258 +/** Unified line diff (LCS) with collapsed context. */
259 +export function DiffView({ before, after, context = 3, className, maxHeight = 560, labels }: { before: string; after: string; context?: number; className?: string; maxHeight?: number | null; labels?: [string, string] }) {
260 + const ops = useMemo(() => collapseDiff(diffLinesLcs(before, after), context), [before, after, context]);
261 + const added = ops.filter((o) => o.type === "add").length;
262 + const removed = ops.filter((o) => o.type === "del").length;
263 + return (
264 + <div className={className}>
265 + <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">
266 + <span className="font-mono text-st-operational">+{added}</span>
267 + <span className="font-mono text-danger">−{removed}</span>
268 + {labels && (
269 + <span className="ml-auto truncate">
270 + {labels[0]} → {labels[1]}
271 + </span>
272 + )}
273 + </div>
274 + {added === 0 && removed === 0 ? (
275 + <div className="rounded-xs border border-hair bg-paper px-3 py-4 text-center text-[12.5px] text-ink-3">No line differences.</div>
276 + ) : (
277 + <div className="overflow-auto rounded-xs border border-hair bg-paper" style={maxHeight ? { maxHeight } : undefined}>
278 + <table className="w-full border-collapse font-mono text-[11.5px] leading-[1.5]">
279 + <tbody>
280 + {ops.map((o, i) =>
281 + o.type === "skip" ? (
282 + <tr key={i} className="bg-surface-2/60 text-ink-4">
283 + <td colSpan={3} className="px-2.5 py-0.5 text-center text-[11px]">
284 + ··· {o.count} unchanged lines ···
285 + </td>
286 + </tr>
287 + ) : (
288 + <tr key={i} className={cn(o.type === "add" && "bg-ok/10", o.type === "del" && "bg-danger/10")}>
289 + <td className="select-none border-r border-hair px-1.5 text-right text-ink-4 tnum" style={{ width: 1 }}>
290 + {o.type !== "add" ? o.a : ""}
291 + </td>
292 + <td className="select-none border-r border-hair px-1.5 text-right text-ink-4 tnum" style={{ width: 1 }}>
293 + {o.type !== "del" ? o.b : ""}
294 + </td>
295 + <td className={cn("whitespace-pre-wrap break-all px-2.5 align-top", o.type === "add" ? "text-st-operational" : o.type === "del" ? "text-danger" : "text-ink-2")}>
296 + <span className="mr-1.5 select-none text-ink-4">{o.type === "add" ? "+" : o.type === "del" ? "−" : " "}</span>
297 + {o.text || " "}
298 + </td>
299 + </tr>
300 + ),
301 + )}
302 + </tbody>
303 + </table>
304 + </div>
305 + )}
306 + </div>
307 + );
308 +}
309 +
310 +/* ------------------------------------------------------------------ forms */
311 +
312 +const control = "h-9 w-full rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink outline-none placeholder:text-ink-4 focus:border-ink disabled:opacity-60";
313 +
314 +export function Field({ label, hint, error, children, className, inline }: { label: ReactNode; hint?: ReactNode; error?: ReactNode; children: ReactNode; className?: string; inline?: boolean }) {
315 + return (
316 + <label className={cn("block min-w-0", inline && "flex items-center gap-2", className)}>
317 + <span className={cn("label-caps block", inline ? "mb-0" : "mb-1")}>{label}</span>
318 + {children}
319 + {hint && !error && <span className="mt-1 block text-[11.5px] text-ink-4">{hint}</span>}
320 + {error && <span className="mt-1 block text-[11.5px] text-danger">{error}</span>}
321 + </label>
322 + );
323 +}
324 +
325 +export function Input({ className, mono, ...rest }: InputHTMLAttributes<HTMLInputElement> & { mono?: boolean }) {
326 + return <input className={cn(control, mono && "font-mono text-[12.5px]", className)} {...rest} />;
327 +}
328 +
329 +export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
330 + return (
331 + <select className={cn(control, "appearance-none bg-[length:12px] bg-[right_8px_center] bg-no-repeat pr-7", className)} style={{ backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7178' stroke-width='2'><path d='m6 9 6 6 6-6'/></svg>\")" }} {...rest}>
332 + {children}
333 + </select>
334 + );
335 +}
336 +
337 +export function Textarea({ className, mono, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement> & { mono?: boolean; ref?: React.Ref<HTMLTextAreaElement> }) {
338 + return <textarea className={cn("w-full rounded-sm border border-hair-2 bg-surface px-2.5 py-2 text-[13px] leading-[1.5] text-ink outline-none placeholder:text-ink-4 focus:border-ink disabled:opacity-60", mono && "font-mono text-[12px]", className)} spellCheck={false} {...rest} />;
339 +}
340 +
341 +export function Checkbox({ label, className, ...rest }: InputHTMLAttributes<HTMLInputElement> & { label: ReactNode }) {
342 + return (
343 + <label className={cn("inline-flex min-h-9 cursor-pointer items-center gap-2 text-[13px] text-ink-2", className)}>
344 + <input type="checkbox" className="size-4 accent-[var(--accent)]" {...rest} />
345 + <span>{label}</span>
346 + </label>
347 + );
348 +}
349 +
350 +/** Small segmented control for filters / tabs (button-based, controlled). */
351 +export function Segmented<T extends string>({ value, onChange, options, className, label }: { value: T; onChange: (v: T) => void; options: Array<{ value: T; label: ReactNode; count?: number | null }>; className?: string; label?: string }) {
352 + return (
353 + <div role="tablist" aria-label={label} className={cn("inline-flex max-w-full overflow-x-auto rounded-sm border border-hair-2 bg-surface no-scrollbar", className)}>
354 + {options.map((o) => {
355 + const active = o.value === value;
356 + return (
357 + <button key={o.value} type="button" role="tab" aria-selected={active} onClick={() => onChange(o.value)} className={cn("inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap border-r border-hair-2 px-2.5 text-[12.5px] last:border-r-0", active ? "bg-ink text-paper" : "text-ink-2 hover:bg-surface-2")}>
358 + {o.label}
359 + {o.count != null && <span className={cn("figure text-[11px]", active ? "text-paper/70" : "text-ink-4")}>{fmtInt(o.count)}</span>}
360 + </button>
361 + );
362 + })}
363 + </div>
364 + );
365 +}
366 +
367 +/* ------------------------------------------------------------------ layout */
368 +
369 +export function Panel({ title, description, aside, children, className, padded = true, id }: { title: ReactNode; description?: ReactNode; aside?: ReactNode; children: ReactNode; className?: string; padded?: boolean; id?: string }) {
370 + return (
371 + <Card className={cn("min-w-0", className)} id={id}>
372 + <CardHeader title={title} description={description} aside={aside} />
373 + <CardBody padded={padded}>{children}</CardBody>
374 + </Card>
375 + );
376 +}
377 +
378 +export function Spinner({ className }: { className?: string }) {
379 + return <Loader2 className={cn("size-4 animate-spin text-ink-3", className)} aria-label="Loading" />;
380 +}
381 +
382 +export function RefreshButton({ onClick, busy, updatedAt, label = "Refresh" }: { onClick: () => void; busy?: boolean; updatedAt?: number | null; label?: string }) {
383 + return (
384 + <div className="flex items-center gap-2">
385 + {updatedAt && <span className="hidden text-[11.5px] text-ink-4 sm:inline">updated {new Date(updatedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</span>}
386 + <Button size="xs" variant="ghost" onClick={onClick} disabled={busy} aria-label={label}>
387 + <RefreshCw className={cn("size-3.5", busy && "animate-spin")} strokeWidth={1.75} />
388 + <span className="hidden sm:inline">{label}</span>
389 + </Button>
390 + </div>
391 + );
392 +}
393 +
394 +export function ErrorNote({ error, onRetry, className }: { error: string | null | undefined; onRetry?: () => void; className?: string }) {
395 + if (!error) return null;
396 + return (
397 + <div role="alert" className={cn("flex flex-wrap items-center gap-2 rounded-xs border border-danger/30 bg-danger/10 px-2.5 py-2 text-[12.5px] text-danger", className)}>
398 + <span className="min-w-0 flex-1 break-words">{error}</span>
399 + {onRetry && (
400 + <Button size="xs" variant="ghost" onClick={onRetry}>
401 + Retry
402 + </Button>
403 + )}
404 + </div>
405 + );
406 +}
407 +
408 +/** Button-driven pager (client state). */
409 +export function Pager({ page, perPage, total, onPage, className }: { page: number; perPage: number; total: number; onPage: (p: number) => void; className?: string }) {
410 + const pages = Math.max(1, Math.ceil(total / perPage));
411 + const from = total === 0 ? 0 : (page - 1) * perPage + 1;
412 + const to = Math.min(total, page * perPage);
413 + return (
414 + <div className={cn("flex items-center justify-between gap-3 py-2 text-[12.5px] text-ink-3", className)}>
415 + <span className="tnum">
416 + {fmtInt(from)}–{fmtInt(to)} of {fmtInt(total)}
417 + </span>
418 + <div className="flex items-center gap-1">
419 + <Button size="xs" variant="secondary" disabled={page <= 1} onClick={() => onPage(page - 1)} aria-label="Previous page">
420 + <ChevronLeft className="size-3.5" />
421 + </Button>
422 + <span className="figure px-1 text-ink-2">
423 + {page} / {pages}
424 + </span>
425 + <Button size="xs" variant="secondary" disabled={page >= pages} onClick={() => onPage(page + 1)} aria-label="Next page">
426 + <ChevronRight className="size-3.5" />
427 + </Button>
428 + </div>
429 + </div>
430 + );
431 +}
432 +
433 +/** Collapsible details row. */
434 +export function Disclosure({ summary, children, defaultOpen, className }: { summary: ReactNode; children: ReactNode; defaultOpen?: boolean; className?: string }) {
435 + return (
436 + <details className={cn("group rounded-xs border border-hair", className)} open={defaultOpen}>
437 + <summary className="flex min-h-9 cursor-pointer list-none items-center gap-2 px-2.5 text-[12.5px] text-ink-2 hover:bg-surface-2 [&::-webkit-details-marker]:hidden">
438 + <ChevronRight className="size-3.5 shrink-0 text-ink-4 transition-transform group-open:rotate-90" />
439 + <span className="min-w-0 flex-1">{summary}</span>
440 + </summary>
441 + <div className="border-t border-hair px-2.5 py-2">{children}</div>
442 + </details>
443 + );
444 +}
added apps/web/src/components/admin/shell.tsx +114 −0
@@ -0,0 +1,114 @@
1 +"use client";
2 +
3 +import { Activity, ArrowUpRight, Database, FileText, GitMerge, LogOut, Plug, Wrench, Zap } from "lucide-react";
4 +import Link from "next/link";
5 +import { usePathname } from "next/navigation";
6 +import type { ReactNode } from "react";
7 +import { LogoMark } from "@/components/brand/logo";
8 +import { ThemeToggle } from "@/components/theme";
9 +import { cn } from "@/components/ui/cn";
10 +import { ToastProvider } from "./toast";
11 +
12 +export const ADMIN_NAV = [
13 + { label: "Overview", href: "/admin", icon: Activity, match: (p: string) => p === "/admin" },
14 + { label: "Connectors", href: "/admin/connectors", icon: Plug, match: (p: string) => p.startsWith("/admin/connectors") },
15 + { label: "Documents", href: "/admin/documents", icon: FileText, match: (p: string) => p.startsWith("/admin/documents") },
16 + { label: "Matches", href: "/admin/matches", icon: GitMerge, match: (p: string) => p.startsWith("/admin/matches") },
17 + { label: "Events", href: "/admin/events", icon: Zap, match: (p: string) => p.startsWith("/admin/events") },
18 + { label: "Dev tool", href: "/admin/devtool", icon: Wrench, match: (p: string) => p.startsWith("/admin/devtool") },
19 + { label: "Sources", href: "/admin/sources", icon: Database, match: (p: string) => p.startsWith("/admin/sources") },
20 +] as const;
21 +
22 +/** Compact operational shell: left rail ≥ md, horizontal tabs below (sticky) on phones. */
23 +export function AdminShell({ children }: { children: ReactNode }) {
24 + const path = usePathname();
25 + return (
26 + <ToastProvider>
27 + <div className="flex min-h-dvh">
28 + <aside className="sticky top-0 hidden h-dvh w-[200px] shrink-0 flex-col border-r border-hair bg-surface md:flex">
29 + <div className="flex h-12 items-center gap-2 border-b border-hair px-3">
30 + <Link href="/admin" className="flex items-center gap-2 text-ink">
31 + <LogoMark size={20} />
32 + <span className="text-[13px] font-semibold tracking-tight">DCI Admin</span>
33 + </Link>
34 + </div>
35 + <nav aria-label="Admin" className="flex-1 overflow-y-auto py-2">
36 + <ul className="space-y-px px-2">
37 + {ADMIN_NAV.map((n) => {
38 + const active = n.match(path);
39 + const Icon = n.icon;
40 + return (
41 + <li key={n.href}>
42 + <Link href={n.href} aria-current={active ? "page" : undefined} className={cn("flex h-9 items-center gap-2.5 rounded-sm px-2 text-[13px]", active ? "bg-surface-2 font-medium text-ink" : "text-ink-2 hover:bg-surface-2 hover:text-ink")}>
43 + <Icon className={cn("size-4", active ? "text-accent" : "text-ink-3")} strokeWidth={1.75} />
44 + {n.label}
45 + </Link>
46 + </li>
47 + );
48 + })}
49 + </ul>
50 + </nav>
51 + <div className="space-y-1 border-t border-hair p-2">
52 + <a href="/" className="flex h-9 items-center gap-2 rounded-sm px-2 text-[12.5px] text-ink-2 hover:bg-surface-2 hover:text-ink" target="_blank" rel="noopener">
53 + <ArrowUpRight className="size-4 text-ink-3" strokeWidth={1.75} /> View site
54 + </a>
55 + <div className="flex items-center justify-between px-1">
56 + <ThemeToggle />
57 + <form action="/admin/logout" method="post">
58 + <button type="submit" className="inline-flex h-9 items-center gap-1.5 rounded-sm px-2 text-[12.5px] text-ink-2 hover:bg-surface-2 hover:text-ink">
59 + <LogOut className="size-4 text-ink-3" strokeWidth={1.75} /> Log out
60 + </button>
61 + </form>
62 + </div>
63 + </div>
64 + </aside>
65 +
66 + <div className="flex min-w-0 flex-1 flex-col">
67 + <header className="sticky top-0 z-[60] border-b border-hair bg-paper/95 backdrop-blur md:hidden">
68 + <div className="flex h-12 items-center gap-2 px-3">
69 + <Link href="/admin" className="flex items-center gap-2 text-ink">
70 + <LogoMark size={20} />
71 + <span className="text-[13px] font-semibold tracking-tight">DCI Admin</span>
72 + </Link>
73 + <div className="ml-auto flex items-center gap-0.5">
74 + <ThemeToggle />
75 + <form action="/admin/logout" method="post">
76 + <button type="submit" aria-label="Log out" className="inline-flex size-9 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2">
77 + <LogOut className="size-4" strokeWidth={1.75} />
78 + </button>
79 + </form>
80 + </div>
81 + </div>
82 + <nav aria-label="Admin" className="flex overflow-x-auto border-t border-hair px-2 no-scrollbar">
83 + {ADMIN_NAV.map((n) => {
84 + const active = n.match(path);
85 + return (
86 + <Link key={n.href} href={n.href} aria-current={active ? "page" : undefined} className={cn("relative inline-flex h-10 shrink-0 items-center whitespace-nowrap px-2.5 text-[12.5px] font-medium", active ? "text-ink after:absolute after:inset-x-2 after:bottom-0 after:h-[2px] after:bg-ink" : "text-ink-3")}>
87 + {n.label}
88 + </Link>
89 + );
90 + })}
91 + </nav>
92 + </header>
93 + <main id="main" className="min-w-0 flex-1 px-3 pb-10 pt-3 sm:px-5 sm:pt-4 lg:px-6">
94 + {children}
95 + </main>
96 + </div>
97 + </div>
98 + </ToastProvider>
99 + );
100 +}
101 +
102 +/** Page title row for admin pages (denser than the public PageHeader). */
103 +export function AdminPageHeader({ title, eyebrow, description, aside, className }: { title: ReactNode; eyebrow?: ReactNode; description?: ReactNode; aside?: ReactNode; className?: string }) {
104 + return (
105 + <header className={cn("mb-3 flex flex-col gap-2 sm:mb-4 sm:flex-row sm:items-end sm:justify-between", className)}>
106 + <div className="min-w-0">
107 + {eyebrow && <div className="label-caps mb-1">{eyebrow}</div>}
108 + <h1 className="truncate text-[18px] font-semibold leading-tight tracking-[-0.01em] text-ink sm:text-[20px]">{title}</h1>
109 + {description && <p className="mt-0.5 text-[12.5px] text-ink-3">{description}</p>}
110 + </div>
111 + {aside && <div className="flex shrink-0 flex-wrap items-center gap-2">{aside}</div>}
112 + </header>
113 + );
114 +}
added apps/web/src/components/admin/sources-table.tsx +145 −0
@@ -0,0 +1,145 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo, useState } from "react";
5 +import { Badge, EmptyState, RelativeTime, SearchInput, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";
6 +import { cn } from "@/components/ui/cn";
7 +import { DASH, fmtInt } from "@/lib/format";
8 +import { SOURCE_KIND_LABEL } from "@/lib/labels";
9 +import { routes } from "@/lib/routes";
10 +import type { SourceListItem } from "@/lib/admin-api";
11 +import { ErrorNote, ExtLink, Mono, RefreshButton, Select } from "./primitives";
12 +import { AdminPageHeader } from "./shell";
13 +import { useAdminQuery } from "./use-admin-query";
14 +
15 +type SortKey = "name" | "kind" | "documents" | "provenanceRows" | "facilities" | "lastDocumentAt";
16 +
17 +export function SourcesTable() {
18 + const q = useAdminQuery<SourceListItem[]>("v1/sources", { refreshMs: 120_000 });
19 + const [kind, setKind] = useState("");
20 + const [search, setSearch] = useState("");
21 + const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "provenanceRows", dir: "desc" });
22 + const list = useMemo(() => q.data ?? [], [q.data]);
23 + const kinds = useMemo(() => [...new Set(list.map((s) => s.kind))].sort(), [list]);
24 + const rows = useMemo(() => {
25 + const s = search.trim().toLowerCase();
26 + const out = list.filter((x) => (!kind || x.kind === kind) && (!s || x.name.toLowerCase().includes(s) || x.domain.toLowerCase().includes(s) || (x.connectorId ?? "").includes(s) || (x.license ?? "").toLowerCase().includes(s)));
27 + out.sort((a, b) => {
28 + const va = a[sort.key];
29 + const vb = b[sort.key];
30 + const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va ?? "").localeCompare(String(vb ?? ""));
31 + return (sort.dir === "asc" ? cmp : -cmp) || a.name.localeCompare(b.name);
32 + });
33 + return out;
34 + }, [list, kind, search, sort]);
35 + const th = (key: SortKey, label: string, numeric?: boolean) => (
36 + <Th numeric={numeric} sort={sort.key === key ? sort.dir : null} className="cursor-pointer select-none" onClick={() => setSort((s) => ({ key, dir: s.key === key ? (s.dir === "asc" ? "desc" : "asc") : numeric ? "desc" : "asc" }))}>
37 + {label}
38 + </Th>
39 + );
40 + const totals = useMemo(() => rows.reduce((a, s) => ({ documents: a.documents + s.documents, provenance: a.provenance + s.provenanceRows, facilities: a.facilities + s.facilities, licensed: a.licensed + (s.license ? 1 : 0) }), { documents: 0, provenance: 0, facilities: 0, licensed: 0 }), [rows]);
41 +
42 + return (
43 + <div>
44 + <AdminPageHeader title="Sources" description={q.data ? `${fmtInt(list.length)} sources · ${fmtInt(totals.licensed)} with a recorded license · ${fmtInt(totals.provenance)} provenance rows` : "Every source behind the index, with license, attribution and what it produced"} aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />
45 + <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />
46 + <div className="mb-3 flex flex-col gap-2 sm:flex-row">
47 + <Select value={kind} onChange={(e) => setKind(e.target.value)} className="sm:w-[180px]" aria-label="Kind">
48 + <option value="">All kinds</option>
49 + {kinds.map((k) => (
50 + <option key={k} value={k}>
51 + {SOURCE_KIND_LABEL[k] ?? k}
52 + </option>
53 + ))}
54 + </Select>
55 + <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Search name, domain, connector, license" wrapperClassName="flex-1" />
56 + </div>
57 + {q.loading && !q.data ? (
58 + <SkeletonRows rows={10} />
59 + ) : rows.length === 0 ? (
60 + <EmptyState compact title="No sources match" />
61 + ) : (
62 + <div className="rounded-sm border border-hair bg-surface px-3">
63 + <Table className="text-[12.5px]">
64 + <THead>
65 + <Tr>
66 + {th("name", "Source")}
67 + {th("kind", "Kind")}
68 + <Th>License</Th>
69 + <Th>Attribution</Th>
70 + <Th>Docs</Th>
71 + {th("documents", "Documents", true)}
72 + {th("provenanceRows", "Provenance", true)}
73 + {th("facilities", "Facilities", true)}
74 + {th("lastDocumentAt", "Last document")}
75 + </Tr>
76 + </THead>
77 + <TBody>
78 + {rows.map((s) => (
79 + <Tr key={s.id}>
80 + <Td primary label="Source" className="max-w-[260px]">
81 + <Link href={routes.source(s.id)} target="_blank" className="block truncate font-medium text-ink hover:text-accent hover:underline">
82 + {s.name}
83 + </Link>
84 + <div className="flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">
85 + <span className="truncate">{s.domain}</span>
86 + {s.connectorId && (
87 + <Link href={`/admin/connectors/${encodeURIComponent(s.connectorId)}`} className="font-mono hover:text-accent">
88 + {s.connectorId}
89 + </Link>
90 + )}
91 + {s.robotsAllowed === false && (
92 + <Badge size="xs" tone="danger">
93 + robots blocked
94 + </Badge>
95 + )}
96 + </div>
97 + </Td>
98 + <Td label="Kind" muted>
99 + {SOURCE_KIND_LABEL[s.kind] ?? s.kind}
100 + </Td>
101 + <Td label="License" className={cn("max-w-[260px]", !s.license && "text-warn")}>
102 + <span className="line-clamp-2" title={s.license ?? undefined}>
103 + {s.license ?? "not recorded"}
104 + </span>
105 + </Td>
106 + <Td label="Attribution" className="max-w-[200px]">
107 + <span className="line-clamp-2" title={s.attribution ?? undefined}>
108 + {s.attribution ?? DASH}
109 + </span>
110 + </Td>
111 + <Td label="Docs">
112 + {s.url ? <ExtLink href={s.url}>link</ExtLink> : <span className="text-ink-4">{DASH}</span>}
113 + {s.notes && (
114 + <span className="ml-1 cursor-help text-ink-4" title={s.notes}>
115 + ⓘ
116 + </span>
117 + )}
118 + </Td>
119 + <Td label="Documents" numeric>
120 + {fmtInt(s.documents)}
121 + </Td>
122 + <Td label="Provenance" numeric>
123 + {fmtInt(s.provenanceRows)}
124 + </Td>
125 + <Td label="Facilities" numeric>
126 + {fmtInt(s.facilities)}
127 + </Td>
128 + <Td label="Last document" muted>
129 + {s.lastDocumentAt ? <RelativeTime iso={s.lastDocumentAt} /> : <Mono>{DASH}</Mono>}
130 + </Td>
131 + </Tr>
132 + ))}
133 + </TBody>
134 + </Table>
135 + <div className="flex flex-wrap gap-4 border-t border-hair py-2 text-[12px] text-ink-3">
136 + <span>{fmtInt(rows.length)} sources</span>
137 + <span className="figure">{fmtInt(totals.documents)} documents</span>
138 + <span className="figure">{fmtInt(totals.provenance)} provenance rows</span>
139 + <span className="figure">{fmtInt(totals.facilities)} facilities</span>
140 + </div>
141 + </div>
142 + )}
143 + </div>
144 + );
145 +}
added apps/web/src/components/admin/use-admin-query.ts +120 −0
@@ -0,0 +1,120 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useRef, useState } from "react";
4 +import { adminFetch, type AdminQuery, type AdminResult } from "@/lib/admin-api";
5 +
6 +export interface AdminQueryState<T> {
7 + data: T | null;
8 + meta: Record<string, unknown> | undefined;
9 + error: string | null;
10 + status: number;
11 + /** no result yet for the current path/query */
12 + loading: boolean;
13 + /** a background refresh is in flight while data is shown */
14 + refreshing: boolean;
15 + updatedAt: number | null;
16 + refresh: () => Promise<void>;
17 + /** optimistic local update */
18 + mutate: (fn: (prev: T | null) => T | null) => void;
19 +}
20 +
21 +interface Options {
22 + /** auto-refresh interval, ms (null = off). Paused while the tab is hidden. */
23 + refreshMs?: number | null;
24 + enabled?: boolean;
25 + query?: AdminQuery;
26 + timeoutMs?: number;
27 +}
28 +
29 +interface Slot<T> {
30 + key: string;
31 + data: T | null;
32 + meta: Record<string, unknown> | undefined;
33 + error: string | null;
34 + status: number;
35 + updatedAt: number | null;
36 + refreshing: boolean;
37 +}
38 +
39 +/**
40 + * Tiny SWR-like hook over the admin proxy: fetch on mount / when `path` or the serialized `query` changes, optional
41 + * polling, manual refresh, optimistic `mutate`. Aborts in-flight requests on change / unmount. Never throws.
42 + * `loading` is derived (result key ≠ current key) so no state is written synchronously inside effects.
43 + */
44 +export function useAdminQuery<T>(path: string | null, opts: Options = {}): AdminQueryState<T> {
45 + const { refreshMs = null, enabled = true, timeoutMs } = opts;
46 + const qs = JSON.stringify(opts.query ?? null);
47 + const key = `${path ?? ""}|${qs}`;
48 + const active = Boolean(path && enabled);
49 + const [slot, setSlot] = useState<Slot<T>>({ key: "", data: null, meta: undefined, error: null, status: 0, updatedAt: null, refreshing: false });
50 + const abortRef = useRef<AbortController | null>(null);
51 + const keyRef = useRef(key);
52 + useEffect(() => {
53 + keyRef.current = key;
54 + }, [key]);
55 +
56 + const load = useCallback(
57 + async (background: boolean) => {
58 + if (!path || !enabled) return;
59 + const myKey = `${path}|${qs}`;
60 + abortRef.current?.abort();
61 + const ctrl = new AbortController();
62 + abortRef.current = ctrl;
63 + if (background) setSlot((s) => ({ ...s, refreshing: true }));
64 + const res: AdminResult<T> = await adminFetch<T>(path, { query: (JSON.parse(qs) as AdminQuery | null) ?? undefined, signal: ctrl.signal, timeoutMs });
65 + if (ctrl.signal.aborted || keyRef.current !== myKey) return;
66 + if (res.ok) setSlot({ key: myKey, data: res.data, meta: res.meta, error: null, status: res.status, updatedAt: Date.now(), refreshing: false });
67 + else setSlot((s) => ({ key: myKey, data: s.key === myKey ? s.data : null, meta: s.key === myKey ? s.meta : undefined, error: res.error, status: res.status, updatedAt: s.key === myKey ? s.updatedAt : null, refreshing: false }));
68 + },
69 + [path, enabled, qs, timeoutMs],
70 + );
71 +
72 + useEffect(() => {
73 + if (!active) return;
74 + void load(false);
75 + return () => abortRef.current?.abort();
76 + }, [load, active]);
77 +
78 + useEffect(() => {
79 + if (!refreshMs || !active) return;
80 + const tick = () => {
81 + if (document.visibilityState === "visible") void load(true);
82 + };
83 + const t = setInterval(tick, refreshMs);
84 + document.addEventListener("visibilitychange", tick);
85 + return () => {
86 + clearInterval(t);
87 + document.removeEventListener("visibilitychange", tick);
88 + };
89 + }, [refreshMs, load, active]);
90 +
91 + const refresh = useCallback(() => load(true), [load]);
92 + const mutate = useCallback((fn: (prev: T | null) => T | null) => setSlot((s) => ({ ...s, data: fn(s.data) })), []);
93 +
94 + const current = slot.key === key;
95 + return {
96 + data: current ? slot.data : null,
97 + meta: current ? slot.meta : undefined,
98 + error: current ? slot.error : null,
99 + status: current ? slot.status : 0,
100 + loading: active && !current,
101 + refreshing: slot.refreshing,
102 + updatedAt: current ? slot.updatedAt : null,
103 + refresh,
104 + mutate,
105 + };
106 +}
107 +
108 +/** Run a mutation through the proxy with a busy flag. */
109 +export function useAdminMutation() {
110 + const [busy, setBusy] = useState<string | null>(null);
111 + const run = useCallback(async <T,>(key: string, path: string, init: { method?: "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; query?: AdminQuery; timeoutMs?: number }): Promise<AdminResult<T>> => {
112 + setBusy(key);
113 + try {
114 + return await adminFetch<T>(path, { method: init.method ?? "POST", body: init.body ?? {}, query: init.query, timeoutMs: init.timeoutMs });
115 + } finally {
116 + setBusy((b) => (b === key ? null : b));
117 + }
118 + }, []);
119 + return { busy, run };
120 +}
added apps/web/src/components/admin/use-url-state.ts +33 −0
@@ -0,0 +1,33 @@
1 +"use client";
2 +
3 +import { usePathname, useRouter, useSearchParams } from "next/navigation";
4 +import { useCallback, useMemo } from "react";
5 +
6 +/** URL search params as state: `set({ key: value | null })` replaces the URL (no scroll, no history entry). */
7 +export function useUrlState(): [Record<string, string>, (patch: Record<string, string | number | boolean | null | undefined>, opts?: { push?: boolean }) => void] {
8 + const sp = useSearchParams();
9 + const router = useRouter();
10 + const pathname = usePathname();
11 + const params = useMemo(() => {
12 + const out: Record<string, string> = {};
13 + sp.forEach((v, k) => {
14 + out[k] = v;
15 + });
16 + return out;
17 + }, [sp]);
18 + const set = useCallback(
19 + (patch: Record<string, string | number | boolean | null | undefined>, opts?: { push?: boolean }) => {
20 + const next = new URLSearchParams(sp.toString());
21 + for (const [k, v] of Object.entries(patch)) {
22 + if (v === null || v === undefined || v === "" || v === false) next.delete(k);
23 + else next.set(k, v === true ? "1" : String(v));
24 + }
25 + const qs = next.toString();
26 + const url = qs ? `${pathname}?${qs}` : pathname;
27 + if (opts?.push) router.push(url, { scroll: false });
28 + else router.replace(url, { scroll: false });
29 + },
30 + [sp, router, pathname],
31 + );
32 + return [params, set];
33 +}
added apps/web/src/components/admin/yaml.ts +213 −0
@@ -0,0 +1,213 @@
1 +/**
2 + * Minimal YAML helpers for the admin console (no runtime YAML dependency in the web bundle):
3 + * - `toYaml()` serializes plain JSON data (objects / arrays / scalars) into readable block YAML;
4 + * - `yamlTokens()` tokenizes a YAML line for syntax highlighting;
5 + * - `diffLinesLcs()` line diff (LCS) for the compare views.
6 + * Parsing YAML is delegated to the API (`devtool/validate-config` returns the parsed config).
7 + */
8 +
9 +const PLAIN = /^[A-Za-z0-9_][A-Za-z0-9_./:@-]*$/;
10 +const RESERVED = new Set(["true", "false", "null", "yes", "no", "on", "off", "~", ""]);
11 +
12 +function scalar(v: unknown): string {
13 + if (v === null || v === undefined) return "null";
14 + if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null";
15 + if (typeof v === "boolean") return v ? "true" : "false";
16 + const s = String(v);
17 + if (s === "") return '""';
18 + if (RESERVED.has(s.toLowerCase()) || /^[-?:,[\]{}#&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s) || /^[+-]?\d/.test(s) || /[:#]\s|\n|\t/.test(s) || s.includes(": ") || s.endsWith(":")) {
19 + return JSON.stringify(s);
20 + }
21 + if (PLAIN.test(s) || !/["'\\]/.test(s)) return s;
22 + return JSON.stringify(s);
23 +}
24 +
25 +function isScalar(v: unknown): boolean {
26 + return v === null || v === undefined || typeof v !== "object";
27 +}
28 +
29 +function inlineable(v: unknown): boolean {
30 + if (Array.isArray(v)) return v.length > 0 && v.length <= 6 && v.every(isScalar) && v.map(scalar).join(", ").length < 70;
31 + if (v && typeof v === "object") {
32 + const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined);
33 + return entries.length > 0 && entries.length <= 5 && entries.every(([, x]) => isScalar(x)) && entries.map(([k, x]) => `${k}: ${scalar(x)}`).join(", ").length < 80;
34 + }
35 + return false;
36 +}
37 +
38 +function inline(v: unknown): string {
39 + if (Array.isArray(v)) return `[${v.map(scalar).join(", ")}]`;
40 + const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined);
41 + return `{ ${entries.map(([k, x]) => `${k}: ${scalar(x)}`).join(", ")} }`;
42 +}
43 +
44 +export function toYaml(value: unknown, indent = 0): string {
45 + const pad = " ".repeat(indent);
46 + if (isScalar(value)) return `${pad}${scalar(value)}\n`;
47 + if (Array.isArray(value)) {
48 + if (value.length === 0) return `${pad}[]\n`;
49 + return value
50 + .map((item) => {
51 + if (isScalar(item)) return `${pad}- ${scalar(item)}\n`;
52 + if (inlineable(item)) return `${pad}- ${inline(item)}\n`;
53 + if (Array.isArray(item)) return `${pad}-\n${toYaml(item, indent + 1)}`;
54 + const body = toYaml(item, indent + 1);
55 + // hoist the first key onto the dash line
56 + const lines = body.split("\n");
57 + const first = lines.shift() ?? "";
58 + return `${pad}- ${first.trimStart()}\n${lines.filter((l) => l !== "").join("\n")}${lines.length > 1 ? "\n" : ""}`;
59 + })
60 + .join("");
61 + }
62 + const entries = Object.entries(value as Record<string, unknown>).filter(([, x]) => x !== undefined);
63 + if (entries.length === 0) return `${pad}{}\n`;
64 + return entries
65 + .map(([k, x]) => {
66 + const key = PLAIN.test(k) ? k : JSON.stringify(k);
67 + if (isScalar(x)) {
68 + if (typeof x === "string" && x.includes("\n")) return `${pad}${key}: |\n${x.split("\n").map((l) => `${pad} ${l}`).join("\n")}\n`;
69 + return `${pad}${key}: ${scalar(x)}\n`;
70 + }
71 + if (inlineable(x)) return `${pad}${key}: ${inline(x)}\n`;
72 + if (Array.isArray(x) && x.length === 0) return `${pad}${key}: []\n`;
73 + if (!Array.isArray(x) && Object.keys(x as object).length === 0) return `${pad}${key}: {}\n`;
74 + return `${pad}${key}:\n${toYaml(x, indent + 1)}`;
75 + })
76 + .join("");
77 +}
78 +
79 +/* ------------------------------------------------------------------ highlighting */
80 +
81 +export type YamlTokenKind = "key" | "string" | "number" | "bool" | "comment" | "punct" | "text" | "anchor";
82 +export interface YamlToken {
83 + kind: YamlTokenKind;
84 + text: string;
85 +}
86 +
87 +export function yamlTokens(line: string): YamlToken[] {
88 + const out: YamlToken[] = [];
89 + let rest = line;
90 + const indentMatch = /^(\s*)(-\s+)?/.exec(rest);
91 + if (indentMatch && indentMatch[0]) {
92 + out.push({ kind: "punct", text: indentMatch[0] });
93 + rest = rest.slice(indentMatch[0].length);
94 + }
95 + if (rest.startsWith("#")) {
96 + out.push({ kind: "comment", text: rest });
97 + return out;
98 + }
99 + const keyMatch = /^("[^"]*"|'[^']*'|[^\s:#{}[\],]+(?:\s[^\s:#{}[\],]+)*)\s*:(\s|$)/.exec(rest);
100 + if (keyMatch && keyMatch[1] !== undefined) {
101 + const keyText = keyMatch[1];
102 + out.push({ kind: "key", text: keyText });
103 + out.push({ kind: "punct", text: rest.slice(keyText.length, keyMatch[0].length) });
104 + rest = rest.slice(keyMatch[0].length);
105 + }
106 + while (rest.length) {
107 + let m: RegExpExecArray | null;
108 + if ((m = /^#.*$/.exec(rest))) out.push({ kind: "comment", text: m[0] });
109 + else if ((m = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*')/.exec(rest))) out.push({ kind: "string", text: m[0] });
110 + else if ((m = /^(true|false|null|~|yes|no)(?=[\s,}\]]|$)/i.exec(rest))) out.push({ kind: "bool", text: m[0] });
111 + else if ((m = /^[+-]?\d[\d_]*(\.\d+)?(e[+-]?\d+)?(?=[\s,}\]]|$)/i.exec(rest))) out.push({ kind: "number", text: m[0] });
112 + else if ((m = /^[|>][+-]?\d*$/.exec(rest))) out.push({ kind: "anchor", text: m[0] });
113 + else if ((m = /^[[\]{},:]\s*/.exec(rest))) out.push({ kind: "punct", text: m[0] });
114 + else if ((m = /^[^"'#[\]{},:]+/.exec(rest))) out.push({ kind: "text", text: m[0] });
115 + else {
116 + out.push({ kind: "text", text: rest[0]! });
117 + m = null;
118 + rest = rest.slice(1);
119 + continue;
120 + }
121 + rest = rest.slice(m[0].length);
122 + }
123 + return out;
124 +}
125 +
126 +/* ------------------------------------------------------------------ line diff (LCS) */
127 +
128 +export type DiffOp = { type: "same" | "add" | "del"; text: string; a?: number; b?: number };
129 +
130 +/** Line diff with an LCS table (capped: beyond 3000×3000 lines it falls back to a head/tail alignment). */
131 +export function diffLinesLcs(a: string, b: string): DiffOp[] {
132 + const A = a.split("\n");
133 + const B = b.split("\n");
134 + // trim common prefix / suffix to keep the table small
135 + let start = 0;
136 + while (start < A.length && start < B.length && A[start] === B[start]) start++;
137 + let endA = A.length;
138 + let endB = B.length;
139 + while (endA > start && endB > start && A[endA - 1] === B[endB - 1]) {
140 + endA--;
141 + endB--;
142 + }
143 + const out: DiffOp[] = [];
144 + for (let i = 0; i < start; i++) out.push({ type: "same", text: A[i]!, a: i + 1, b: i + 1 });
145 + const midA = A.slice(start, endA);
146 + const midB = B.slice(start, endB);
147 + if (midA.length * midB.length > 9_000_000) {
148 + for (let i = 0; i < midA.length; i++) out.push({ type: "del", text: midA[i]!, a: start + i + 1 });
149 + for (let j = 0; j < midB.length; j++) out.push({ type: "add", text: midB[j]!, b: start + j + 1 });
150 + } else {
151 + const n = midA.length;
152 + const m = midB.length;
153 + const dp = new Uint32Array((n + 1) * (m + 1));
154 + for (let i = n - 1; i >= 0; i--) {
155 + for (let j = m - 1; j >= 0; j--) {
156 + dp[i * (m + 1) + j] = midA[i] === midB[j] ? dp[(i + 1) * (m + 1) + j + 1]! + 1 : Math.max(dp[(i + 1) * (m + 1) + j]!, dp[i * (m + 1) + j + 1]!);
157 + }
158 + }
159 + let i = 0;
160 + let j = 0;
161 + while (i < n && j < m) {
162 + if (midA[i] === midB[j]) {
163 + out.push({ type: "same", text: midA[i]!, a: start + i + 1, b: start + j + 1 });
164 + i++;
165 + j++;
166 + } else if (dp[(i + 1) * (m + 1) + j]! >= dp[i * (m + 1) + j + 1]!) {
167 + out.push({ type: "del", text: midA[i]!, a: start + i + 1 });
168 + i++;
169 + } else {
170 + out.push({ type: "add", text: midB[j]!, b: start + j + 1 });
171 + j++;
172 + }
173 + }
174 + while (i < n) {
175 + out.push({ type: "del", text: midA[i]!, a: start + i + 1 });
176 + i++;
177 + }
178 + while (j < m) {
179 + out.push({ type: "add", text: midB[j]!, b: start + j + 1 });
180 + j++;
181 + }
182 + }
183 + for (let k = 0; k < A.length - endA; k++) out.push({ type: "same", text: A[endA + k]!, a: endA + k + 1, b: endB + k + 1 });
184 + return out;
185 +}
186 +
187 +/** Collapse long unchanged runs to `context` lines around changes. */
188 +export function collapseDiff(ops: DiffOp[], context = 3): Array<DiffOp | { type: "skip"; count: number }> {
189 + const keep = new Array<boolean>(ops.length).fill(false);
190 + for (let i = 0; i < ops.length; i++) {
191 + if (ops[i]!.type === "same") continue;
192 + for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) keep[k] = true;
193 + }
194 + const out: Array<DiffOp | { type: "skip"; count: number }> = [];
195 + let skip = 0;
196 + for (let i = 0; i < ops.length; i++) {
197 + if (keep[i]) {
198 + if (skip) out.push({ type: "skip", count: skip });
199 + skip = 0;
200 + out.push(ops[i]!);
201 + } else skip++;
202 + }
203 + if (skip) out.push({ type: "skip", count: skip });
204 + return out;
205 +}
206 +
207 +/** Bump "v3" → "v4", "1.2" → "1.3", anything else → "<x>-2". */
208 +export function bumpVersion(v: string | null | undefined): string {
209 + const s = (v ?? "v1").trim();
210 + const m = /^(.*?)(\d+)$/.exec(s);
211 + if (m) return `${m[1]}${Number(m[2]) + 1}`;
212 + return `${s}-2`;
213 +}
modified apps/web/src/components/entity/breadcrumbs.tsx +1 −1
@@ -26,7 +26,7 @@ export function Breadcrumbs({ items, className }: { items: Crumb[]; className?:
26 26 <li key={c.href} className="inline-flex items-center gap-1">
27 27 {i > 0 && <ChevronRight className="size-3 text-ink-4" aria-hidden />}
28 28 {last ? (
29 − <span aria-current="page" className="max-w-[60vw] truncate text-ink-2 sm:max-w-none">
29 + <span aria-current="page" className="max-w-[60vw] truncate text-ink-2 sm:max-w-[360px] lg:max-w-[560px]">
30 30 {c.name}
31 31 </span>
32 32 ) : (
modified apps/web/src/components/entity/entity-map.tsx +3 −3
@@ -58,8 +58,8 @@ export function EntityMap({ center, zoom, filters, precision, className, openLab
58 58 const el = ref.current;
59 59 if (!el) return;
60 60 if (typeof IntersectionObserver === "undefined") {
61 − setVisible(true);
62 − return;
61 + const t = setTimeout(() => setVisible(true), 0);
62 + return () => clearTimeout(t);
63 63 }
64 64 const io = new IntersectionObserver(
65 65 (entries) => {
@@ -82,7 +82,7 @@ export function EntityMap({ center, zoom, filters, precision, className, openLab
82 82 return (
83 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 84 {visible ? <MapCanvas preview initialView={view} filters={filters} className="h-full w-full" /> : <Skeleton className="h-full w-full rounded-none" />}
85 − {center && (
85 + {center && precision && (
86 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 87 {hollow ? (
88 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} />
modified apps/web/src/components/entity/provenance.tsx +17 −13
@@ -101,38 +101,42 @@ export function ProvenanceTable({ items, emptyDescription = "No field-level prov
101 101 <Th>Field</Th>
102 102 <Th>Value</Th>
103 103 <Th>Source</Th>
104 − <Th className="hidden md:table-cell">First observed</Th>
105 − <Th className="hidden md:table-cell">Last observed</Th>
104 + <Th className="hidden lg:table-cell">First observed</Th>
105 + <Th className="hidden lg:table-cell">Last observed</Th>
106 106 <Th>Confidence</Th>
107 − <Th className="hidden lg:table-cell">Method</Th>
108 − <Th className="hidden sm:table-cell">Link</Th>
107 + <Th className="hidden xl:table-cell">Method</Th>
108 + <Th className="hidden md:table-cell">Link</Th>
109 109 </tr>
110 110 </THead>
111 111 <TBody>
112 112 {sorted.map((p, i) => (
113 113 <Tr key={`${p.field}-${p.sourceId}-${i}`}>
114 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))}
115 + <Td label="Value" className="figure max-w-[220px] text-[12.5px] lg:max-w-[300px]">
116 + <span className="min-w-0 [overflow-wrap:anywhere]">
117 + {p.isEstimate && <EstimateMark className="mr-0.5" />}
118 + {fmtValue(p.value, valueHint(p.field))}
119 + </span>
118 120 </Td>
119 121 <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 + <span className="min-w-0 [overflow-wrap:anywhere]">
123 + <span className="text-ink">{p.sourceName}</span>
124 + <span className="ml-1 text-[11px] text-ink-3">{SOURCE_KIND_LABEL[p.sourceKind]}</span>
125 + </span>
122 126 </Td>
123 − <Td label="First observed" className="figure hidden text-[12px] text-ink-3 md:table-cell">
127 + <Td label="First observed" className="figure hidden text-[12px] text-ink-3 lg:table-cell">
124 128 {fmtDate(p.firstObserved)}
125 129 </Td>
126 − <Td label="Last observed" className="figure hidden text-[12px] text-ink-3 md:table-cell">
130 + <Td label="Last observed" className="figure hidden text-[12px] text-ink-3 lg:table-cell">
127 131 {fmtDate(p.lastObserved)}
128 132 </Td>
129 133 <Td label="Confidence">
130 134 <ConfidenceBadge level={p.confidence} size="xs" />
131 135 </Td>
132 − <Td label="Method" className="figure hidden text-[11.5px] text-ink-3 lg:table-cell">
136 + <Td label="Method" className="figure hidden text-[11.5px] text-ink-3 xl:table-cell">
133 137 {p.method ?? "—"}
134 138 </Td>
135 − <Td label="Link" className="hidden sm:table-cell">
139 + <Td label="Link" className="hidden md:table-cell">
136 140 <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 141 source <ExternalLink className="size-3" />
138 142 </a>
modified apps/web/src/components/entity/section-nav.tsx +2 −2
@@ -14,8 +14,8 @@ export function SectionNav({ items, className }: { items: SectionLink[]; classNa
14 14 const list = items.filter((it) => it.count === undefined || it.count === null || it.count > 0);
15 15 if (list.length < 2) return null;
16 16 return (
17 − <nav aria-label="Sections" className={cn("sticky top-12 z-[15] -mx-3 border-b border-hair bg-paper/95 px-3 backdrop-blur sm:top-14 sm:mx-0 sm:px-0", className)}>
18 − <ul className="flex min-w-max gap-0 overflow-x-auto no-scrollbar">
17 + <nav aria-label="Sections" className={cn("sticky top-12 z-[15] -mx-3 overflow-x-auto border-b border-hair bg-paper/95 px-3 backdrop-blur no-scrollbar sm:top-14 sm:mx-0 sm:px-0", className)}>
18 + <ul className="flex w-max min-w-full gap-0">
19 19 {list.map((it) => (
20 20 <li key={it.id} className="first:[&>a]:pl-0">
21 21 <a href={`#${it.id}`} className="inline-flex h-10 items-center gap-1.5 whitespace-nowrap border-b-2 border-transparent px-3 text-[12.5px] font-medium text-ink-3 hover:border-hair-2 hover:text-ink">
modified apps/web/src/components/ui/timeline.tsx +14 −3
@@ -1,7 +1,7 @@
1 1 import type { SourceHistoryItem } from "@dci/core";
2 2 import { ExternalLink } from "lucide-react";
3 3 import type { ReactNode } from "react";
4 −import { fmtDate } from "@/lib/format";
4 +import { fmtDate, fmtValue } from "@/lib/format";
5 5 import { SOURCE_KIND_LABEL } from "@/lib/labels";
6 6 import { cn } from "./cn";
7 7
@@ -33,7 +33,7 @@ export function TimelineList({ items, className, dense }: { items: TimelineEntry
33 33 <time className="figure text-[11.5px] text-ink-3" dateTime={it.date}>
34 34 {fmtDate(it.date)}
35 35 </time>
36 − <span className="text-[13px] font-medium text-ink">{it.title}</span>
36 + <span className="min-w-0 break-words text-[13px] font-medium text-ink">{it.title}</span>
37 37 </div>
38 38 {it.description && <div className="mt-0.5 text-[12.5px] text-ink-2">{it.description}</div>}
39 39 {(it.meta || it.href) && (
@@ -56,7 +56,7 @@ export function TimelineList({ items, className, dense }: { items: TimelineEntry
56 56 export function SourceHistory({ items, className }: { items: SourceHistoryItem[]; className?: string }) {
57 57 const entries: TimelineEntry[] = items.map((s) => ({
58 58 date: s.date,
59 − title: s.description,
59 + title: prettyDescription(s.description),
60 60 meta: (
61 61 <span>
62 62 {s.sourceName} · {SOURCE_KIND_LABEL[s.sourceKind]} · {s.kind}
@@ -67,3 +67,14 @@ export function SourceHistory({ items, className }: { items: SourceHistoryItem[]
67 67 }));
68 68 return <TimelineList items={entries} className={className} dense />;
69 69 }
70 +
71 +/** "field observed: {json}" → "field observed: human value" (structured payloads are otherwise one unbreakable token). */
72 +function prettyDescription(d: string): string {
73 + const m = /^(.*?:\s*)([[{].*[\]}])\s*$/s.exec(d);
74 + if (!m) return d;
75 + try {
76 + return `${m[1]}${fmtValue(JSON.parse(m[2]!))}`;
77 + } catch {
78 + return d;
79 + }
80 +}
added apps/web/src/lib/countries.ts +30 −0
@@ -0,0 +1,30 @@
1 +import { cache } from "react";
2 +import { api } from "@/lib/api";
3 +import { routes } from "@/lib/routes";
4 +
5 +/** iso2 → canonical country slug (from /countries?all=1), memoised per request. */
6 +export const countrySlugMap = cache(async (): Promise<Map<string, string>> => {
7 + const r = await api.countries({ all: 1 });
8 + const m = new Map<string, string>();
9 + for (const c of r.items) m.set(c.iso2.toUpperCase(), c.slug);
10 + return m;
11 +});
12 +
13 +/** Fallback slug when the registry is unreachable: ASCII-folded, hyphenated name (matches the API's slugs for most countries). */
14 +export function slugifyCountryName(name: string | null | undefined, iso2: string): string {
15 + if (!name) return iso2.toLowerCase();
16 + const folded = name
17 + .normalize("NFD")
18 + .replace(/[̀-ͯ]/g, "")
19 + .toLowerCase()
20 + .replace(/[^a-z0-9]+/g, "-")
21 + .replace(/(^-|-$)/g, "");
22 + return folded || iso2.toLowerCase();
23 +}
24 +
25 +/** Href of the country page for an iso2 (canonical slug when known). */
26 +export async function countryHref(iso2: string | null | undefined, name?: string | null): Promise<string | null> {
27 + if (!iso2) return null;
28 + const m = await countrySlugMap();
29 + return routes.country(m.get(iso2.toUpperCase()) ?? slugifyCountryName(name, iso2));
30 +}
modified apps/web/src/lib/format.ts +5 −0
@@ -111,6 +111,7 @@ export function fmtValue(v: unknown, hint?: "mw" | "date" | "usd"): string {
111 111 if (typeof v === "number") {
112 112 if (hint === "mw") return fmtMw(v);
113 113 if (hint === "usd") return fmtUsd(v);
114 + if (!Number.isInteger(v)) return new Intl.NumberFormat(EN, { maximumFractionDigits: 4 }).format(v);
114 115 return fmtInt(v);
115 116 }
116 117 if (typeof v === "string") {
@@ -122,6 +123,10 @@ export function fmtValue(v: unknown, hint?: "mw" | "date" | "usd"): string {
122 123 if (typeof v === "object") {
123 124 const o = v as Record<string, unknown>;
124 125 if ("value" in o && Object.keys(o).length <= 2) return fmtValue(o.value, hint);
126 + if (typeof o.lat === "number" && typeof o.lng === "number") {
127 + const extra = [typeof o.precision === "string" ? o.precision : null, typeof o.source === "string" ? o.source : null].filter(Boolean).join(" · ");
128 + return `${o.lat.toFixed(4)}, ${o.lng.toFixed(4)}${extra ? ` (${extra})` : ""}`;
129 + }
125 130 // Structured event payloads ({ code, name, status, mw, operators, countries, url… }) → compact human line
126 131 const parts: string[] = [];
127 132 if (typeof o.name === "string") parts.push(o.name);
added docs/PEERINGDB-APPROVAL.md +37 −0
@@ -0,0 +1,37 @@
1 +# PeeringDB — approval request (to send before enabling the connector)
2 +
3 +The PeeringDB Acceptable Use Policy (https://www.peeringdb.com/aup) allows bulk download only for "Internet
4 +operational issues" (including "Internet research and analysis") and requires PeeringDB's permission before data is
5 +reproduced or passed on in bulk. DataCenterIndex republishes derived facility / IXP records publicly, so the
6 +`peeringdb` connector ships **disabled** (`config/connectors/peeringdb.yaml`, `enabled: false`) until written approval
7 +is received. Once approved, request an API key (Authorization: Api-Key) and set `PEERINGDB_API_KEY` in
8 +`deploy/.env.crawl`, then set `enabled: true` and redeploy the crawl node.
9 +
10 +## Draft e-mail — to: support@peeringdb.com
11 +
12 +Subject: Request for approval — use of PeeringDB facility/IX data in DataCenterIndex.io (research index)
13 +
14 +Hello PeeringDB team,
15 +
16 +I am Simon-Pierre Boucher (contact@spboucher.ai), operator of DataCenterIndex.io, a non-commercial public research
17 +index of data center infrastructure (facilities, campuses, operators, cloud regions, internet exchanges, projects)
18 +hosted on my own infrastructure (MacLustr, www.maclustr.io).
19 +
20 +I would like to use the public PeeringDB API (`fac`, `org`, `campus`, `ix`, `ixfac`, `netfac`, `net`) as one of the
21 +sources of the index, specifically to:
22 +
23 +- list colocation facilities with their operator, address, coordinates and PeeringDB id;
24 +- show which internet exchanges and how many networks/carriers are present at each facility;
25 +- link each record back to its PeeringDB page with the attribution "Data: PeeringDB".
26 +
27 +Data would be refreshed at most weekly (incremental `since=` requests, one request per endpoint, spaced ≥ 2 s), stored
28 +with full provenance, and displayed per facility/IX page — no bulk redistribution, no resale, no marketing use. Every
29 +displayed value keeps its PeeringDB source and link. I am happy to register an API key under this account, add a
30 +`User-Agent` identifying the crawler (`DataCenterIndexBot/0.1 (+https://www.datacenterindex.io/bot)`), and to comply
31 +with any additional attribution or rate-limit requirements you have.
32 +
33 +Could you confirm whether this use is acceptable under the AUP, and if so, what attribution wording you prefer?
34 +
35 +Thank you,
36 +Simon-Pierre Boucher
37 +contact@spboucher.ai — https://www.datacenterindex.io
modified packages/core/src/api-types.ts +1 −0
@@ -83,6 +83,7 @@ export interface FacilitySummary {
83 83 itCapacityMw: number | null;
84 84 totalPowerMw: number | null;
85 85 plannedPowerMw: number | null;
86 + mwIsEstimate?: boolean;
86 87 isAi: boolean;
87 88 isHyperscale: boolean;
88 89 confidence: ConfidenceLevel;
89 90