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%

Deploy fixes (macOS rsync, web Dockerfile), PeeringDB disabled pending approval, offline country lookup

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

12 changed files +273 −4

added apps/web/src/components/entity/breadcrumbs.tsx +45 −0
@@ -0,0 +1,45 @@
1 +import { ChevronRight } from "lucide-react";
2 +import Link from "next/link";
3 +import { cn } from "@/components/ui/cn";
4 +import { routes } from "@/lib/routes";
5 +import { breadcrumbJsonLd } from "@/lib/seo";
6 +import { JsonLd } from "./json-ld";
7 +
8 +export interface Crumb {
9 + name: string;
10 + href: string;
11 +}
12 +
13 +/**
14 + * Breadcrumb trail (Home is implicit). Last item is the current page (not a link). Emits BreadcrumbList JSON-LD.
15 + * On mobile the trail scrolls horizontally instead of wrapping so the title stays first in view.
16 + */
17 +export function Breadcrumbs({ items, className }: { items: Crumb[]; className?: string }) {
18 + const all: Crumb[] = [{ name: "Home", href: routes.home() }, ...items];
19 + return (
20 + <>
21 + <nav aria-label="Breadcrumb" className={cn("-mx-3 overflow-x-auto px-3 no-scrollbar sm:mx-0 sm:px-0", className)}>
22 + <ol className="flex min-w-max items-center gap-1 text-[12px] text-ink-3">
23 + {all.map((c, i) => {
24 + const last = i === all.length - 1;
25 + return (
26 + <li key={c.href} className="inline-flex items-center gap-1">
27 + {i > 0 && <ChevronRight className="size-3 text-ink-4" aria-hidden />}
28 + {last ? (
29 + <span aria-current="page" className="max-w-[60vw] truncate text-ink-2 sm:max-w-none">
30 + {c.name}
31 + </span>
32 + ) : (
33 + <Link href={c.href} className="hover:text-ink hover:underline">
34 + {c.name}
35 + </Link>
36 + )}
37 + </li>
38 + );
39 + })}
40 + </ol>
41 + </nav>
42 + <JsonLd data={breadcrumbJsonLd(all)} />
43 + </>
44 + );
45 +}
added apps/web/src/components/entity/json-ld.tsx +5 −0
@@ -0,0 +1,5 @@
1 +/** Inline JSON-LD script (server component). Pass one object or an array of objects. */
2 +export function JsonLd({ data }: { data: Record<string, unknown> | Array<Record<string, unknown>> }) {
3 + const json = JSON.stringify(data).replace(/</g, "\\u003c");
4 + return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />;
5 +}
added apps/web/src/components/entity/section-nav.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import { cn } from "@/components/ui/cn";
2 +
3 +export interface SectionLink {
4 + id: string;
5 + label: string;
6 + count?: number | null;
7 +}
8 +
9 +/**
10 + * In-page anchor navigation for stacked detail sections (sticky under the header, horizontal scroll on mobile).
11 + * Server component — no active-state tracking, sections are all on one page.
12 + */
13 +export function SectionNav({ items, className }: { items: SectionLink[]; className?: string }) {
14 + const list = items.filter((it) => it.count === undefined || it.count === null || it.count > 0);
15 + if (list.length < 2) return null;
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">
19 + {list.map((it) => (
20 + <li key={it.id} className="first:[&>a]:pl-0">
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">
22 + {it.label}
23 + {typeof it.count === "number" && <span className="figure text-[11px] text-ink-4">{it.count}</span>}
24 + </a>
25 + </li>
26 + ))}
27 + </ul>
28 + </nav>
29 + );
30 +}
modified apps/web/src/lib/api.ts +20 −0
@@ -1,6 +1,7 @@
1 1 import type {
2 2 ApiEnvelope,
3 3 CloudRegionSummary,
4 + CountryDetail,
4 5 CountrySummary,
5 6 Dashboard,
6 7 EventDTO,
@@ -9,12 +10,16 @@ import type {
9 10 FacilitySummary,
10 11 IxpSummary,
11 12 MapResponse,
13 + MetroDetail,
12 14 MetroSummary,
15 + OperatorDetail,
13 16 OperatorSummary,
17 + ProjectDetail,
14 18 ProjectSummary,
15 19 Ranking,
16 20 SearchResponse,
17 21 } from "@dci/core";
22 +import type { CloudRegionDetail, IxpDetail, PipelineAggregates, SitemapEntry, SitemapKind, SourceDetail, SourceWithCounts } from "@/lib/dto";
18 23
19 24 /**
20 25 * Typed API client over the v1 contract (packages/core/src/api-types.ts).
@@ -157,6 +162,21 @@ export const api = {
157 162 search: (q: string, extra?: Query) => apiGet<SearchResponse>("/search", { q, ...extra }, { revalidate: REVALIDATE.list }),
158 163
159 164 map: (q: Query) => apiGet<MapResponse>("/map", q, { revalidate: REVALIDATE.map }),
165 +
166 + /* ---- entity details (facilities paginated with ?fPage on operator / country / metro) */
167 + operator: (slug: string, q?: Query) => apiGet<OperatorDetail>(`/operators/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),
168 + country: (slug: string, q?: Query) => apiGet<CountryDetail>(`/countries/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),
169 + metro: (slug: string, q?: Query) => apiGet<MetroDetail>(`/metros/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),
170 + project: (slug: string) => apiGet<ProjectDetail>(`/projects/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),
171 + projectPipeline: () => apiGet<PipelineAggregates>("/projects/pipeline", undefined, { revalidate: REVALIDATE.list }),
172 + cloudRegion: (slug: string) => apiGet<CloudRegionDetail>(`/cloud-regions/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),
173 + ixp: (slug: string) => apiGet<IxpDetail>(`/ixps/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),
174 + sources: async (q?: Query) => {
175 + const r = await apiGet<SourceWithCounts[] | { items: SourceWithCounts[]; total?: number }>("/sources", q, { revalidate: REVALIDATE.list });
176 + return paged<SourceWithCounts>(r);
177 + },
178 + source: (id: string) => apiGet<SourceDetail>(`/sources/${encodeURIComponent(id)}`, undefined, { revalidate: REVALIDATE.detail }),
179 + sitemap: (kind: SitemapKind, page = 1) => apiGet<SitemapEntry[]>(`/sitemap/${kind}`, { page }, { revalidate: 600 }),
160 180 };
161 181
162 182 /** Event-list query for /live and the home module. */
added apps/web/src/lib/dto.ts +65 −0
@@ -0,0 +1,65 @@
1 +import type { CloudRegionSummary, FacilityStatus, FacilitySummary, IxpSummary, SourceRef } from "@dci/core";
2 +
3 +/**
4 + * Response shapes implemented by @dci/api that are not (yet) part of the shared contract in
5 + * packages/core/src/api-types.ts (see apps/api/README.md and apps/api/src/repositories/*).
6 + */
7 +
8 +export interface CloudRegionDetail extends CloudRegionSummary {
9 + regionName: string | null;
10 + metro: { id: string; slug: string; name: string } | null;
11 + countryName: string | null;
12 + siblingRegions: CloudRegionSummary[];
13 + facilitiesInMetro: FacilitySummary[];
14 + externalIds: Record<string, string | number>;
15 + updatedAt: string;
16 +}
17 +
18 +export interface IxpDetail extends IxpSummary {
19 + countryName: string | null;
20 + metro: { id: string; slug: string; name: string } | null;
21 + regionContinent: string | null;
22 + facilities: FacilitySummary[];
23 + externalIds: Record<string, string | number>;
24 + updatedAt: string;
25 +}
26 +
27 +export interface SourceWithCounts extends SourceRef {
28 + connectorId: string;
29 + priority: number;
30 + robotsAllowed: boolean | null;
31 + notes: string | null;
32 + documents: number;
33 + provenanceRows: number;
34 + facilities: number;
35 + lastDocumentAt: string | null;
36 + updatedAt: string;
37 +}
38 +
39 +export interface SourceDetail extends SourceWithCounts {
40 + byPageType: Array<{ pageType: string; count: number }>;
41 + byEntityType: Array<{ entityType: string; count: number }>;
42 +}
43 +
44 +export interface PipelineAggregates {
45 + byStatus: Array<{ status: FacilityStatus; count: number; mw: number | null; investmentUsd: number | null }>;
46 + byYear: Array<{ year: string; count: number; mw: number | null }>;
47 + byCountry: Array<{ countryIso2: string; name: string | null; slug: string | null; count: number; mw: number | null }>;
48 + totals: { projects: number; plannedMw: number | null; investmentUsd: number | null; ai: number };
49 +}
50 +
51 +export interface SitemapEntry {
52 + slug: string;
53 + updatedAt: string;
54 +}
55 +
56 +export const SITEMAP_KINDS = ["facilities", "operators", "countries", "metros", "projects", "cloud-regions"] as const;
57 +export type SitemapKind = (typeof SITEMAP_KINDS)[number];
58 +
59 +/** `meta` returned by the operator / country / metro detail endpoints (facilities paginated with ?fPage). */
60 +export interface DetailFacilitiesMeta {
61 + facilitiesPage?: number;
62 + facilitiesPerPage?: number;
63 + facilitiesTotal?: number;
64 + methodology?: string;
65 +}
modified apps/web/src/lib/routes.ts +2 −0
@@ -18,6 +18,8 @@ export const routes = {
18 18 projects: (qs?: string) => (qs ? `/projects?${qs}` : "/projects"),
19 19 project: (slug: string) => `/projects/${slug}`,
20 20 rankings: (key?: string) => (key ? `/rankings#${key}` : "/rankings"),
21 + ranking: (key: string) => `/rankings/${key}`,
22 + bot: () => "/bot",
21 23 cloudRegions: () => "/cloud-regions",
22 24 cloudRegion: (slug: string) => `/cloud-regions/${slug}`,
23 25 ixps: () => "/ixps",
added apps/web/src/lib/seo.ts +40 −0
@@ -0,0 +1,40 @@
1 +import type { Metadata } from "next";
2 +import { SITE_URL } from "@/lib/routes";
3 +
4 +/** Metadata for an entity / index page: title, description, canonical + OG/Twitter mirror (OG image comes from the segment's opengraph-image.tsx). */
5 +export function pageMetadata({ title, description, path, absoluteTitle }: { title: string; description: string; path: string; absoluteTitle?: boolean }): Metadata {
6 + const desc = description.length > 300 ? `${description.slice(0, 297)}…` : description;
7 + return {
8 + title: absoluteTitle ? { absolute: title } : title,
9 + description: desc,
10 + alternates: { canonical: path },
11 + openGraph: { title, description: desc, url: `${SITE_URL}${path}`, type: "website" },
12 + twitter: { card: "summary_large_image", title, description: desc },
13 + };
14 +}
15 +
16 +/** Metadata for an entity that does not exist (still renders 404 body via notFound()). */
17 +export const NOT_FOUND_METADATA: Metadata = { title: "Not found", robots: { index: false, follow: false } };
18 +
19 +export function absoluteUrl(path: string): string {
20 + return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
21 +}
22 +
23 +/** Drop null / undefined / empty values from a JSON-LD object (Google ignores empty props but it keeps the payload tidy). */
24 +export function compactJsonLd<T extends Record<string, unknown>>(obj: T): Partial<T> {
25 + const out: Record<string, unknown> = {};
26 + for (const [k, v] of Object.entries(obj)) {
27 + if (v === null || v === undefined || v === "") continue;
28 + if (Array.isArray(v) && v.length === 0) continue;
29 + out[k] = v;
30 + }
31 + return out as Partial<T>;
32 +}
33 +
34 +export function breadcrumbJsonLd(items: Array<{ name: string; href: string }>) {
35 + return {
36 + "@context": "https://schema.org",
37 + "@type": "BreadcrumbList",
38 + itemListElement: items.map((it, i) => ({ "@type": "ListItem", position: i + 1, name: it.name, item: absoluteUrl(it.href) })),
39 + };
40 +}
added apps/worker/src/ingest/country-lookup.ts +58 −0
@@ -0,0 +1,58 @@
1 +import { readFileSync, existsSync } from "node:fs";
2 +import { dirname, join } from "node:path";
3 +import { fileURLToPath } from "node:url";
4 +
5 +/**
6 + * Offline point-in-country lookup (Natural Earth 1:50m admin-0, public domain) — used when a source publishes
7 + * coordinates but no country (OpenStreetMap features rarely carry addr:country). Deterministic, no network.
8 + */
9 +type Ring = number[][];
10 +interface Feat { iso2: string; bbox: [number, number, number, number]; polys: Ring[][] }
11 +let feats: Feat[] | null = null;
12 +
13 +function load(): Feat[] {
14 + if (feats) return feats;
15 + const here = dirname(fileURLToPath(import.meta.url));
16 + const candidates = [join(here, "..", "..", "data", "ne_50m_admin_0_countries.geojson"), join(process.cwd(), "apps", "worker", "data", "ne_50m_admin_0_countries.geojson"), join(process.cwd(), "data", "ne_50m_admin_0_countries.geojson")];
17 + const file = candidates.find((f) => existsSync(f));
18 + if (!file) { feats = []; return feats; }
19 + const gj = JSON.parse(readFileSync(file, "utf8")) as { features: Array<{ properties: Record<string, unknown>; geometry: { type: string; coordinates: unknown } }> };
20 + const out: Feat[] = [];
21 + for (const f of gj.features) {
22 + const p = f.properties;
23 + let iso2 = String(p.ISO_A2_EH ?? p.ISO_A2 ?? "-99");
24 + if (iso2 === "-99") { const a = String(p.ADM0_A3 ?? ""); iso2 = ({ FRA: "FR", NOR: "NO", KOS: "XK", SDS: "SS", SOL: "SO" } as Record<string, string>)[a] ?? "-99"; }
25 + if (!/^[A-Z]{2}$/.test(iso2)) continue;
26 + const polys: Ring[][] = f.geometry.type === "Polygon" ? [f.geometry.coordinates as Ring[]] : f.geometry.type === "MultiPolygon" ? (f.geometry.coordinates as Ring[][]) : [];
27 + let minX = 180, minY = 90, maxX = -180, maxY = -90;
28 + for (const poly of polys) for (const [x, y] of poly[0] ?? []) { if (x! < minX) minX = x!; if (x! > maxX) maxX = x!; if (y! < minY) minY = y!; if (y! > maxY) maxY = y!; }
29 + out.push({ iso2, bbox: [minX, minY, maxX, maxY], polys });
30 + }
31 + feats = out;
32 + return feats;
33 +}
34 +
35 +function inRing(x: number, y: number, ring: Ring): boolean {
36 + let inside = false;
37 + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
38 + const xi = ring[i]![0]!, yi = ring[i]![1]!, xj = ring[j]![0]!, yj = ring[j]![1]!;
39 + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
40 + }
41 + return inside;
42 +}
43 +
44 +/** ISO-2 of the country containing the point, or null (ocean / unknown). Handles holes. */
45 +export function countryFromPoint(lat: number, lng: number): string | null {
46 + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
47 + for (const f of load()) {
48 + const [minX, minY, maxX, maxY] = f.bbox;
49 + if (lng < minX || lng > maxX || lat < minY || lat > maxY) continue;
50 + for (const poly of f.polys) {
51 + if (!poly[0] || !inRing(lng, lat, poly[0])) continue;
52 + let hole = false;
53 + for (let k = 1; k < poly.length; k++) if (inRing(lng, lat, poly[k]!)) { hole = true; break; }
54 + if (!hole) return f.iso2;
55 + }
56 + }
57 + return null;
58 +}
modified apps/worker/src/ingest/facilities.ts +2 −0
@@ -14,6 +14,7 @@ import { isHyperscalerName } from "./canonical-operators.js";
14 14 import { resolveCampus } from "./campuses.js";
15 15 import { discoverySignificance, emitDiffEvents, recordEvent, TRACKED_FACILITY_FIELDS } from "./events.js";
16 16 import { bboxAround, validGeo } from "./geo.js";
17 +import { countryFromPoint } from "./country-lookup.js";
17 18 import { facilityIdForKey, upsertKey } from "./keys.js";
18 19 import { attachIxpsByName } from "./ixps.js";
19 20 import { bestMatch, bestMw, completenessScore, decide, facilityConfidence, isPipelineStatus, shouldReplace, shouldReplaceGeo, shouldReplaceMw, type FacilityCandidate, type FieldObservation, type MatchDecision, type MatchScore } from "./match.js";
@@ -191,6 +192,7 @@ export async function ingestFacility(tx: Tx, ctx: IngestContext, nf: NormalizedF
191 192 const owner = nf.ownerName && normalizeName(nf.ownerName) !== normalizeName(nf.operatorName ?? "") ? await resolveOperator(tx, ctx, { name: nf.ownerName }) : null;
192 193 const geo = validGeo(nf.geo) ? nf.geo : null;
193 194 let country = await safeCountry(tx, ctx, nf.countryIso2);
195 + if (!country && geo) country = await safeCountry(tx, ctx, countryFromPoint(geo.lat, geo.lng)); // offline Natural Earth polygons (OSM features rarely carry addr:country)
194 196 const metro = await assignMetro(tx, { lat: geo?.lat, lng: geo?.lng, city: nf.city, countryIso2: country });
195 197 country = country ?? metro.countryIso2;
196 198
modified config/connectors/peeringdb.yaml +3 −0
@@ -4,6 +4,9 @@ domain: www.peeringdb.com
4 4 kind: dataset
5 5 mode: api
6 6 priority: 2
7 +# DISABLED 2026-09-11 pending written approval from support@peeringdb.com: the AUP excludes commercial application
8 +# and requires permission for bulk reproduction. Re-enable (enabled: true) once approval + PEERINGDB_API_KEY are in place.
9 +enabled: false
7 10 # Verbatim from https://www.peeringdb.com/aup (retrieved 2026-09-11). PeeringDB publishes no CC/ODbL license; the AUP governs use.
8 11 license: >-
9 12 PeeringDB Acceptable Use Policy (https://www.peeringdb.com/aup), © 2004-2026 PeeringDB All Rights Reserved:
modified deploy/bin/lib.sh +2 −2
@@ -81,12 +81,12 @@ ensure_remote_layout() { # ensure_remote_layout <target>
81 81 sync_repo() { # sync_repo <target>
82 82 local t="$1" host; host="$(host_for "$t")"
83 83 log "rsync repo → $host:$REMOTE_DIR"
84 − rsync -az --delete --info=stats1 \
84 + rsync -az --delete \
85 85 --exclude-from="$REPO_ROOT/deploy/rsync-exclude.txt" \
86 86 -e "ssh ${SSH_OPTS[*]}" \
87 87 "$REPO_ROOT/" "$host:$REMOTE_DIR/"
88 88 if [[ -f "$REPO_ROOT/deploy/.env.$t" ]]; then
89 − rsync -az --chmod=F600 -e "ssh ${SSH_OPTS[*]}" "$REPO_ROOT/deploy/.env.$t" "$host:$REMOTE_DIR/deploy/.env.$t"
89 + scp -q "${SSH_OPTS[@]}" "$REPO_ROOT/deploy/.env.$t" "$host:$REMOTE_DIR/deploy/.env.$t" && remote "$t" "chmod 600 $REMOTE_DIR/deploy/.env.$t"
90 90 fi
91 91 remote "$t" "chmod +x $REMOTE_DIR/deploy/bin/*.sh $REMOTE_DIR/deploy/bin/remote/*.sh $REMOTE_DIR/deploy/docker/entrypoint.sh $REMOTE_DIR/deploy/minio/init.sh"
92 92 ok "synced"
modified deploy/docker/Dockerfile.web +1 −2
@@ -32,8 +32,7 @@ COPY . .
32 32 RUN --mount=type=cache,id=dci-pnpm-store,target=/pnpm/store \
33 33 pnpm install --offline --frozen-lockfile --config.dangerously-allow-all-builds=true --config.confirm-modules-purge=false --filter "@dci/web..."
34 34 RUN --mount=type=cache,id=dci-next-cache,target=/app/apps/web/.next/cache \
35 − pnpm --filter @dci/web run build \
36 − && rm -rf apps/web/.next/cache
35 + pnpm --filter @dci/web run build
37 36
38 37 # ---------------------------------------------------------------- prod-deps: runtime node_modules only
39 38 FROM fetch AS prod-deps
40 39