loopable / web /src /settings /AdminPane.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
24.2 kB
// ---------------------------------------------------------------------------
// settings / AdminPane.tsx — THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 /
// R3+R4, contract C2).
//
// The one surface in this product that looks ACROSS tenants: who our customers
// are, how many people use each workspace, what they have built in it, whether
// their data sources are alive, and what the automation fleet costs.
//
// SELF-CONTAINED BY CONTRACT (C2). It fetches its own data via
// `platformAdminApi` and takes ONE prop, so mounting it is a rail entry plus a
// line in the pane switch — `SettingsModal.tsx` is another session's file this
// wave and must not have to learn anything about this pane's data.
//
// ⚠ THE PROP IS TYPED STRUCTURALLY, not as `SessionUser` from `../shell/session`.
// The mount site passes the shell's session user (a wider object), which
// satisfies this shape by structural typing — and this pane therefore imports
// nothing from the shell, which is what the wave's cross-fence rule asks for
// while `session.ts` is open on another desk.
//
// ⛔ EVERY NUMBER HERE IS THE SERVER'S, AND EVERY NUMBER DRILLS. Clicking a count
// opens the rows it was computed from — the same collector, projected twice
// ([[no-unverifiable-aggregates]]). A count that could not be READ renders as an
// em dash with the reason, never as a zero: "this customer has no databases" and
// "we could not look" are different facts and the pane refuses to conflate them.
//
// DESIGN: sentence-case micro-labels (R6 — no caps in app chrome), short noun
// headers, tabular figures right-aligned, hairlines not shadows, tokens only,
// no emojis (DESIGN.md §2–§4).
// ---------------------------------------------------------------------------
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";
// --- formatting -------------------------------------------------------------
const int = (n: number) => n.toLocaleString();
/** A count that may be unknown. `null` is NOT zero — see the api module's header. */
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)}</>;
}
/** "3 days ago" for a stamp, "never" for an absent one. Never a fabricated date.
*
* ⛔ ONLY FOR OFFSET-BEARING STAMPS. `Date.parse` reads a stamp with no zone as
* BROWSER-LOCAL, so running a container-local timestamp through this reports the
* viewer's UTC offset as elapsed time — hours of error in a column people scan,
* and a future-dated stamp that renders as "just now" indefinitely. Everything
* this pane passes here (`last_login`, `last_active`, `generatedAt`) is written
* with an explicit offset; the automation engine's `lastRunAt` is NOT, so it is
* rendered verbatim instead. */
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",
};
/** The loading shape — a skeleton, not a spinner (DESIGN.md §4). */
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>
);
}
// --- the pane ---------------------------------------------------------------
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);
// Wave 20 (R6): the releases panel. Loaded with the overview rather than on demand — it is
// two small Hub reads and the first question an operator opens this pane to answer.
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[]) ?? []);
// The server's own honest note travels with the rows rather than being
// re-invented here: it is the one that knows WHY a tenant is missing.
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 }) {
// A tooltip nobody can see is a tooltip nobody reads: the label carries a
// dotted underline exactly when there is something behind it.
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>
);
}
/** A numeric cell that opens its own rows. Unknown counts are not clickable —
* there is nothing to drill INTO when the read failed, and a button that opens
* an empty table would read as "none". */
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;