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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InstanceCard } from "./InstanceCard";
|
||||
import type { Instance, License } from "@/lib/api";
|
||||
|
||||
const base: Instance = {
|
||||
instance_id: "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d",
|
||||
account_id: "a1",
|
||||
name: "Acme Production",
|
||||
slug: "acme",
|
||||
deployment: "cloud",
|
||||
tier: "professional",
|
||||
status: "active",
|
||||
current_license: "l1",
|
||||
relink_count: 0,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function licence(daysFromNow: number): License {
|
||||
return {
|
||||
license_id: "l1",
|
||||
instance_id: base.instance_id,
|
||||
account_id: "a1",
|
||||
tier: "professional",
|
||||
deployment: "cloud",
|
||||
limits: { max_servers: -1, max_secret_groups: -1, max_channels: -1 },
|
||||
features: ["console", "oidc"],
|
||||
issued_at: "2026-01-01T00:00:00Z",
|
||||
expires_at: new Date(Date.now() + daysFromNow * 86_400_000).toISOString(),
|
||||
issued_by: "staff@example.com",
|
||||
reason: "new",
|
||||
};
|
||||
}
|
||||
|
||||
describe("InstanceCard", () => {
|
||||
it("shows a valid licence with days remaining", () => {
|
||||
render(<InstanceCard instance={base} license={licence(367)} />);
|
||||
expect(screen.getByText("Valid")).toBeInTheDocument();
|
||||
expect(screen.getByText(/367 days remaining/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns inside fourteen days", () => {
|
||||
render(<InstanceCard instance={base} license={licence(9)} />);
|
||||
expect(screen.getByText("Expiring")).toBeInTheDocument();
|
||||
expect(screen.getByText(/9 days remaining/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names what still works when expired", () => {
|
||||
render(<InstanceCard instance={base} license={licence(-3)} />);
|
||||
expect(screen.getByText("Expired")).toBeInTheDocument();
|
||||
// The reassurance is the point: this is the first thing a worried
|
||||
// customer needs, and the backend really does keep these running.
|
||||
expect(screen.getByText(/servers and monitors are still running/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/changes are disabled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prompts to link when paid but never linked", () => {
|
||||
render(
|
||||
<InstanceCard
|
||||
instance={{
|
||||
...base,
|
||||
status: "awaiting_link",
|
||||
deployment: "self_hosted",
|
||||
current_license: undefined,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Awaiting link")).toBeInTheDocument();
|
||||
expect(screen.getByText(/not attached to an install yet/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /link an install/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links to the instance's own subdomain for cloud", () => {
|
||||
render(<InstanceCard instance={base} license={licence(30)} />);
|
||||
expect(screen.getByRole("link", { name: /open/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import clsx from "clsx";
|
||||
import type { License } from "@/lib/api";
|
||||
import { formatDate, formatStamp, limitLabel } from "@/lib/format";
|
||||
|
||||
const REASON: Record<License["reason"], string> = {
|
||||
new: "New",
|
||||
renewal: "Renewal",
|
||||
tier_change: "Tier change",
|
||||
relink: "Relink",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
/*
|
||||
* Licences are append-only: a renewal supersedes its predecessor rather than
|
||||
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
|
||||
* and are overprinted the way a cancelled instrument is — hiding them would
|
||||
* destroy the only record of why an instance stopped working on a given date.
|
||||
*/
|
||||
export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
if (licenses.length === 0) {
|
||||
return (
|
||||
<p className="text-ink-2">
|
||||
No licence has ever been issued for this instance, so it is read-only.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="grid">
|
||||
{licenses.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
<li
|
||||
key={l.license_id}
|
||||
className={clsx(
|
||||
"grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
|
||||
dead && "text-ink-3",
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
<b
|
||||
className={clsx(
|
||||
"block text-[0.82rem] font-semibold",
|
||||
dead ? "text-ink-3" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{formatDate(l.issued_at)}
|
||||
</b>
|
||||
{formatStamp(l.issued_at)}
|
||||
</div>
|
||||
<div className="grid justify-items-start gap-1.5">
|
||||
{dead && (
|
||||
<span className="-rotate-2 rounded-sm border-2 border-archival px-1.5 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.18em] text-archival opacity-75">
|
||||
Superseded
|
||||
</span>
|
||||
)}
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
{l.tier.replace("_", " ")}
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">
|
||||
{REASON[l.reason]}
|
||||
</span>
|
||||
</p>
|
||||
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
|
||||
{limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.superseded_by && (
|
||||
<>
|
||||
{" "}
|
||||
· replaced by{" "}
|
||||
<span className="text-accent underline">
|
||||
{l.superseded_by.slice(0, 8)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LicenceDelivery } from "./LicenceDelivery";
|
||||
|
||||
describe("LicenceDelivery", () => {
|
||||
it("offers the file and always shows the blob as a fallback", () => {
|
||||
render(
|
||||
<LicenceDelivery
|
||||
instanceId="6a0fe3f0-49d2-4aa1-967c-a3094b200b5d"
|
||||
blob="VANTAGE-LIC abc123"
|
||||
downloadUrl="https://admin.example.com/api/instances/x/license/download"
|
||||
/>,
|
||||
);
|
||||
const link = screen.getByRole("link", { name: /download licence/i });
|
||||
expect(link).toHaveAttribute("href", expect.stringContaining("/license/download"));
|
||||
// A blocked download must never leave a paying customer stuck.
|
||||
expect(screen.getByText(/VANTAGE-LIC abc123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Settings → Licence/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NotConnectedPanel } from "./NotConnected";
|
||||
|
||||
describe("NotConnectedPanel", () => {
|
||||
it("names the variable that is wrong and where it is set", () => {
|
||||
render(<NotConnectedPanel url="https://admin.example.com" />);
|
||||
|
||||
expect(screen.getByRole("heading")).toHaveTextContent(
|
||||
/not connected to the licensing service/i,
|
||||
);
|
||||
expect(screen.getByText(/ADMIN_API_URL/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/https:\/\/admin\.example\.com/)).toBeInTheDocument();
|
||||
// The two mistakes that actually cause this, both named.
|
||||
expect(screen.getByText(/reachable from your browser/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ADMIN_ORIGIN/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says the value is missing when no URL was baked in", () => {
|
||||
render(<NotConnectedPanel url="" />);
|
||||
expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Queue } from "./Queue";
|
||||
|
||||
describe("Queue", () => {
|
||||
it("shows the count and links every row to the work", () => {
|
||||
render(
|
||||
<Queue
|
||||
title="Failed injections"
|
||||
tone="expired"
|
||||
count={2}
|
||||
items={[
|
||||
{ label: "Acme Production", href: "/staff/instances/i1", meta: "4h ago" },
|
||||
{ label: "Globex EU", href: "/staff/instances/i2", meta: "26m ago" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Failed injections")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Acme Production" })).toHaveAttribute(
|
||||
"href",
|
||||
"/staff/instances/i1",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so plainly when there is nothing to do", () => {
|
||||
render(<Queue title="Past due" tone="expired" count={0} items={[]} />);
|
||||
expect(screen.getByText(/nothing to do/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RelinkPanel } from "./RelinkPanel";
|
||||
|
||||
describe("RelinkPanel", () => {
|
||||
it("shows the remaining allowance", () => {
|
||||
render(<RelinkPanel instanceId="i1" used={1} max={3} onRelink={vi.fn()} />);
|
||||
expect(screen.getByText("2 of 3 relinks left this term")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /relink/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("disables at zero and says what to do instead", () => {
|
||||
render(<RelinkPanel instanceId="i1" used={3} max={3} onRelink={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /relink/i })).toBeDisabled();
|
||||
expect(screen.getByText(/contact support/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user