SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
22.3 KB · 450 lines tsx
Raw Blame History
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