loopable / web /src /settings /platformAdminApi.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
7.93 kB
// ---------------------------------------------------------------------------
// settings / platformAdminApi.ts β€” the Loopable admin plane's client half
// (wave 19, owner item 13 / R3+R4, contract C2).
//
// β›” THE CLIENT HIDES; THE SERVER FORBIDS. Every route here is walled by
// `core.platform_admin.is_platform_admin` β€” a double lock (the record flag AND
// the `loopable` tenant), fail-closed, proven by `verify_api.py` enumerating the
// router and having a tenant admin try every path. The `platformAdmin` flag on
// `GET /settings` exists so the rail does not paint a door that would 403; it is
// never the permission ([[aios-permissioning]]).
//
// SELF-CONTAINED BY CONTRACT (C2). This module and `AdminPane.tsx` are the only
// two files the admin plane owns on the client, and they import nothing from the
// rest of `settings/` β€” the pane fetches its own data, so mounting it is one line
// in the rail and one line in the pane switch.
//
// ⚠ NULL IS A REAL VALUE HERE, AND IT IS NOT ZERO. Any per-tenant count can come
// back `null`, meaning "that subsystem could not be read for this customer" β€” a
// suspended tenant record, a locked keychain, an HF repo that did not answer.
// Rendering it as 0 would tell an operator a customer has no databases when the
// truth is that we did not look. The types say `number | null` for exactly that
// reason and the pane renders the difference.
// ---------------------------------------------------------------------------
import { API_V1, CREDENTIALS } from "../apiContract";
export type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
async function call<T>(path: string): Promise<ApiResult<T>> {
try {
const res = await fetch(`${API_V1}/platform-admin${path}`, { credentials: CREDENTIALS });
let body: unknown = null;
try {
body = await res.json();
} catch {
/* an unreadable body is handled below, never thrown at the pane */
}
if (!res.ok) {
const err = (body as { error?: { message?: string } } | null)?.error;
const message =
res.status >= 500
? "Something went wrong on our side. Try again in a moment."
: err?.message ||
(res.status === 403
? "This surface is not available to your account."
: "That could not be loaded.");
return { ok: false, status: res.status, message };
}
return { ok: true, data: body as T };
} catch {
return { ok: false, status: 0, message: "The server could not be reached." };
}
}
/** One customer. `error` / `errors` carry the honest reason a count is null. */
export interface TenantRow {
slug: string;
name: string;
/** `record` = provisioned into the control-plane bucket; `compiled` = tenant #0. */
source: string;
status: string;
domains: string[];
modules: string[] | "all";
/** R2: a dedicated dataset repo, or the shared repo. The isolation shape. */
storeRepo: string;
storePrefix: string;
error: string;
users: number;
admins: number;
databases: number | null;
rows: number | null;
connectors: number | null;
connectorsPaused?: number | null;
automations: number | null;
automationsEnabled?: number | null;
keychainLocked?: boolean | null;
errors?: string[];
}
export interface Overview {
tenants: TenantRow[];
totals: {
tenants: number;
users: number;
orphanUsers: number;
databases: number;
rows: number;
automations: number;
unknownTenants: number;
};
storeAvailable: boolean;
generatedAt: string;
tookMs: number;
}
export interface PlatformUser {
username: string;
name: string;
email: string;
tenant: string;
role: string;
active: boolean;
/** R4 β€” absent means never, and the pane says "never" rather than inventing a date. */
lastLogin: string;
lastActive: string;
platformAdmin: boolean;
}
export interface DatabaseRow {
tenant: string;
key: string;
label: string;
source: string;
createdBy: string;
created: string;
fields: number;
rowCount: number;
}
export interface ConnectorRow {
tenant: string;
key: string;
label: string;
type: string;
source: string;
active: boolean;
paused: boolean;
}
export interface AutomationRow {
tenant: string;
id: string;
name: string;
kind: string;
enabled: boolean;
cron: string;
/** null = a cadence the server would not parse; shown as "custom", never as a number. */
runsPerDay: number | null;
state: string;
lastRunAt: string;
lastSummary: string;
runsRetained: number;
failedRetained: number;
createdBy: string;
created: string;
}
export interface FleetCost {
cadence: string;
invocationsPerMonth: number;
freeRequestsPct: number;
freeSchedulerPct: number;
lambdaMb: number;
usd: number;
fleetRunsPerDay: number;
unknownCadence: number;
basis: string;
}
export interface AwsReport {
available: boolean;
text: string;
note: string;
}
export function getOverview(): Promise<ApiResult<Overview>> {
return call<Overview>("/overview");
}
/** `tenant` narrows every drill; omitted, they answer for the whole platform. */
const q = (tenant?: string) => (tenant ? `?tenant=${encodeURIComponent(tenant)}` : "");
export function getUsers(
tenant?: string
): Promise<ApiResult<{ users: PlatformUser[]; count: number; stampsNote: string }>> {
return call(`/users${q(tenant)}`);
}
export function getDatabases(
tenant?: string
): Promise<
ApiResult<{
databases: DatabaseRow[];
count: number;
rows: number;
errors: Record<string, string>;
}>
> {
return call(`/databases${q(tenant)}`);
}
export function getConnectors(
tenant?: string
): Promise<
ApiResult<{
connectors: ConnectorRow[];
count: number;
keychainLocked: Record<string, boolean>;
errors: Record<string, string>;
note: string;
}>
> {
return call(`/connectors${q(tenant)}`);
}
export function getAutomations(
tenant?: string
): Promise<
ApiResult<{
automations: AutomationRow[];
count: number;
enabled: number;
historyRetained: number;
cost: FleetCost;
errors: Record<string, string>;
tickEnabled: boolean;
}>
> {
return call(`/automations${q(tenant)}`);
}
export function getAws(days = 7): Promise<ApiResult<{ days: number; report: AwsReport }>> {
return call(`/aws?days=${encodeURIComponent(String(days))}`);
}
// ── Wave 20 (owner item 12 / R6): RELEASES ──────────────────────────────────
// Read-only by ruling. The operator SEES what is running where and what could be
// gone back to; promoting stays `deploy_web.py --promote=vN`, so no web session
// can move production. `promote` carries that command in the payload because the
// person reading this panel is exactly the person who needs it.
/** One environment. `version` is what the SPACE says about itself (its own
* `VERSION` file) β€” never inferred from a timestamp, because `last_modified`
* moves when a SECRET is pushed and would report a deploy that never happened. */
export interface ReleaseEnv {
env: string;
space: string;
/** null + a `note` = we could not read it. NOT "nothing is deployed". */
version: string | null;
note: string | null;
/** HF runtime stage (`RUNNING`, `BUILDING`, …), or null when unreadable. */
stage: string | null;
}
export interface ReleaseTag {
version: string;
sha: string;
date: string;
subject: string;
}
export interface ReleasesPayload {
/** The version THIS container is running (`AIOS_VERSION`), for the "you are here" case. */
here: string;
environments: ReleaseEnv[];
releases: ReleaseTag[];
/** The CLI that moves a version. In the payload because the panel is read-only by ruling. */
promote: string;
}
export function getReleases(): Promise<ApiResult<ReleasesPayload>> {
return call("/releases");
}