feat(adminsite): the licence ledger and staff instance actions

The screen that answers "why did this stop working on the 14th". Read top to
bottom it is one instance's whole history: what was issued, why, by whom, and
what replaced it.

Superseded entries stay visible and overprinted rather than disappearing,
because licences are append-only and hiding them would destroy the only
record that answers the question. Each links to its successor.

Injection state is shown live for cloud instances and omitted for
self-hosted, where the customer holds the blob and there is nothing for us to
have written. Staff relinks carry no cap, with the reason stated inline: the
customer cap exists to put a human in the loop, and this is that human.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 21:15:41 +01:00
co-authored by Claude Opus 5
parent 73efb206ac
commit cefbac625c
8 changed files with 242 additions and 167 deletions
@@ -0,0 +1,91 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type Tier } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export function IssuePanel({
instanceId,
deployment,
}: {
instanceId: string;
deployment: string;
}) {
const qc = useQueryClient();
const [tier, setTier] = useState<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
const [term, setTerm] = useState("annual");
const [newId, setNewId] = useState("");
const [error, setError] = useState<string | undefined>();
const invalidate = () => qc.invalidateQueries({ queryKey: ["staff-instance", instanceId] });
const issue = useMutation({
mutationFn: () => api.staff.issue(instanceId, { tier, term, reason: "manual" }),
onSuccess: invalidate,
onError: (e) => setError(e instanceof ApiError ? e.message : "Issue failed."),
});
const relink = useMutation({
mutationFn: () => api.staff.relink(instanceId, newId.trim()),
onSuccess: invalidate,
onError: (e) => setError(e instanceof ApiError ? e.message : "Relink failed."),
});
return (
<section className="grid gap-4 border-t border-rule-soft pt-5">
<div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Tier
</span>
<select
value={tier}
onChange={(e) => setTier(e.target.value as Tier)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Term
</span>
<select
value={term}
onChange={(e) => setTerm(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
>
<option value="annual">Annual</option>
<option value="monthly">Monthly</option>
</select>
</label>
<Button type="button" onClick={() => issue.mutate()} disabled={issue.isPending}>
{issue.isPending ? "Issuing…" : "Issue licence"}
</Button>
</div>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Relink to instance ID"
value={newId}
onChange={(e) => setNewId(e.target.value)}
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
/>
<Button
type="button"
variant="line"
onClick={() => relink.mutate()}
disabled={!newId.trim()}
>
Relink
</Button>
</div>
{error && <p className="text-[0.82rem] text-expired">{error}</p>}
</section>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import Link from "next/link";
import clsx from "clsx";
import { api, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { IssuePanel } from "./IssuePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
current: { label: "Control plane holds the current licence", tone: "text-valid" },
stale: {
label: "Control plane holds an older blob — the reconciler will repair it",
tone: "text-warn",
},
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
none_issued: { label: "Nothing issued yet, so nothing to inject", tone: "text-ink-3" },
};
export default function StaffInstancePage() {
const id = String(useParams().id);
const { data, isLoading } = useQuery({
queryKey: ["staff-instance", id],
queryFn: () => api.staff.instance(id),
refetchInterval: 30_000,
});
if (isLoading || !data) return <p className="text-ink-3">Loading</p>;
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.instance.name || data.instance.instance_id}</h1>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{data.instance.instance_id}
</p>
<p className="text-[0.82rem]">
<Link
href={`/staff/accounts/${data.account.account_id}`}
className="text-accent underline"
>
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 &&
` · ${data.instance.relink_count} relinks this term`}
</span>
</p>
{data.injection.applicable && inj && (
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
)}
</header>
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
<Ledger licenses={data.licenses} />
<IssuePanel
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
</section>
</div>
);
}