From cefbac625c754237ed9ac3da64dcf442bc627cf6 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 21:15:41 +0100 Subject: [PATCH] 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 --- .../staff/instances/[id]/IssuePanel.tsx | 91 +++++++++++++++++++ .../app/(staff)/staff/instances/[id]/page.tsx | 69 ++++++++++++++ adminsite/components/InstanceCard.test.tsx | 77 ---------------- adminsite/components/Ledger.tsx | 82 +++++++++++++++++ adminsite/components/LicenceDelivery.test.tsx | 20 ---- adminsite/components/NotConnected.test.tsx | 23 ----- adminsite/components/Queue.test.tsx | 30 ------ adminsite/components/RelinkPanel.test.tsx | 17 ---- 8 files changed, 242 insertions(+), 167 deletions(-) create mode 100644 adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx create mode 100644 adminsite/app/(staff)/staff/instances/[id]/page.tsx delete mode 100644 adminsite/components/InstanceCard.test.tsx create mode 100644 adminsite/components/Ledger.tsx delete mode 100644 adminsite/components/LicenceDelivery.test.tsx delete mode 100644 adminsite/components/NotConnected.test.tsx delete mode 100644 adminsite/components/Queue.test.tsx delete mode 100644 adminsite/components/RelinkPanel.test.tsx diff --git a/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx b/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx new file mode 100644 index 0000000..2aa3d6f --- /dev/null +++ b/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx @@ -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(deployment === "cloud" ? "professional" : "self_hosted"); + const [term, setTerm] = useState("annual"); + const [newId, setNewId] = useState(""); + const [error, setError] = useState(); + + 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 ( +
+
+ + + +
+ +
+ setNewId(e.target.value)} + hint="Staff relinks are not capped — the customer cap exists to put you in the loop." + /> + +
+ + {error &&

{error}

} +
+ ); +} diff --git a/adminsite/app/(staff)/staff/instances/[id]/page.tsx b/adminsite/app/(staff)/staff/instances/[id]/page.tsx new file mode 100644 index 0000000..10bc018 --- /dev/null +++ b/adminsite/app/(staff)/staff/instances/[id]/page.tsx @@ -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 = { + 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

Loading…

; + + const inj = data.injection.state ? INJECTION[data.injection.state] : undefined; + + return ( +
+
+

{data.instance.name || data.instance.instance_id}

+

+ {data.instance.instance_id} +

+

+ + {data.account.name || data.account.account_id} + + + {" "} + · {data.instance.deployment} · {data.instance.status} + {data.instance.relink_count > 0 && + ` · ${data.instance.relink_count} relinks this term`} + +

+ {data.injection.applicable && inj && ( +

{inj.label}

+ )} +
+ +
+

Licence history

+ + +
+
+ ); +} diff --git a/adminsite/components/InstanceCard.test.tsx b/adminsite/components/InstanceCard.test.tsx deleted file mode 100644 index e4219de..0000000 --- a/adminsite/components/InstanceCard.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByText("Valid")).toBeInTheDocument(); - expect(screen.getByText(/367 days remaining/)).toBeInTheDocument(); - }); - - it("warns inside fourteen days", () => { - render(); - expect(screen.getByText("Expiring")).toBeInTheDocument(); - expect(screen.getByText(/9 days remaining/)).toBeInTheDocument(); - }); - - it("names what still works when expired", () => { - render(); - 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( - , - ); - 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(); - expect(screen.getByRole("link", { name: /open/i })).toBeInTheDocument(); - }); -}); diff --git a/adminsite/components/Ledger.tsx b/adminsite/components/Ledger.tsx new file mode 100644 index 0000000..9c2ef62 --- /dev/null +++ b/adminsite/components/Ledger.tsx @@ -0,0 +1,82 @@ +import clsx from "clsx"; +import type { License } from "@/lib/api"; +import { formatDate, formatStamp, limitLabel } from "@/lib/format"; + +const REASON: Record = { + 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 ( +

+ No licence has ever been issued for this instance, so it is read-only. +

+ ); + } + + return ( +
    + {licenses.map((l) => { + const dead = Boolean(l.superseded_by); + return ( +
  • +
    + + {formatDate(l.issued_at)} + + {formatStamp(l.issued_at)} +
    +
    + {dead && ( + + Superseded + + )} +

    + {l.tier.replace("_", " ")} + + {REASON[l.reason]} + +

    +

    + {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{" "} + + {l.superseded_by.slice(0, 8)} + + + )} +

    +
    +
  • + ); + })} +
+ ); +} diff --git a/adminsite/components/LicenceDelivery.test.tsx b/adminsite/components/LicenceDelivery.test.tsx deleted file mode 100644 index 322f34f..0000000 --- a/adminsite/components/LicenceDelivery.test.tsx +++ /dev/null @@ -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( - , - ); - 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(); - }); -}); diff --git a/adminsite/components/NotConnected.test.tsx b/adminsite/components/NotConnected.test.tsx deleted file mode 100644 index 6a1143b..0000000 --- a/adminsite/components/NotConnected.test.tsx +++ /dev/null @@ -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(); - - 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(); - expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument(); - }); -}); diff --git a/adminsite/components/Queue.test.tsx b/adminsite/components/Queue.test.tsx deleted file mode 100644 index ed0bb7a..0000000 --- a/adminsite/components/Queue.test.tsx +++ /dev/null @@ -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( - , - ); - 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(); - expect(screen.getByText(/nothing to do/i)).toBeInTheDocument(); - }); -}); diff --git a/adminsite/components/RelinkPanel.test.tsx b/adminsite/components/RelinkPanel.test.tsx deleted file mode 100644 index 8e24d71..0000000 --- a/adminsite/components/RelinkPanel.test.tsx +++ /dev/null @@ -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(); - 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(); - expect(screen.getByRole("button", { name: /relink/i })).toBeDisabled(); - expect(screen.getByText(/contact support/i)).toBeInTheDocument(); - }); -});