spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import Link from "next/link";4import { useMemo, useState } from "react";5import type { FacilityDetail, OperatorSummary, SearchResponse } from "@dci/core";6import { FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS } from "@dci/core";7import { Badge, Button, ConfidenceBadge, EmptyState, PrecisionBadge, SkeletonBlock, StatusBadge } from "@/components/ui";8import { cn } from "@/components/ui/cn";9import { DASH, fmtDateTimeUtc, fmtValue, placeLine } from "@/lib/format";10import { PRECISION_LABEL, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";11import { routes } from "@/lib/routes";12import { adminFetch, type FacilityPatch, type FacilityPatchResult } from "@/lib/admin-api";13import { useDebounced } from "@/lib/hooks";14import { Checkbox, ErrorNote, Field, Input, KV, Mono, Panel, Select, Textarea } from "./primitives";15import { AdminPageHeader } from "./shell";16import { useToast } from "./toast";17import { useAdminMutation, useAdminQuery } from "./use-admin-query";1819interface 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}3839function 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}6061const 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};6667/** Build the PATCH body from the diff between the initial and the current form. Returns validation errors per field. */68function 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}112113function 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 clear130 </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}153154function 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 view195 </Link>196 <Button size="xs" variant="ghost" onClick={() => setTarget(null)}>197 change198 </Button>199 <Button size="xs" variant="danger" onClick={() => void merge()} disabled={busy !== null}>200 Merge now201 </Button>202 </div>203 )}204 </div>205 );206}207208export 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 });227228 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 };248249 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" />;258259 return (260 <div>261 <AdminPageHeader262 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 page273 </Link>274 </span>275 }276 aside={277 <>278 <Button size="sm" variant="ghost" onClick={() => setEdits(null)} disabled={!edits}>279 Reset280 </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 )}306307 <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>349350 <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 OpenStreetMap373 </a>374 . Address on record: {f.address ?? DASH}375 </p>376 )}377 </Panel>378379 <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>393394 <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>397398 <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>402403 <div className="space-y-3">404 <Panel title="Current record" description="As served by the public API">405 <KV406 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}450