feat: Updated admin instance page
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 41s

This commit is contained in:
2026-08-11 09:59:27 +01:00
parent a228ab24a0
commit 99cd2a8ec3
2 changed files with 158 additions and 116 deletions
+129 -106
View File
@@ -2,8 +2,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { API_BASE, ApiError, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
@@ -11,10 +12,52 @@ import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { TermBar } from "@/components/TermBar";
import { EmptyState, Note, Panel } from "@/components/Panel";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
import { featureLabel } from "@/lib/features";
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
/** One key/value row. The key is the same keyed idiom as everywhere else. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4">
<dt className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{label}</dt>
<dd className="m-0 text-[0.88rem] tabular-nums">{value}</dd>
</div>
);
}
/*
* Every feature the product sells, granted or not.
*
* Listing only what is included answers "what do I have" but not "what am I
* missing", which is the question someone on this screen is actually weighing
* before they click Change plan. The absent ones are struck through rather than
* omitted, so the comparison is on the page instead of in another tab.
*/
function Features({ granted }: { granted: string[] }) {
const all = Object.keys(FEATURE_LABEL);
// Anything the licence carries that this build does not know about is still
// shown — the map degrades to the raw key, which is ugly but never wrong.
const extras = granted.filter((f) => !all.includes(f));
return (
<div className="grid gap-2">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Features</span>
<div className="flex flex-wrap gap-1.5">
{[...all, ...extras].map((f) => {
const on = granted.includes(f);
return (
<span key={f} title={featureDesc(f) || undefined} className={on ? "rounded-sm border border-rule px-2 py-0.5 text-[0.78rem] text-ink-2" : "rounded-sm border border-rule-soft px-2 py-0.5 text-[0.78rem] text-ink-3 line-through decoration-ink-3/60"}>
{featureLabel(f)}
</span>
);
})}
</div>
</div>
);
}
export default function InstancePage() {
const id = String(useParams().id);
@@ -31,12 +74,11 @@ export default function InstancePage() {
const relink = useMutation({
mutationFn: (newId: string) => api.relink(id, newId),
onSuccess: (lic) => {
onSuccess: (lic: License) => {
qc.invalidateQueries({ queryKey: ["account"] });
router.replace(`/instances/${lic.instance_id}`);
},
onError: (err) =>
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
onError: (err) => setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
});
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
@@ -53,140 +95,121 @@ export default function InstancePage() {
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
const cloud = instance.deployment === "cloud";
const maxRelinks = account.data?.max_relinks ?? 3;
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title={instance.name || "Unnamed instance"}
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
} · created ${formatDate(instance.created_at)}`}
record={[
{ key: "Instance", value: instance.instance_id, copy: true },
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
]}
subtitle={`${cloud ? "Cloud" : "Self-hosted"} instance${instance.tier ? ` on ${instance.tier.replace("_", " ")}` : ""} · created ${formatDate(instance.created_at)}`}
/*
* The two things this screen is for, in the header rather than
* hunted for further down. Download is self-hosted only: a cloud
* licence is injected into the control plane directly and there
* is nothing for the customer to do with the file.
*/
actions={
<>
{lic && !cloud && (
<LinkButton variant="line" external href={api.licenseBlobUrl(instance.instance_id)}>
Download licence
</LinkButton>
)}
{lic && <LinkButton href="/purchase">Renew licence</LinkButton>}
</>
}
record={[{ key: "Instance", value: instance.instance_id, copy: true }, ...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : [])]}
status={<StatePill state={state} />}
/>
<PageFrame
aside={
<>
<RailCard title="Licence">
{lic ? (
<RailFacts
rows={[
{ label: "Tier", value: lic.tier.replace("_", " ") },
{ label: "Issued", value: formatDate(lic.issued_at) },
{ label: "Expires", value: formatDate(lic.expires_at) },
{ label: "Reason", value: lic.reason },
]}
/>
) : (
<p className="text-[0.82rem] text-ink-2">
No licence issued yet.
</p>
)}
host ? (
<RailCard title="Console">
<p className="text-[0.82rem] text-ink-2">Servers, workflows and monitors live in the instance itself.</p>
<a href={`https://${host}`} className="inline-flex items-center justify-center gap-2 rounded border border-rule px-3 py-2 text-[0.84rem] font-semibold text-ink no-underline hover:border-accent hover:text-accent">
Open {instance.name || "instance"} &rarr;
</a>
<p className="font-mono text-[0.72rem] text-ink-3">{host}</p>
</RailCard>
{lic && (
<RailCard title="Included">
<RailFacts
rows={[
{
label: "Servers",
value: limitLabel(lic.limits.max_servers),
},
{
label: "Secret groups",
value: limitLabel(lic.limits.max_secret_groups),
},
{
label: "Channels",
value: limitLabel(lic.limits.max_channels),
},
{
label: "Features",
// Labelled, not raw keys: this is
// the customer's own licence, and
// "vuln_scanning" is not a name
// anyone bought.
value: lic.features.map(featureLabel).join(", ") || "none",
},
]}
/>
</RailCard>
)}
{!cloud && (
<RailCard title="Moves">
<RailFacts
rows={[
{
label: "Relinks used",
value: `${instance.relink_count} of ${
account.data?.max_relinks ?? 3
}`,
},
]}
/>
<p className="text-[0.82rem] text-ink-2">
Moving a licence to a different install counts as one.
</p>
</RailCard>
)}
</>
) : undefined
}
>
{/*
* The term leads. This screen is about one licence, and the rail
* already carried its issue and expiry dates as two lines of
* text — which is the arithmetic this bar does for the reader.
* carried its issue and expiry dates as two lines of text —
* which is the arithmetic this bar does for the reader.
*/}
{lic && (
<Panel title="Licence" meta={`${lic.tier.replace("_", " ")} · ${cloud ? "Cloud" : "Self-hosted"}`}>
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
{state === "warn" && <Note tone="warn">Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
{state === "expired" && (
<Note tone="expired">Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed.</Note>
)}
{state === "warn" && <Note tone="warn">Inside 14 days of expiry. Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
{state === "expired" && <Note tone="expired">A lapsed licence does not stop the control plane: agents carry on reporting and your servers keep their keys. It stops accepting changes, so nothing new can be deployed until this is renewed.</Note>}
</Panel>
)}
{/*
* What the licence grants, on the screen about that licence.
* These were four rows in a 320px rail card, which is where
* facts go when nobody has decided they matter.
*/}
{lic && (
<Panel
title="Included"
/* A panel-header action is a quiet link, not a second
full-size button competing with the header's Renew. */
actions={
<Link href="/purchase" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
Change plan &rarr;
</Link>
}
>
<div className="grid gap-x-8 gap-y-2.5 sm:grid-cols-2">
<dl className="grid content-start gap-2.5">
<Row label="Servers" value={limitLabel(lic.limits.max_servers)} />
<Row label="Monitors" value={limitLabel(lic.limits.max_monitors)} />
<Row label="Secret groups" value={limitLabel(lic.limits.max_secret_groups)} />
</dl>
<dl className="grid content-start gap-2.5">
<Row label="Channels" value={limitLabel(lic.limits.max_channels)} />
<Row label="Audit history" value={`${limitLabel(lic.limits.audit_retention_days)} days`} />
<Row label="Issued for" value={lic.reason.replace("_", " ")} />
</dl>
</div>
<Features granted={lic.features} />
</Panel>
)}
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<Panel title="People">
<p className="text-[0.86rem] text-ink-2">
Users for this install are managed inside it, in Settings &rarr; Instance. We have no access to your own deployment.
</p>
<Panel title="Who can sign in">
<p className="text-[0.86rem] text-ink-2">Users for this install are managed inside it, in Settings &rarr; Instance. We have no access to your own deployment.</p>
</Panel>
)}
{lic && !cloud && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
/>
<RelinkPanel
instanceId={instance.instance_id}
used={instance.relink_count}
max={account.data?.max_relinks ?? 3}
error={relinkError}
onRelink={(newId) => relink.mutate(newId)}
/>
</>
{lic && !cloud && <LicenceDelivery instanceId={instance.instance_id} blob={lic.blob ?? ""} downloadUrl={api.licenseBlobUrl(instance.instance_id)} />}
{/*
* "Moves" rather than "Relinks": the count is rationed, so the
* headline is how many are left, and the panel explains what
* spends one. Cloud instances cannot move — we own the host —
* so the panel is absent rather than present and refusing.
*/}
{!cloud && (
<Panel title="Moves" meta={`${instance.relink_count} of ${maxRelinks} used`}>
<p className="text-[0.86rem] text-ink-2">A licence binds to one install. Moving it to different hardware is a relink, and each account gets {maxRelinks}.</p>
<RelinkPanel instanceId={instance.instance_id} used={instance.relink_count} max={maxRelinks} error={relinkError} onRelink={(newId) => relink.mutate(newId)} />
</Panel>
)}
{!lic && (
<Panel bodyless>
<EmptyState
title="No licence issued yet."
body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports."
/>
<EmptyState title="No licence issued yet." body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports." action={<LinkButton href="/purchase">Get a licence</LinkButton>} />
</Panel>
)}
</PageFrame>
+29 -10
View File
@@ -6,6 +6,18 @@ import { Field } from "./Field";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/*
* The relink control, and only the control.
*
* It used to carry its own heading and its own "N of M relinks left this term"
* line. It now sits inside the Moves panel, which already says both — a panel
* titled Moves with "2 of 3 used" in its header, wrapping a section headed
* "Moved to a new server?" that says "1 of 3 relinks left", is the same fact
* told twice in two different directions.
*
* The exhausted case still lives here rather than in the caller: it is the
* reason the button is disabled, so it belongs beside the button.
*/
export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string; used: number; max: number; onRelink: (newId: string) => void; error?: string }) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
@@ -13,18 +25,25 @@ export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string
const exhausted = remaining === 0;
return (
<section className="grid gap-3 border-t border-rule-soft pt-5">
<h2 className="text-xl">Moved to a new server?</h2>
<p className="text-[0.82rem] text-ink-2">Relinking issues a replacement licence for the new install, covering the rest of your current term.</p>
{open && !exhausted && <Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
<div className="grid gap-3">
{open && !exhausted && (
<Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="line" disabled={exhausted || (open && !UUID_RE.test(value.trim()))} onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}>
Relink to a new install
<Button
type="button"
variant="line"
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
>
Move to another install
</Button>
<span className="text-[0.82rem] text-ink-3">
{exhausted ? "You have used every relink for this term contact support and we will sort it out." : `${remaining} of ${max} relinks left this term`}
</span>
{exhausted ? (
<span className="text-[0.82rem] text-ink-3">You have used every move for this term contact support and we will sort it out.</span>
) : (
open && <span className="text-[0.82rem] text-ink-3">Relinking issues a replacement licence covering the rest of your current term.</span>
)}
</div>
</section>
</div>
);
}