| import { useEffect, useRef, useState } from "react"; |
|
|
| import type { ServiceAccountsSnapshot } from "@xtap-pool/shared"; |
|
|
| import type { PoolSnapshot } from "../lib/api.js"; |
| import { |
| addPoolAdmin, |
| addPoolMember, |
| addPoolMemberOrg, |
| fetchAdminPool, |
| fetchAdminEnrichment, |
| fetchAdminFreeLabels, |
| fetchAdminServiceAccounts, |
| issueServiceAccount, |
| removePoolAdmin, |
| removePoolMember, |
| removePoolMemberOrg, |
| repairPoolConfig, |
| repairServiceAccounts, |
| revokeServiceAccount, |
| revokeServiceAccountKey, |
| rotateServiceAccount, |
| } from "../lib/api.js"; |
| import type { MemberOrgGrant } from "../lib/api.js"; |
|
|
| type AdminState = |
| | { status: "loading" } |
| | { status: "ready"; pool: PoolSnapshot; busy?: string; error?: string } |
| | { status: "error"; error: string }; |
|
|
| function sortUsers(users: readonly string[]): string[] { |
| return [...users].sort((a, b) => a.localeCompare(b)); |
| } |
|
|
| function sortMemberOrgs(orgs: readonly MemberOrgGrant[]): MemberOrgGrant[] { |
| return [...orgs].sort((a, b) => a.name.localeCompare(b.name)); |
| } |
|
|
| |
| export function AdminPanel(): React.JSX.Element { |
| const [state, setState] = useState<AdminState>({ status: "loading" }); |
| const [memberInput, setMemberInput] = useState(""); |
| const [adminInput, setAdminInput] = useState(""); |
| const [orgInput, setOrgInput] = useState(""); |
| const [enrichment, setEnrichment] = useState<Awaited<ReturnType<typeof fetchAdminEnrichment>>>(); |
| const [freeLabels, setFreeLabels] = useState<Awaited<ReturnType<typeof fetchAdminFreeLabels>>>(); |
|
|
| useEffect(() => { |
| void fetchAdminPool().then( |
| ({ pool }) => { |
| setState({ status: "ready", pool }); |
| }, |
| (error: unknown) => { |
| setState({ status: "error", error: message(error) }); |
| }, |
| ); |
| }, []); |
|
|
| useEffect(() => { |
| void fetchAdminEnrichment().then(setEnrichment, () => undefined); |
| void fetchAdminFreeLabels().then(setFreeLabels, () => undefined); |
| }, []); |
|
|
| async function mutate(label: string, action: () => Promise<PoolSnapshot>): Promise<void> { |
| if (state.status !== "ready") return; |
| setState({ status: "ready", pool: state.pool, busy: label }); |
| try { |
| const pool = await action(); |
| setState({ status: "ready", pool }); |
| } catch (error) { |
| setState({ status: "ready", pool: state.pool, error: message(error) }); |
| } |
| } |
|
|
| if (state.status === "loading") { |
| return <p className="p-4 text-sm text-(--x-muted)">Loading…</p>; |
| } |
| if (state.status === "error") { |
| return <p className="p-4 text-sm text-red-500">{state.error}</p>; |
| } |
|
|
| const { pool, busy, error } = state; |
| const admins = new Set(pool.admins); |
| const bootstrapAdmins = new Set(pool.bootstrap_admins); |
|
|
| return ( |
| <div className="flex flex-col gap-6 p-4"> |
| <header className="border-b border-(--x-border) pb-4"> |
| <h2 className="text-lg font-bold">Pool Admin</h2> |
| <p className="text-sm text-(--x-muted)"> |
| {pool.members.length.toLocaleString()} members · {pool.admins.length.toLocaleString()}{" "} |
| admins · {pool.member_orgs.length.toLocaleString()} org grant |
| </p> |
| </header> |
| |
| {pool.config_error === undefined ? null : ( |
| <div className="rounded-md border border-red-400 px-3 py-2 text-sm text-red-500"> |
| <p>{pool.config_error}</p> |
| <button |
| type="button" |
| className="mt-2 rounded-md border border-red-400 px-2 py-1 font-semibold" |
| disabled={busy !== undefined} |
| onClick={() => { |
| void mutate("repair-config", repairPoolConfig); |
| }} |
| > |
| Replace with bootstrap membership |
| </button> |
| </div> |
| )} |
| {error === undefined ? null : <p className="text-sm text-red-500">{error}</p>} |
| |
| <section> |
| <h3 className="mb-2 font-bold">Enrichment</h3> |
| {enrichment === undefined ? ( |
| <p className="text-sm text-(--x-muted)">Loading worker status…</p> |
| ) : ( |
| <p className="text-sm text-(--x-muted)"> |
| {enrichment.worker_recently_completed ? "Worker recently completed" : "No recent run"} · |
| pending {enrichment.totals.pending} · retrying {enrichment.totals.retrying} · blocked{" "} |
| {enrichment.totals.blocked} · complete {enrichment.totals.completed} |
| </p> |
| )} |
| {freeLabels === undefined ? null : ( |
| <div className="mt-1 text-sm text-(--x-muted)"> |
| <p> |
| Free labels:{" "} |
| {freeLabels.labels.filter((label) => label.status === "candidate").length} candidate ·{" "} |
| {freeLabels.labels.filter((label) => label.status === "approved").length} approved ·{" "} |
| {freeLabels.labels.filter((label) => label.status === "rejected").length} rejected |
| </p> |
| {freeLabels.candidates.length === 0 ? null : ( |
| <ul className="mt-2 divide-y divide-(--x-border) border-y border-(--x-border)"> |
| {freeLabels.candidates.map((label) => ( |
| <li key={label.name} className="py-2"> |
| <p className="font-medium text-(--x-text)"> |
| {label.name} · {label.unit_count} units · {label.distinct_authors} authors ·{" "} |
| {label.distinct_days} days |
| </p> |
| {label.representative_quotes.slice(0, 2).map((evidence) => ( |
| <p key={`${evidence.unit_id}:${evidence.tweet_id}:${evidence.quote}`}> |
| {evidence.tweet_id}: {evidence.quote} |
| </p> |
| ))} |
| </li> |
| ))} |
| </ul> |
| )} |
| </div> |
| )} |
| </section> |
| |
| <form |
| className="flex flex-wrap gap-2" |
| onSubmit={(event) => { |
| event.preventDefault(); |
| const username = memberInput.trim(); |
| if (username === "") return; |
| setMemberInput(""); |
| void mutate(`member:${username}`, () => addPoolMember(username)); |
| }} |
| > |
| <input |
| aria-label="Member username" |
| className="min-w-0 flex-1 rounded-md border border-(--x-border) bg-(--x-soft) px-3 py-2 text-sm outline-none focus:border-(--x-accent)" |
| placeholder="HF username" |
| value={memberInput} |
| onChange={(event) => { |
| setMemberInput(event.target.value); |
| }} |
| /> |
| <button |
| type="submit" |
| className="rounded-md bg-(--x-accent) px-3 py-2 text-sm font-semibold text-white" |
| disabled={busy !== undefined} |
| > |
| Add member |
| </button> |
| </form> |
| |
| <section> |
| <h3 className="mb-2 font-bold">Members</h3> |
| <ul className="divide-y divide-(--x-border) border-y border-(--x-border)"> |
| {sortUsers(pool.members).map((member) => ( |
| <li key={member} className="flex items-center justify-between gap-3 py-2"> |
| <span>@{member}</span> |
| <div className="flex gap-2"> |
| {!admins.has(member) ? ( |
| <button |
| type="button" |
| className="rounded-md border border-(--x-border) px-2 py-1 text-sm" |
| disabled={busy !== undefined} |
| onClick={() => { |
| void mutate(`admin:${member}`, () => addPoolAdmin(member)); |
| }} |
| > |
| Promote |
| </button> |
| ) : null} |
| {!admins.has(member) ? ( |
| <button |
| type="button" |
| className="rounded-md border border-(--x-border) px-2 py-1 text-sm" |
| disabled={busy !== undefined} |
| onClick={() => { |
| void mutate(`member:${member}`, () => removePoolMember(member)); |
| }} |
| > |
| Remove |
| </button> |
| ) : null} |
| </div> |
| </li> |
| ))} |
| </ul> |
| </section> |
| |
| <form |
| className="flex flex-wrap gap-2" |
| onSubmit={(event) => { |
| event.preventDefault(); |
| const orgName = orgInput.trim(); |
| if (orgName === "") return; |
| setOrgInput(""); |
| void mutate(`member-org:${orgName}`, () => addPoolMemberOrg(orgName)); |
| }} |
| > |
| <input |
| aria-label="Member organization" |
| className="min-w-0 flex-1 rounded-md border border-(--x-border) bg-(--x-soft) px-3 py-2 text-sm outline-none focus:border-(--x-accent)" |
| placeholder="HF organization" |
| value={orgInput} |
| onChange={(event) => { |
| setOrgInput(event.target.value); |
| }} |
| /> |
| <button |
| type="submit" |
| className="rounded-md bg-(--x-accent) px-3 py-2 text-sm font-semibold text-white" |
| disabled={busy !== undefined} |
| > |
| Set org |
| </button> |
| </form> |
| |
| <section> |
| <h3 className="mb-2 font-bold">Member Organization</h3> |
| <ul className="divide-y divide-(--x-border) border-y border-(--x-border)"> |
| {sortMemberOrgs(pool.member_orgs).map((org) => ( |
| <li key={org.sub} className="flex items-center justify-between gap-3 py-2"> |
| <span> |
| @{org.name} |
| {org.display_name === undefined ? null : ( |
| <span className="ml-2 text-sm text-(--x-muted)">{org.display_name}</span> |
| )} |
| </span> |
| <button |
| type="button" |
| className="rounded-md border border-(--x-border) px-2 py-1 text-sm" |
| disabled={busy !== undefined} |
| onClick={() => { |
| void mutate(`member-org:${org.name}`, () => removePoolMemberOrg(org.name)); |
| }} |
| > |
| Remove |
| </button> |
| </li> |
| ))} |
| </ul> |
| </section> |
| |
| <form |
| className="flex flex-wrap gap-2" |
| onSubmit={(event) => { |
| event.preventDefault(); |
| const username = adminInput.trim(); |
| if (username === "") return; |
| setAdminInput(""); |
| void mutate(`admin:${username}`, () => addPoolAdmin(username)); |
| }} |
| > |
| <input |
| aria-label="Admin username" |
| className="min-w-0 flex-1 rounded-md border border-(--x-border) bg-(--x-soft) px-3 py-2 text-sm outline-none focus:border-(--x-accent)" |
| placeholder="HF username" |
| value={adminInput} |
| onChange={(event) => { |
| setAdminInput(event.target.value); |
| }} |
| /> |
| <button |
| type="submit" |
| className="rounded-md bg-(--x-accent) px-3 py-2 text-sm font-semibold text-white" |
| disabled={busy !== undefined} |
| > |
| Add admin |
| </button> |
| </form> |
| |
| <section> |
| <h3 className="mb-2 font-bold">Admins</h3> |
| <ul className="divide-y divide-(--x-border) border-y border-(--x-border)"> |
| {sortUsers(pool.admins).map((admin) => ( |
| <li key={admin} className="flex items-center justify-between gap-3 py-2"> |
| <span>@{admin}</span> |
| {bootstrapAdmins.has(admin) ? ( |
| <span className="text-sm text-(--x-muted)">Bootstrap</span> |
| ) : ( |
| <button |
| type="button" |
| className="rounded-md border border-(--x-border) px-2 py-1 text-sm" |
| disabled={busy !== undefined} |
| onClick={() => { |
| void mutate(`admin:${admin}`, () => removePoolAdmin(admin)); |
| }} |
| > |
| Demote |
| </button> |
| )} |
| </li> |
| ))} |
| </ul> |
| </section> |
| |
| <ServiceAccountsPanel /> |
| </div> |
| ); |
| } |
|
|
| type ServiceState = |
| | { status: "loading" } |
| | { |
| status: "ready"; |
| snapshot: ServiceAccountsSnapshot; |
| busy?: string; |
| error?: string; |
| issued?: { name: string; token: string }; |
| } |
| | { status: "error"; error: string }; |
|
|
| function optionalIssued(issued: { name: string; token: string } | undefined): { |
| issued?: { name: string; token: string }; |
| } { |
| return issued === undefined ? {} : { issued }; |
| } |
|
|
| function ServiceAccountsPanel(): React.JSX.Element { |
| const [state, setState] = useState<ServiceState>({ status: "loading" }); |
| const [name, setName] = useState(""); |
| const issuedRef = useRef<{ name: string; token: string } | undefined>(undefined); |
|
|
| useEffect(() => { |
| void fetchAdminServiceAccounts().then( |
| (snapshot) => { |
| setState({ status: "ready", snapshot }); |
| }, |
| (error: unknown) => { |
| setState({ status: "error", error: message(error) }); |
| }, |
| ); |
| }, []); |
|
|
| async function mutateServices( |
| label: string, |
| operation: () => Promise<{ name?: string; token?: string } | undefined>, |
| ): Promise<void> { |
| if (state.status !== "ready") return; |
| setState({ |
| status: "ready", |
| snapshot: state.snapshot, |
| busy: label, |
| ...optionalIssued(issuedRef.current), |
| }); |
| try { |
| const result = await operation(); |
| if (result?.token !== undefined && result.name !== undefined) { |
| issuedRef.current = { name: result.name, token: result.token }; |
| setState({ |
| status: "ready", |
| snapshot: state.snapshot, |
| busy: label, |
| issued: issuedRef.current, |
| }); |
| } |
| const snapshot = await fetchAdminServiceAccounts(); |
| setState({ |
| status: "ready", |
| snapshot, |
| ...optionalIssued(issuedRef.current), |
| }); |
| } catch (error) { |
| setState({ |
| status: "ready", |
| snapshot: state.snapshot, |
| error: message(error), |
| ...optionalIssued(issuedRef.current), |
| }); |
| } |
| } |
|
|
| if (state.status === "loading") return <p className="text-sm text-(--x-muted)">Loading…</p>; |
| if (state.status === "error") return <p className="text-sm text-red-500">{state.error}</p>; |
|
|
| return ( |
| <section className="grid gap-3 border-t border-(--x-border) pt-5"> |
| <div> |
| <h3 className="font-bold">Service Accounts</h3> |
| <p className="text-sm text-(--x-muted)"> |
| Read-only machine access. Credentials appear once and cannot ingest or administer. |
| </p> |
| </div> |
| |
| {state.snapshot.config_error === undefined ? null : ( |
| <div className="rounded-md border border-red-400 px-3 py-2 text-sm text-red-500"> |
| <p>{state.snapshot.config_error}</p> |
| <button |
| type="button" |
| className="mt-2 rounded-md border border-red-400 px-2 py-1 font-semibold" |
| disabled={state.busy !== undefined} |
| onClick={() => { |
| void mutateServices("repair", async () => { |
| await repairServiceAccounts(); |
| return undefined; |
| }); |
| }} |
| > |
| Replace with empty registry |
| </button> |
| </div> |
| )} |
| |
| {state.issued === undefined ? null : ( |
| <div className="rounded-md border border-amber-400 px-3 py-2 text-sm"> |
| <p className="font-semibold">Copy the {state.issued.name} credential now.</p> |
| <p className="text-(--x-muted)">It will not be shown again.</p> |
| <textarea |
| aria-label="Issued service credential" |
| className="mt-2 h-20 w-full rounded-md border border-(--x-border) bg-(--x-soft) p-2 font-mono text-xs" |
| readOnly |
| value={state.issued.token} |
| /> |
| <button |
| type="button" |
| className="rounded-md border border-(--x-border) px-2 py-1 text-sm" |
| onClick={() => { |
| issuedRef.current = undefined; |
| setState({ status: "ready", snapshot: state.snapshot }); |
| }} |
| > |
| Dismiss |
| </button> |
| </div> |
| )} |
| |
| {state.error === undefined ? null : <p className="text-sm text-red-500">{state.error}</p>} |
| |
| <form |
| className="flex flex-wrap gap-2" |
| onSubmit={(event) => { |
| event.preventDefault(); |
| const accountName = name.trim(); |
| if (accountName === "") return; |
| setName(""); |
| void mutateServices(`issue:${accountName}`, async () => { |
| const credential = await issueServiceAccount(accountName, [ |
| "units:read", |
| "taxonomy:read", |
| ]); |
| return { name: credential.account.name, token: credential.token }; |
| }); |
| }} |
| > |
| <input |
| aria-label="Service account name" |
| className="min-w-0 flex-1 rounded-md border border-(--x-border) bg-(--x-soft) px-3 py-2 text-sm" |
| placeholder="local-frontier" |
| value={name} |
| onChange={(event) => { |
| setName(event.target.value); |
| }} |
| /> |
| <button |
| type="submit" |
| className="rounded-md bg-(--x-accent) px-3 py-2 text-sm font-semibold text-white" |
| disabled={state.busy !== undefined || state.snapshot.config_error !== undefined} |
| > |
| Issue reader |
| </button> |
| </form> |
| |
| <ul className="divide-y divide-(--x-border) border-y border-(--x-border)"> |
| {state.snapshot.accounts.map((account) => ( |
| <li key={account.id} className="grid gap-2 py-3"> |
| <div className="flex flex-wrap items-center justify-between gap-2"> |
| <span> |
| <strong>{account.name}</strong>{" "} |
| <span className="text-sm text-(--x-muted)"> |
| {account.status} · {account.scopes.join(", ")} |
| </span> |
| </span> |
| {account.status === "active" ? ( |
| <div className="flex gap-2"> |
| <button |
| type="button" |
| className="rounded-md border border-(--x-border) px-2 py-1 text-sm" |
| disabled={state.busy !== undefined} |
| onClick={() => { |
| void mutateServices(`rotate:${account.id}`, async () => { |
| const credential = await rotateServiceAccount(account.id); |
| return { name: credential.account.name, token: credential.token }; |
| }); |
| }} |
| > |
| Rotate |
| </button> |
| <button |
| type="button" |
| className="rounded-md border border-red-400 px-2 py-1 text-sm text-red-500" |
| disabled={state.busy !== undefined} |
| onClick={() => { |
| void mutateServices(`revoke:${account.id}`, async () => { |
| await revokeServiceAccount(account.id); |
| return undefined; |
| }); |
| }} |
| > |
| Revoke |
| </button> |
| </div> |
| ) : null} |
| </div> |
| {account.keys.length === 0 ? null : ( |
| <ul className="grid gap-1 pl-3 text-xs text-(--x-muted)"> |
| {account.keys.map((key) => ( |
| <li key={key.id} className="flex items-center justify-between gap-2"> |
| <span> |
| key {key.id} · issued {key.created_at.slice(0, 10)} |
| </span> |
| <button |
| type="button" |
| className="rounded-md border border-(--x-border) px-2 py-1" |
| disabled={state.busy !== undefined} |
| onClick={() => { |
| void mutateServices(`revoke-key:${key.id}`, async () => { |
| await revokeServiceAccountKey(account.id, key.id); |
| return undefined; |
| }); |
| }} |
| > |
| Revoke key |
| </button> |
| </li> |
| ))} |
| </ul> |
| )} |
| </li> |
| ))} |
| </ul> |
| </section> |
| ); |
| } |
|
|
| function message(error: unknown): string { |
| return error instanceof Error ? error.message : "request failed"; |
| } |
|
|