| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useCallback, useEffect, useState } from "react"; |
| import type { |
| AutomationRow, |
| ConnectorRow, |
| DatabaseRow, |
| FleetCost, |
| Overview, |
| PlatformUser, |
| ReleasesPayload, |
| } from "./platformAdminApi"; |
| import { |
| getAutomations, |
| getAws, |
| getReleases, |
| getConnectors, |
| getDatabases, |
| getOverview, |
| getUsers, |
| } from "./platformAdminApi"; |
|
|
| |
|
|
| const int = (n: number) => n.toLocaleString(); |
|
|
| |
| function Count({ n, why }: { n: number | null | undefined; why?: string }) { |
| if (n === null || n === undefined) |
| return ( |
| <span className="padmin-unknown" title={why || "This could not be read just now"}> |
| — |
| </span> |
| ); |
| return <>{int(n)}</>; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function ago(stamp: string): string { |
| if (!stamp) return "never"; |
| const t = Date.parse(stamp.includes("T") ? stamp : stamp.replace(" ", "T")); |
| if (Number.isNaN(t)) return stamp; |
| const mins = Math.floor((Date.now() - t) / 60000); |
| if (mins < 2) return "just now"; |
| if (mins < 60) return `${mins} minutes ago`; |
| const hours = Math.floor(mins / 60); |
| if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; |
| const days = Math.floor(hours / 24); |
| if (days < 60) return `${days} day${days === 1 ? "" : "s"} ago`; |
| return `${Math.floor(days / 30)} months ago`; |
| } |
|
|
| type DrillKind = "users" | "databases" | "connectors" | "automations"; |
|
|
| const DRILL_NOUN: Record<DrillKind, string> = { |
| users: "People", |
| databases: "Databases", |
| connectors: "Data sources", |
| automations: "Automations", |
| }; |
|
|
| |
| function Skeleton({ rows = 3 }: { rows?: number }) { |
| return ( |
| <div className="padmin-skel" aria-hidden="true"> |
| {Array.from({ length: rows }, (_, i) => ( |
| <div key={i} className="padmin-skel-row" /> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
|
|
| export function AdminPane({ user }: { user: { username: string; name: string; role: string } }) { |
| const [ov, setOv] = useState<Overview | null>(null); |
| const [err, setErr] = useState(""); |
| const [fleet, setFleet] = useState<{ rows: AutomationRow[]; cost: FleetCost } | null>(null); |
|
|
| const [drill, setDrill] = useState<{ kind: DrillKind; tenant: string } | null>(null); |
| const [drillRows, setDrillRows] = useState<unknown[] | null>(null); |
| const [drillNote, setDrillNote] = useState(""); |
|
|
| const [aws, setAws] = useState<{ text: string; note: string; available: boolean } | null>(null); |
| |
| |
| const [rel, setRel] = useState<ReleasesPayload | null>(null); |
| const [awsBusy, setAwsBusy] = useState(false); |
|
|
| const load = useCallback(() => { |
| setOv(null); |
| void getOverview().then((r) => { |
| if (r.ok) { |
| setOv(r.data); |
| setErr(""); |
| } else setErr(r.message); |
| }); |
| void getAutomations().then((r) => { |
| if (r.ok) setFleet({ rows: r.data.automations, cost: r.data.cost }); |
| }); |
| void getReleases().then((r) => { |
| if (r.ok) setRel(r.data); |
| }); |
| }, []); |
| useEffect(load, [load]); |
|
|
| const openDrill = useCallback((kind: DrillKind, tenant: string) => { |
| setDrill({ kind, tenant }); |
| setDrillRows(null); |
| setDrillNote(""); |
| const fetcher = |
| kind === "users" |
| ? getUsers |
| : kind === "databases" |
| ? getDatabases |
| : kind === "connectors" |
| ? getConnectors |
| : getAutomations; |
| void fetcher(tenant || undefined).then((r) => { |
| if (!r.ok) { |
| setDrillRows([]); |
| setDrillNote(r.message); |
| return; |
| } |
| const d = r.data as Record<string, unknown>; |
| setDrillRows((d[kind] as unknown[]) ?? []); |
| |
| |
| const errors = (d.errors as Record<string, string>) ?? {}; |
| const words = Object.entries(errors).map(([t, e]) => `${t}: ${e}`); |
| setDrillNote( |
| [typeof d.stampsNote === "string" ? d.stampsNote : "", ...words] |
| .filter(Boolean) |
| .join(" · ") |
| ); |
| }); |
| }, []); |
|
|
| const loadAws = useCallback(() => { |
| setAwsBusy(true); |
| void getAws(7).then((r) => { |
| setAwsBusy(false); |
| if (r.ok) setAws(r.data.report); |
| else setAws({ text: "", note: r.message, available: false }); |
| }); |
| }, []); |
|
|
| return ( |
| <div className="set-pane"> |
| <h3 className="set-h">Loopable admin</h3> |
| <p className="set-help set-pane-intro"> |
| Every workspace on the platform, signed in as {user.name}. Counts open the rows they were |
| computed from; anything that could not be read shows a dash and says why, rather than a |
| zero. |
| </p> |
| {err ? <p className="set-error">{err}</p> : null} |
| |
| {!ov ? ( |
| <Skeleton rows={4} /> |
| ) : ( |
| <> |
| <div className="padmin-totals"> |
| <Total label="Workspaces" value={int(ov.totals.tenants)} /> |
| <Total label="People" value={int(ov.totals.users)} /> |
| <Total label="Databases" value={int(ov.totals.databases)} /> |
| <Total label="Records" value={int(ov.totals.rows)} /> |
| <Total |
| label="Automation cost" |
| value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "—"} |
| hint={fleet?.cost.basis} |
| /> |
| </div> |
| {ov.totals.unknownTenants > 0 ? ( |
| <p className="set-help padmin-caveat"> |
| {ov.totals.unknownTenants} workspace |
| {ov.totals.unknownTenants === 1 ? "" : "s"} could not be read, so the totals above |
| exclude {ov.totals.unknownTenants === 1 ? "it" : "them"}. |
| </p> |
| ) : null} |
| |
| <div className="padmin-tablewrap"> |
| <table className="padmin-table"> |
| <thead> |
| <tr> |
| <th>Workspace</th> |
| <th>Storage</th> |
| <th className="padmin-num">People</th> |
| <th className="padmin-num">Databases</th> |
| <th className="padmin-num">Records</th> |
| <th className="padmin-num">Sources</th> |
| <th className="padmin-num">Automations</th> |
| </tr> |
| </thead> |
| <tbody> |
| {ov.tenants.map((t) => ( |
| <tr key={t.slug}> |
| <td> |
| <span className="padmin-name">{t.name}</span> |
| <span className="padmin-meta"> |
| {t.slug} |
| {t.status !== "active" ? ` · ${t.status}` : ""} |
| {t.errors && t.errors.length ? ` · ${t.errors[0]}` : ""} |
| </span> |
| </td> |
| <td> |
| <span className="padmin-meta"> |
| {t.storeRepo && t.storeRepo.includes("/") |
| ? "own repository" |
| : t.storePrefix |
| ? "shared repository" |
| : "tenant #0 repository"} |
| {t.keychainLocked ? " · keychain locked" : ""} |
| </span> |
| </td> |
| <DrillCell |
| n={t.users} |
| onOpen={() => openDrill("users", t.slug)} |
| title={`${t.admins} administrator${t.admins === 1 ? "" : "s"}`} |
| /> |
| <DrillCell |
| n={t.databases} |
| onOpen={() => openDrill("databases", t.slug)} |
| why={t.errors?.[0]} |
| /> |
| <DrillCell |
| n={t.rows} |
| onOpen={() => openDrill("databases", t.slug)} |
| why={t.errors?.[0]} |
| /> |
| <DrillCell |
| n={t.connectors} |
| onOpen={() => openDrill("connectors", t.slug)} |
| why={t.errors?.[0]} |
| title={ |
| t.connectorsPaused ? `${t.connectorsPaused} paused` : "none paused" |
| } |
| /> |
| <DrillCell |
| n={t.automations} |
| onOpen={() => openDrill("automations", t.slug)} |
| why={t.errors?.[0]} |
| title={ |
| t.automationsEnabled != null |
| ? `${t.automationsEnabled} scheduled` |
| : undefined |
| } |
| /> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| |
| <p className="set-help padmin-caveat"> |
| Read {ago(ov.generatedAt)} in {ov.tookMs} ms.{" "} |
| {ov.totals.orphanUsers > 0 |
| ? `${ov.totals.orphanUsers} account${ |
| ov.totals.orphanUsers === 1 ? "" : "s" |
| } belong to a workspace this deployment no longer knows. ` |
| : ""} |
| <button type="button" className="padmin-link" onClick={load}> |
| Refresh |
| </button> |
| </p> |
| |
| {drill ? ( |
| <section className="padmin-drill"> |
| <div className="padmin-drill-head"> |
| <h4 className="padmin-h4"> |
| {DRILL_NOUN[drill.kind]} |
| {drill.tenant ? ` — ${drill.tenant}` : ""} |
| </h4> |
| <button type="button" className="padmin-link" onClick={() => setDrill(null)}> |
| Close |
| </button> |
| </div> |
| {drillRows === null ? ( |
| <Skeleton rows={2} /> |
| ) : drillRows.length === 0 ? ( |
| <p className="set-help">Nothing here yet.</p> |
| ) : ( |
| <DrillTable kind={drill.kind} rows={drillRows} /> |
| )} |
| {drillNote ? <p className="set-help padmin-caveat">{drillNote}</p> : null} |
| </section> |
| ) : null} |
| |
| {fleet ? ( |
| <section className="padmin-block"> |
| {/* Named for what it SHOWS. The fleet's rows live one level down, |
| behind the Automations count — this section is the cost and the |
| reasoning behind it, so calling it "fleet" would promise a |
| table that is deliberately not here. */} |
| <h4 className="padmin-h4">Automation cost</h4> |
| <p className="set-help"> |
| {fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length} |
| {fleet.cost.fleetRunsPerDay |
| ? `, about ${fleet.cost.fleetRunsPerDay} runs a day` |
| : ""} |
| {fleet.cost.unknownCadence |
| ? ` (${fleet.cost.unknownCadence} on a custom cadence, not counted)` |
| : ""} |
| . {fleet.cost.basis} |
| </p> |
| </section> |
| ) : null} |
| |
| {/* ── Wave 20 (owner item 12 / R6): what is running where ────────────────── |
| READ-ONLY BY RULING. The operator sees both environments and every cut |
| release; moving one is a CLI command, printed below rather than wired to |
| a button, so no browser session can rewrite production. */} |
| <section className="padmin-block"> |
| <h4 className="padmin-h4">Releases</h4> |
| {!rel ? ( |
| <Skeleton rows={2} /> |
| ) : ( |
| <> |
| <table className="padmin-table"> |
| <thead> |
| <tr> |
| <th>Environment</th> |
| <th>Version</th> |
| <th>Stage</th> |
| <th>Space</th> |
| </tr> |
| </thead> |
| <tbody> |
| {rel.environments.map((e) => ( |
| <tr key={e.env}> |
| <td>{e.env === "live" ? "Live (pinned)" : "Staging (follows the tree)"}</td> |
| {/* ⚠ An unreadable version is a NOTE, never a blank. A blank cell in a |
| column headed "Version" reads as "nothing is deployed", which about a |
| production environment is the worst available way to be wrong. */} |
| <td>{e.version || <span className="set-help">{e.note || "unknown"}</span>}</td> |
| <td>{e.stage || "—"}</td> |
| {/* The Space ID as TEXT, not a link: the server deliberately builds no |
| URL (portability C1 — a host literal in runtime code hard-codes the |
| current host into a process designed to move). */} |
| <td>{e.space}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| {rel.releases.length ? ( |
| <table className="padmin-table"> |
| <thead> |
| <tr> |
| <th>Release</th> |
| <th>Commit</th> |
| <th>Cut</th> |
| <th>What shipped</th> |
| </tr> |
| </thead> |
| <tbody> |
| {rel.releases.map((r) => ( |
| <tr key={r.version}> |
| <td>{r.version}</td> |
| <td>{r.sha}</td> |
| <td>{r.date}</td> |
| <td>{r.subject}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| ) : ( |
| <p className="set-help"> |
| No tagged releases were readable from either Space. |
| </p> |
| )} |
| <p className="set-help"> |
| To move Live to another version, or roll it back, run:{" "} |
| <code>{rel.promote}</code> |
| </p> |
| </> |
| )} |
| </section> |
|
|
| <section className="padmin-block"> |
| <h4 className="padmin-h4">AWS cron</h4> |
| {!aws ? ( |
| <p className="set-help"> |
| The external tick that wakes this app on schedule. Reading its usage runs a live |
| CloudWatch query and takes a few seconds.{" "} |
| <button |
| type="button" |
| className="padmin-link" |
| onClick={loadAws} |
| disabled={awsBusy} |
| > |
| {awsBusy ? "Reading…" : "Check usage"} |
| </button> |
| </p> |
| ) : aws.available ? ( |
| <pre className="padmin-pre">{aws.text}</pre> |
| ) : ( |
| <p className="set-help">{aws.note}</p> |
| )} |
| </section> |
| </> |
| )} |
| </div> |
| ); |
| } |
|
|
| function Total({ label, value, hint }: { label: string; value: string; hint?: string }) { |
| |
| |
| return ( |
| <div className={"padmin-total" + (hint ? " has-hint" : "")} title={hint || undefined}> |
| <span className="padmin-total-label">{label}</span> |
| <span className="padmin-total-value">{value}</span> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function DrillCell({ |
| n, |
| onOpen, |
| why, |
| title, |
| }: { |
| n: number | null | undefined; |
| onOpen: () => void; |
| why?: string; |
| title?: string; |
| }) { |
| if (n === null || n === undefined) |
| return ( |
| <td className="padmin-num"> |
| <Count n={n} why={why} /> |
| </td> |
| ); |
| return ( |
| <td className="padmin-num"> |
| <button type="button" className="padmin-drill-btn" onClick={onOpen} title={title}> |
| {int(n)} |
| </button> |
| </td> |
| ); |
| } |
|
|
| function DrillTable({ kind, rows }: { kind: DrillKind; rows: unknown[] }) { |
| if (kind === "users") { |
| const rs = rows as PlatformUser[]; |
| return ( |
| <div className="padmin-tablewrap"> |
| <table className="padmin-table"> |
| <thead> |
| <tr> |
| <th>Person</th> |
| <th>Workspace</th> |
| <th>Role</th> |
| <th>Last sign-in</th> |
| <th>Last active</th> |
| </tr> |
| </thead> |
| <tbody> |
| {rs.map((u) => ( |
| <tr key={`${u.tenant}/${u.username}`}> |
| <td> |
| <span className="padmin-name">{u.name}</span> |
| <span className="padmin-meta"> |
| {u.username} |
| {u.email ? ` · ${u.email}` : ""} |
| {u.active ? "" : " · deactivated"} |
| </span> |
| </td> |
| <td>{u.tenant}</td> |
| <td> |
| {u.role} |
| {u.platformAdmin ? " · platform" : ""} |
| </td> |
| <td>{ago(u.lastLogin)}</td> |
| <td>{ago(u.lastActive)}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
| if (kind === "databases") { |
| const rs = rows as DatabaseRow[]; |
| return ( |
| <div className="padmin-tablewrap"> |
| <table className="padmin-table"> |
| <thead> |
| <tr> |
| <th>Database</th> |
| <th>Workspace</th> |
| <th>Built from</th> |
| <th className="padmin-num">Fields</th> |
| <th className="padmin-num">Records</th> |
| </tr> |
| </thead> |
| <tbody> |
| {rs.map((d) => ( |
| <tr key={`${d.tenant}/${d.key}`}> |
| <td> |
| <span className="padmin-name">{d.label}</span> |
| <span className="padmin-meta"> |
| {d.key} |
| {d.createdBy ? ` · ${d.createdBy}` : ""} |
| </span> |
| </td> |
| <td>{d.tenant}</td> |
| <td>{d.source}</td> |
| <td className="padmin-num">{int(d.fields)}</td> |
| <td className="padmin-num">{int(d.rowCount)}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
| if (kind === "connectors") { |
| const rs = rows as ConnectorRow[]; |
| return ( |
| <div className="padmin-tablewrap"> |
| <table className="padmin-table"> |
| <thead> |
| <tr> |
| <th>Source</th> |
| <th>Workspace</th> |
| <th>Kind</th> |
| <th>Status</th> |
| </tr> |
| </thead> |
| <tbody> |
| {rs.map((c) => ( |
| <tr key={`${c.tenant}/${c.key}`}> |
| <td> |
| <span className="padmin-name">{c.label}</span> |
| <span className="padmin-meta"> |
| {c.source === "env" ? "environment credentials" : "keychain"} |
| </span> |
| </td> |
| <td>{c.tenant}</td> |
| <td>{c.type}</td> |
| <td> |
| <span |
| className={ |
| "padmin-dot " + |
| (c.paused ? "is-paused" : c.active ? "is-live" : "is-idle") |
| } |
| /> |
| {c.paused ? "paused" : c.active ? "serving" : "stored"} |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
| const rs = rows as AutomationRow[]; |
| return ( |
| <div className="padmin-tablewrap"> |
| <table className="padmin-table"> |
| <thead> |
| <tr> |
| <th>Automation</th> |
| <th>Workspace</th> |
| <th>Schedule</th> |
| <th className="padmin-num">Runs a day</th> |
| <th>Last run</th> |
| </tr> |
| </thead> |
| <tbody> |
| {rs.map((a) => ( |
| <tr key={`${a.tenant}/${a.id}`}> |
| <td> |
| <span className="padmin-name">{a.name}</span> |
| <span className="padmin-meta"> |
| {a.kind} |
| {a.failedRetained |
| ? ` · ${a.failedRetained} of the last ${a.runsRetained} runs failed` |
| : ""} |
| </span> |
| </td> |
| <td>{a.tenant}</td> |
| <td>{a.enabled ? a.cron || "scheduled" : "paused"}</td> |
| <td className="padmin-num"> |
| {a.enabled ? (a.runsPerDay === null ? "custom" : a.runsPerDay) : "—"} |
| </td> |
| <td> |
| <span |
| className={ |
| "padmin-dot " + |
| (a.state === "error" ? "is-error" : a.state === "ok" ? "is-live" : "is-idle") |
| } |
| /> |
| {/* Verbatim, not relative: the engine writes this stamp with no |
| zone (`automation_engine._iso()` is naive local-to-container), |
| so "x hours ago" would be wrong by the viewer's offset. */} |
| {a.lastRunAt ? a.lastRunAt.replace("T", " ") : "never"} |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
|
|
| export default AdminPane; |
|
|