spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type {2 AiIndex,3 ApiEndpointDoc,4 ApiEnvelope,5 ClaimDTO,6 CloudRegionSummary,7 ConnectivityOverview,8 CoverageReport,9 DownloadDataset,10 EntityHistory,11 ExploreQuery,12 ExploreResponse,13 NearbyInfrastructure,14 OperatorComparison,15 PowerOverview,16 Pulse,17 SourceCoverage,18 TimeMachine,19 WatchlistItem,20 CountryDetail,21 CountrySummary,22 Dashboard,23 EventDTO,24 FacilityDetail,25 FacilityFilters,26 FacilitySummary,27 IxpSummary,28 MapResponse,29 MetroDetail,30 MetroSummary,31 OperatorDetail,32 OperatorSummary,33 ProjectDetail,34 ProjectSummary,35 Ranking,36 SearchResponse,37} from "@dci/core";38import type { CloudRegionDetail, IxpDetail, PipelineAggregates, SitemapEntry, SitemapKind, SourceDetail, SourceWithCounts } from "@/lib/dto";3940/**41 * Typed API client over the v1 contract (packages/core/src/api-types.ts).42 * Server: `API_URL_INTERNAL ?? NEXT_PUBLIC_API_URL` + `/api/v1`. Client: `NEXT_PUBLIC_API_URL`.43 * `apiGet` NEVER throws — pages render skeletons/empty states from `{ ok:false }`.44 */4546export const REVALIDATE = { dashboard: 60, list: 120, detail: 300, live: 30, map: 0 } as const;47export const TIMEOUT_MS = 8000;4849export function serverApiBase(): string {50 const base = process.env.API_URL_INTERNAL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://127.0.0.1:8311";51 return `${base.replace(/\/$/, "")}/api/v1`;52}5354export function clientApiBase(): string {55 if (typeof window === "undefined") return serverApiBase();56 const base = process.env.NEXT_PUBLIC_API_URL ?? "";57 return `${base.replace(/\/$/, "")}/api/v1`;58}5960export type ApiResult<T> = { ok: true; data: T; meta: ApiEnvelope<T>["meta"]; sources: ApiEnvelope<T>["sources"]; status: number } | { ok: false; data: null; meta?: undefined; sources?: undefined; error: string; status: number };6162export type Query = Record<string, string | number | boolean | undefined | null>;6364export function buildQuery(q?: Query): string {65 if (!q) return "";66 const sp = new URLSearchParams();67 for (const [k, v] of Object.entries(q)) {68 if (v === undefined || v === null || v === "" || v === false) continue;69 sp.set(k, v === true ? "1" : String(v));70 }71 const s = sp.toString();72 return s ? `?${s}` : "";73}7475interface GetOptions {76 revalidate?: number;77 timeoutMs?: number;78 /** override the base (client hooks pass clientApiBase()) */79 base?: string;80 signal?: AbortSignal;81 tags?: string[];82}8384export async function apiGet<T>(path: string, query?: Query, opts: GetOptions = {}): Promise<ApiResult<T>> {85 const base = opts.base ?? serverApiBase();86 const url = `${base}${path.startsWith("/") ? path : `/${path}`}${buildQuery(query)}`;87 const ctrl = new AbortController();88 const timer = setTimeout(() => ctrl.abort(new Error("timeout")), opts.timeoutMs ?? TIMEOUT_MS);89 if (opts.signal) opts.signal.addEventListener("abort", () => ctrl.abort(opts.signal?.reason), { once: true });90 try {91 const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = {92 headers: { accept: "application/json" },93 signal: ctrl.signal,94 };95 if (typeof window === "undefined") {96 const rv = opts.revalidate ?? REVALIDATE.list;97 init.next = rv > 0 ? { revalidate: rv, tags: opts.tags } : { revalidate: 0 };98 if (rv === 0) init.cache = "no-store";99 }100 const res = await fetch(url, init);101 if (!res.ok) return { ok: false, data: null, error: `HTTP ${res.status}`, status: res.status };102 const json = (await res.json()) as ApiEnvelope<T> | T;103 if (json && typeof json === "object" && "data" in (json as object)) {104 const env = json as ApiEnvelope<T>;105 return { ok: true, data: env.data, meta: env.meta, sources: env.sources, status: res.status };106 }107 return { ok: true, data: json as T, meta: undefined, sources: undefined, status: res.status };108 } catch (e) {109 const msg = e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : "network error";110 return { ok: false, data: null, error: msg, status: 0 };111 } finally {112 clearTimeout(timer);113 }114}115116/* ------------------------------------------------------------------ server helpers (typed) */117118export interface Paged<T> {119 items: T[];120 total: number;121 page: number;122 perPage: number;123}124125function paged<T>(r: ApiResult<T[] | { items: T[]; total?: number }>, fallbackPage = 1, fallbackPer = 50): Paged<T> {126 if (!r.ok || !r.data) return { items: [], total: 0, page: fallbackPage, perPage: fallbackPer };127 const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []);128 const total = (r.meta?.total as number | undefined) ?? (Array.isArray(r.data) ? items.length : (r.data.total ?? items.length));129 return { items, total, page: (r.meta?.page as number | undefined) ?? fallbackPage, perPage: (r.meta?.perPage as number | undefined) ?? fallbackPer };130}131132export const api = {133 health: () => apiGet<{ status: string }>("/health", undefined, { revalidate: 0, timeoutMs: 2500 }),134 dashboard: () => apiGet<Dashboard>("/dashboard", undefined, { revalidate: REVALIDATE.dashboard, tags: ["dashboard"] }),135136 facilities: async (f: FacilityFilters) => {137 const r = await apiGet<FacilitySummary[] | { items: FacilitySummary[]; total?: number }>("/datacenters", f as Query, { revalidate: REVALIDATE.list });138 return { ...paged<FacilitySummary>(r, f.page ?? 1, f.per_page ?? 50), ok: r.ok, error: r.ok ? undefined : r.error };139 },140 facility: (slug: string) => apiGet<FacilityDetail>(`/datacenters/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),141142 operators: async (q?: Query) => {143 const r = await apiGet<OperatorSummary[] | { items: OperatorSummary[]; total?: number }>("/operators", q, { revalidate: REVALIDATE.list });144 return paged<OperatorSummary>(r);145 },146 countries: async (q?: Query) => {147 const r = await apiGet<CountrySummary[] | { items: CountrySummary[]; total?: number }>("/countries", q, { revalidate: REVALIDATE.list });148 return paged<CountrySummary>(r);149 },150 metros: async (q?: Query) => {151 const r = await apiGet<MetroSummary[] | { items: MetroSummary[]; total?: number }>("/metros", q, { revalidate: REVALIDATE.list });152 return paged<MetroSummary>(r);153 },154 projects: async (q?: Query) => {155 const r = await apiGet<ProjectSummary[] | { items: ProjectSummary[]; total?: number }>("/projects", q, { revalidate: REVALIDATE.list });156 return paged<ProjectSummary>(r);157 },158 cloudRegions: async (q?: Query) => {159 const r = await apiGet<CloudRegionSummary[] | { items: CloudRegionSummary[]; total?: number }>("/cloud-regions", q, { revalidate: REVALIDATE.list });160 return paged<CloudRegionSummary>(r);161 },162 ixps: async (q?: Query) => {163 const r = await apiGet<IxpSummary[] | { items: IxpSummary[]; total?: number }>("/ixps", q, { revalidate: REVALIDATE.list });164 return paged<IxpSummary>(r);165 },166167 rankings: async () => {168 const r = await apiGet<Ranking[]>("/rankings", undefined, { revalidate: REVALIDATE.list });169 return r.ok && Array.isArray(r.data) ? r.data : [];170 },171 ranking: (key: string, q?: Query) => apiGet<Ranking>(`/rankings/${encodeURIComponent(key)}`, q, { revalidate: REVALIDATE.list }),172173 events: async (q?: Query) => {174 const r = await apiGet<EventDTO[] | { items: EventDTO[]; total?: number }>("/events", q, { revalidate: REVALIDATE.live });175 return { ...paged<EventDTO>(r, Number(q?.page ?? 1), Number(q?.per_page ?? 50)), ok: r.ok, error: r.ok ? undefined : r.error };176 },177178 search: (q: string, extra?: Query) => apiGet<SearchResponse>("/search", { q, ...extra }, { revalidate: REVALIDATE.list }),179180 map: (q: Query) => apiGet<MapResponse>("/map", q, { revalidate: REVALIDATE.map }),181182 /* ---- entity details (facilities paginated with ?fPage on operator / country / metro) */183 operator: (slug: string, q?: Query) => apiGet<OperatorDetail>(`/operators/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),184 country: (slug: string, q?: Query) => apiGet<CountryDetail>(`/countries/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),185 metro: (slug: string, q?: Query) => apiGet<MetroDetail>(`/metros/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),186 project: (slug: string) => apiGet<ProjectDetail>(`/projects/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),187 projectPipeline: () => apiGet<PipelineAggregates>("/projects/pipeline", undefined, { revalidate: REVALIDATE.list }),188 cloudRegion: (slug: string) => apiGet<CloudRegionDetail>(`/cloud-regions/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),189 ixp: (slug: string) => apiGet<IxpDetail>(`/ixps/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),190 sources: async (q?: Query) => {191 const r = await apiGet<SourceWithCounts[] | { items: SourceWithCounts[]; total?: number }>("/sources", q, { revalidate: REVALIDATE.list });192 return paged<SourceWithCounts>(r);193 },194 source: (id: string) => apiGet<SourceDetail>(`/sources/${encodeURIComponent(id)}`, undefined, { revalidate: REVALIDATE.detail }),195 sitemap: (kind: SitemapKind, page = 1) => apiGet<SitemapEntry[]>(`/sitemap/${kind}`, { page }, { revalidate: 600 }),196197 /* ---- API 2.0 (2026-09-12): evidence, history, graph, intelligence */198 facilityHistory: (slug: string) => apiGet<EntityHistory>(`/datacenters/${encodeURIComponent(slug)}/history`, undefined, { revalidate: REVALIDATE.detail }),199 facilityClaims: (slug: string) => apiGet<ClaimDTO[]>(`/datacenters/${encodeURIComponent(slug)}/claims`, undefined, { revalidate: REVALIDATE.detail }),200 projectHistory: (slug: string) => apiGet<EntityHistory>(`/projects/${encodeURIComponent(slug)}/history`, undefined, { revalidate: REVALIDATE.detail }),201 projectClaims: (slug: string) => apiGet<ClaimDTO[]>(`/projects/${encodeURIComponent(slug)}/claims`, undefined, { revalidate: REVALIDATE.detail }),202 nearby: (q: { lat: number; lng: number; radius_km?: number; types?: string }) => apiGet<NearbyInfrastructure>("/nearby", q, { revalidate: REVALIDATE.detail }),203 pulse: (window: "24h" | "7d" | "30d" = "7d") => apiGet<Pulse>("/pulse", { window }, { revalidate: REVALIDATE.dashboard, tags: ["pulse"] }),204 compareOperators: (slugs: string[]) => apiGet<OperatorComparison>("/compare/operators", { slugs: slugs.join(",") }, { revalidate: REVALIDATE.list }),205 explore: (q: ExploreQuery) => apiGet<ExploreResponse>("/explore", q as Query, { revalidate: REVALIDATE.list }),206 coverage: () => apiGet<CoverageReport>("/coverage", undefined, { revalidate: REVALIDATE.list }),207 coverageSources: () => apiGet<SourceCoverage[]>("/coverage/sources", undefined, { revalidate: REVALIDATE.list }),208 aiIndex: () => apiGet<AiIndex>("/ai-infrastructure", undefined, { revalidate: REVALIDATE.list }),209 power: () => apiGet<PowerOverview>("/power", undefined, { revalidate: REVALIDATE.list }),210 connectivity: () => apiGet<ConnectivityOverview>("/connectivity", undefined, { revalidate: REVALIDATE.list }),211 timeMachine: () => apiGet<TimeMachine>("/time-machine", undefined, { revalidate: REVALIDATE.list }),212 downloadDatasets: () => apiGet<DownloadDataset[]>("/download/datasets", undefined, { revalidate: REVALIDATE.list }),213 docsMeta: () => apiGet<ApiEndpointDoc[]>("/docs-meta", undefined, { revalidate: 600 }),214 watchlist: () => apiGet<WatchlistItem[]>("/watchlist", undefined, { revalidate: 0 }),215 watchlistFeed: (q?: Query) => apiGet<EventDTO[]>("/watchlist/feed", q, { revalidate: 0 }),216};217218/** Event-list query for /live and the home module. */219export interface EventQuery {220 type?: string;221 country?: string;222 operator?: string;223 min_significance?: number;224 since?: string;225 page?: number;226 per_page?: number;227}228