Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion apps/api/src/routes/admin-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,23 @@ function stubEnv(
return { AUTH: auth, REGISTRY: fakeKv([]) } as unknown as Env;
}

function fakeKv(names: string[]): Pick<KVNamespace, "list"> {
function fakeKv(
names: string[],
records: Record<string, Record<string, unknown>> = {},
): Pick<KVNamespace, "list" | "get"> {
return {
list: (async () => ({
keys: names.map((name) => ({ name: `ws:${name}` })),
list_complete: true,
cacheStatus: null,
})) as unknown as KVNamespace["list"],
// The workspaces list now reads each record for its plan + BYOB flags
// (loadWorkspaceRecord). Return the seeded record (already parsed, since
// callers pass `{ type: "json" }`) or null for an unseeded workspace.
get: (async (key: string) => {
const name = key.startsWith("ws:") ? key.slice(3) : key;
return records[name] ?? null;
}) as unknown as KVNamespace["get"],
};
}

Expand Down Expand Up @@ -92,11 +102,45 @@ describe("GET /admin-ui/workspaces", () => {
organization: { id: "org1", slug: "acme", name: "acme" },
memberCount: 2,
pendingInviteCount: 1,
plan: "free",
byob: false,
},
],
});
});

it("reports plan + BYOB from each workspace record", async () => {
const auth = stubAuth((req) => {
const url = new URL(req.url);
if (url.pathname === "/api/auth/get-session") {
return new Response(JSON.stringify({ session: {}, user: ADMIN_USER }), { status: 200 });
}
if (url.pathname === "/internal/orgs/summaries") {
return Response.json({ organizations: [] });
}
return new Response(null, { status: 404 });
});
const env = {
AUTH: auth,
REGISTRY: fakeKv(["paid", "byo", "plain"], {
// Pro tier, still on the shared bucket.
paid: { plan: "pro" },
// Free tier, but on their own bucket (customer S3 credentials, no
// binding) → isByoRecord true.
byo: { accountId: "acc", accessKeyId: "ak", secretAccessKey: "sk" },
}),
} as unknown as Env;
const res = await app().request("/admin-ui/workspaces", {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as {
workspaces: { workspace: string; plan: string; byob: boolean }[];
};
const byName = Object.fromEntries(body.workspaces.map((w) => [w.workspace, w]));
expect(byName.paid).toMatchObject({ plan: "pro", byob: false });
expect(byName.byo).toMatchObject({ plan: "free", byob: true });
expect(byName.plain).toMatchObject({ plan: "free", byob: false });
});

it("leaves org null when a workspace has no matching summary", async () => {
const auth = stubAuth((req) => {
const url = new URL(req.url);
Expand All @@ -118,6 +162,8 @@ describe("GET /admin-ui/workspaces", () => {
organization: null,
memberCount: 0,
pendingInviteCount: 0,
plan: "free",
byob: false,
},
],
});
Expand Down
36 changes: 32 additions & 4 deletions apps/api/src/routes/admin-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,15 @@ import { getWorkspaceUsage } from "../usage";
import {
byoBucketAllowed,
isPurgedTombstone,
loadWorkspaceRecord,
loadWorkspaceRecordRaw,
type WorkspaceRecord,
} from "../workspace";
import { mutateWorkspaceRecord } from "../workspace-mutate";
import { LIMIT_FIELDS, validateLimitsPatch } from "../workspace-limits";
import { planResponse, planSourceFor, validatePlanPatch } from "../workspace-plan";
import { getPlan, resolveEffectiveLimits, type WorkspacePlanLimits } from "@uploads/billing";
import { storageStatusResponse } from "./workspace-storage";
import { isByoRecord, storageStatusResponse } from "./workspace-storage";
import { dbFor } from "../db-session";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
Expand Down Expand Up @@ -200,13 +201,29 @@ async function allOrgSummaries(env: Env): Promise<Map<string, OrgSummary>> {
return map;
}

/** One row of the `/admin-ui/workspaces` list: the KV workspace + its org counts. */
function workspaceSummaryResponse(name: string, summary: OrgSummary | undefined) {
/**
* One row of the `/admin-ui/workspaces` list: the KV workspace + its org
* counts, plus the two at-a-glance signals an operator otherwise had to
* expand a row to see. Both derive from the workspace record alone (no extra
* AUTH round-trip): `plan` is the catalog id (`getPlan` fails open to "free"
* for a legacy/unapplied record, never "pro"), and `byob` is the storage
* mode's `"byo"` bit — the same `isByoRecord` gate `storageStatusResponse`
* uses for `mode`. `plan` deliberately does NOT distinguish Stripe-paid from
* admin-comped (that needs the per-workspace subscription lookup the drawer's
* plan endpoint does); the list only answers "free vs paid tier".
*/
function workspaceSummaryResponse(
name: string,
summary: OrgSummary | undefined,
record: WorkspaceRecord | null,
) {
return {
workspace: name,
organization: summary?.organization ?? null,
memberCount: summary?.memberCount ?? 0,
pendingInviteCount: summary?.pendingInviteCount ?? 0,
plan: getPlan(record?.plan).id,
byob: record ? isByoRecord(record) : false,
};
}

Expand Down Expand Up @@ -588,7 +605,18 @@ export const adminUi = new Hono<SessionVars>()
} while (cursor);

const summaries = await allOrgSummaries(c.env);
const workspaces = names.map((name) => workspaceSummaryResponse(name, summaries.get(name)));
// Plan + BYOB come off each workspace record. KV has no multi-get and the
// list enumeration returns keys only, so a per-workspace read is
// unavoidable; run them as one parallel fan-out rather than serially. This
// adds N subrequests (one KV get each) on top of the list pages — fine for
// the operator surface's current workspace count, but note it scales with N
// and would need chunking or a denormalized summary blob before N could
// approach the Workers subrequest ceiling. A null record (soft-deleted /
// purged tombstone) falls back to free / shared, same as an unknown workspace.
const records = await Promise.all(names.map((name) => loadWorkspaceRecord(c.env, name)));
const workspaces = names.map((name, i) =>
workspaceSummaryResponse(name, summaries.get(name), records[i]),
);
return c.json({ workspaces });
})

Expand Down
169 changes: 169 additions & 0 deletions apps/web/src/components/admin/AdminWorkspacesTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* The admin operator "Workspaces" view: a shadcn `Table` of every registered
* workspace with at-a-glance Plan and BYOB columns (previously only visible by
* expanding a row), and a right-hand `Sheet` drawer for the full per-workspace
* detail (people, plan, limits, storage, GitHub links). Replaces the imperative
* innerHTML table the page shipped with.
*
* The single island mounted by `pages/admin/index.astro` (manual SSR +
* hydrateRoot, no `client:*` — same mechanism as WorkspaceFileTable). It
* composes `IslandErrorBoundary` itself so mounting it directly reconciles
* against the SSR'd tree without an extra wrapper. Data is fetched on mount
* against `/admin-ui/*`, which independently enforces admin access — the
* client-side gate in AdminLayout is a UX affordance only.
*/
import { useEffect, useMemo, useState } from "react";
import { Badge } from "@uploads/ui/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@uploads/ui/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@uploads/ui/components/ui/table";
import "@uploads/ui/styles.css";
import { IslandErrorBoundary } from "../IslandErrorBoundary";
import { makeAdminApi, type AdminWorkspaceSummary } from "../../lib/admin-api";
import { WorkspaceDetail } from "./WorkspaceDetail";

type LoadState =
| { status: "loading" }
| { status: "error" }
| { status: "ok"; workspaces: AdminWorkspaceSummary[] };

export interface AdminWorkspacesTableProps {
apiOrigin: string;
}

function AdminWorkspacesTableInner({ apiOrigin }: AdminWorkspacesTableProps) {
const api = useMemo(() => makeAdminApi(apiOrigin), [apiOrigin]);
const [state, setState] = useState<LoadState>({ status: "loading" });
const [selected, setSelected] = useState<AdminWorkspaceSummary | null>(null);

useEffect(() => {
let alive = true;
api
.listWorkspaces()
.then((workspaces) => alive && setState({ status: "ok", workspaces }))
.catch(() => alive && setState({ status: "error" }));
return () => {
alive = false;
};
}, [api]);

if (state.status === "loading") {
return <p className="text-(length:--text-meta) text-muted-foreground">Loading…</p>;
}
if (state.status === "error") {
return <p className="text-(length:--text-meta) text-destructive">Failed to load workspaces.</p>;
}
if (state.workspaces.length === 0) {
return <p className="text-(length:--text-meta) text-muted-foreground">No workspaces yet.</p>;
}

return (
<>
<div className="w-full overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Workspace</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Storage</TableHead>
<TableHead className="text-right">Members</TableHead>
<TableHead className="text-right">Pending</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{state.workspaces.map((ws) => (
<TableRow
key={ws.workspace}
tabIndex={0}
role="button"
aria-label={`Open ${ws.workspace} details`}
className="cursor-pointer"
onClick={() => setSelected(ws)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(ws);
}
}}
>
<TableCell className="font-mono font-medium text-foreground">
{ws.workspace}
</TableCell>
<TableCell className={ws.organization ? "" : "text-muted-foreground"}>
{ws.organization ? ws.organization.name : "no organization yet"}
</TableCell>
<TableCell>
{ws.plan === "free" ? (
<span className="text-muted-foreground">Free</span>
) : (
<Badge variant="default">{ws.plan === "pro" ? "Pro" : ws.plan}</Badge>
)}
</TableCell>
<TableCell>
{ws.byob ? (
<Badge variant="secondary">BYO</Badge>
) : (
<span className="text-muted-foreground">Shared</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">{ws.memberCount}</TableCell>
<TableCell className="text-right tabular-nums">{ws.pendingInviteCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

<Sheet
open={selected !== null}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
{selected && (
<>
<SheetHeader>
<SheetTitle className="font-mono">{selected.workspace}</SheetTitle>
<SheetDescription>
{selected.organization
? selected.organization.name
: "No organization provisioned yet"}
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-6">
<WorkspaceDetail
key={selected.workspace}
api={api}
workspace={selected.workspace}
hasOrg={selected.organization !== null}
/>
</div>
</>
)}
</SheetContent>
</Sheet>
</>
);
}

export function AdminWorkspacesTable(props: AdminWorkspacesTableProps) {
return (
<IslandErrorBoundary>
<AdminWorkspacesTableInner {...props} />
</IslandErrorBoundary>
);
}
35 changes: 35 additions & 0 deletions apps/web/src/components/admin/GithubLinksSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* GitHub repos claimed by the workspace — read-only list, the React port of
* the imperative `renderGithubLinks`. Renders nothing when there are no links
* (same as before), so the section collapses out of the drawer.
*/
import type { AdminApi } from "../../lib/admin-api";
import { Muted, SectionHeading } from "./StatusLine";
import { useAdminResource } from "./use-admin-resource";

export function GithubLinksSection({ api, workspace }: { api: AdminApi; workspace: string }) {
const { data: links, error } = useAdminResource(
() => api.getGithubLinks(workspace),
[api, workspace],
);

if (error) return <Muted>Failed to load GitHub links.</Muted>;
if (!links || links.length === 0) return null;

return (
<div>
<SectionHeading>GitHub repos claimed</SectionHeading>
<ul className="grid list-none gap-1 p-0">
{links.map((l) => (
<li
key={`${l.repo}-${l.source}`}
className="flex justify-between gap-2 text-(length:--text-meta)"
>
<span className="font-mono text-(length:--text-micro) text-foreground">{l.repo}</span>
<span className="text-muted-foreground">{l.source}</span>
</li>
))}
</ul>
</div>
);
}
Loading
Loading