diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx
index 33b92a0..70c3cee 100644
--- a/adminsite/app/(customer)/billing/page.tsx
+++ b/adminsite/app/(customer)/billing/page.tsx
@@ -6,7 +6,8 @@ import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
-import { formatDate } from "@/lib/format";
+import { TermSpark } from "@/components/TermBar";
+import { formatDate, licenceState } from "@/lib/format";
export default function BillingPage() {
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
@@ -22,6 +23,21 @@ export default function BillingPage() {
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
+ /*
+ * A subscription reports when the period ends but not when it began, so the
+ * start is derived from the term. Only the two terms we actually sell are
+ * handled — anything else returns null and the row falls back to the date
+ * alone, because a bar drawn from a guessed span is worse than no bar.
+ */
+ const periodStart = (end: string, term: string): string | null => {
+ const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0;
+ if (!months) return null;
+ const d = new Date(end);
+ if (Number.isNaN(d.getTime())) return null;
+ d.setMonth(d.getMonth() - months);
+ return d.toISOString();
+ };
+
return (
diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx
index 7230201..4f5e7ee 100644
--- a/adminsite/app/(customer)/instances/[id]/page.tsx
+++ b/adminsite/app/(customer)/instances/[id]/page.tsx
@@ -9,6 +9,7 @@ import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
+import { TermBar } from "@/components/TermBar";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
@@ -136,6 +137,35 @@ export default function InstancePage() {
>
}
>
+ {/*
+ * 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.
+ */}
+ {lic && (
+
+
+ Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.
+
+ )}
+ {state === "expired" && (
+
+ Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed.
+
+ )}
+
+ )}
+
{cloud ? (
) : (
diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx
index 311e36b..73727a5 100644
--- a/adminsite/app/(customer)/page.tsx
+++ b/adminsite/app/(customer)/page.tsx
@@ -34,21 +34,55 @@ export default function OverviewPage() {
const live = data.instances.filter((i) => i.status !== "deleted");
- // Work the customer has to do, gathered across every instance. This is the
- // only account-level view of it each record only knows about itself.
+ /*
+ * Work the customer has to do, gathered across every instance. This is the
+ * only account-level view of it — each record only knows about itself.
+ *
+ * Each item carries the way out of it. It used to be a list of sentences in
+ * the rail, which told someone their licence was expiring and then made
+ * them go and find the instance that owned it; the fix for every one of
+ * these is one click, so the click belongs on the row.
+ */
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
- if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
- if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
- if (state === "warn")
+ const name = i.name || "An instance";
+
+ if (state === "none")
return [
{
id: i.instance_id,
- text: `${i.name} expires`,
- note: `${daysRemaining(lic!.expires_at)}d`,
+ text: `${name} is waiting for an install ID`,
+ note: "You have paid for this. Paste the UUID from the install to get your licence.",
+ href: i.status === "awaiting_link" ? `/instances/link?claim=${i.instance_id}` : "/purchase",
+ action: i.status === "awaiting_link" ? "Link install" : "Get a licence",
+ tag: "",
},
];
+ if (state === "expired")
+ return [
+ {
+ id: i.instance_id,
+ text: `${name} has expired`,
+ note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.",
+ href: `/instances/${i.instance_id}`,
+ action: "Renew",
+ tag: "now",
+ },
+ ];
+ if (state === "warn") {
+ const d = daysRemaining(lic!.expires_at);
+ return [
+ {
+ id: i.instance_id,
+ text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`,
+ note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.",
+ href: `/instances/${i.instance_id}`,
+ action: "Renew",
+ tag: `${d}d`,
+ },
+ ];
+ }
return [];
});
@@ -71,32 +105,43 @@ export default function OverviewPage() {
/>
{live.length === 0 ? (
-
-
No instances yet
-
- Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
-
-
- Buy a plan
+ /*
+ * An empty screen is an invitation to act, and the two ways in
+ * are genuinely different products — we host it, or you do. One
+ * button and a paragraph explaining the other option made the
+ * self-hosted path read as an afterthought, which it is not.
+ */
+
+
+
No instances yet
+
An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.
+
+
+
+
Cloud
+
We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.
+
+ Create a cloud instance
+
+
+
+
Self-hosted
+
You host it. Get the licence here, then paste your install’s ID to bind it.
+
+
+ License my own install
+
+
+
+
+
+
The Free tier covers 5 servers and needs no card.
) : (
- {attention.length > 0 && (
-
-
- {attention.map((a) => (
-
- {a.text}
- {a.note && {a.note}}
-
- ))}
-
-
- )}
-
{(people.data ?? []).slice(0, 5).map((p) => (
@@ -147,6 +192,38 @@ export default function OverviewPage() {
>
}
>
+ {/*
+ * First in the main column, not in the rail. This is the
+ * reason the page is open; the rail is for things that are
+ * merely true. It disappears entirely when there is nothing
+ * in it rather than saying "all clear", which is a line
+ * nobody needs to read twice a week.
+ */}
+ {attention.length > 0 && (
+
+
+ {/*
+ * The live licence is the one nothing has superseded, which is the
+ * record's own statement of the fact — not its position in the
+ * array, which is the server's ordering and not a guarantee.
+ */}
+ {current && (
+
+
+
Current licence
+ {current.license_id}
+
+
+
+ )}
+
Licence history
diff --git a/adminsite/app/(staff)/staff/licenses/page.tsx b/adminsite/app/(staff)/staff/licenses/page.tsx
index 63c3058..4c7fd3c 100644
--- a/adminsite/app/(staff)/staff/licenses/page.tsx
+++ b/adminsite/app/(staff)/staff/licenses/page.tsx
@@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api, type Tier } from "@/lib/api";
-import { formatDate } from "@/lib/format";
+import { formatDate, licenceState } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
+import { TermSpark } from "@/components/TermBar";
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
@@ -60,6 +61,7 @@ export default function LicensesPage() {
Instance
Tier
Reason
+
Term
Expires
State
@@ -83,6 +85,17 @@ export default function LicensesPage() {
{l.tier.replace("_", " ")}
{l.reason.replace("_", " ")}
+ {/* A superseded row's term is not a countdown to
+ anything — it ended when its successor was
+ issued, so drawing a bar for it would invite
+ a comparison that means nothing. */}
+
+
+
);
}
export default function VerifyPage() {
return (
-
-
-
-
-
+ }>
+
+
);
}
diff --git a/adminsite/components/AuthShell.tsx b/adminsite/components/AuthShell.tsx
new file mode 100644
index 0000000..9be6f10
--- /dev/null
+++ b/adminsite/components/AuthShell.tsx
@@ -0,0 +1,67 @@
+import Link from "next/link";
+
+/*
+ * The frame for every screen you can reach without a session: sign in, email
+ * verification, and accepting an invitation.
+ *
+ * These three had drifted into three different layouts. Sign in was a centred
+ * 26rem card with the lockup above it; verify and accept-invite were bare
+ * left-aligned text on the full 1200px rail, with no masthead, no panel and no
+ * brand anywhere on the page. Those two are the first screens a new customer
+ * ever sees — arriving from an email, on a domain they have not visited before
+ * — and they were the two that did not say whose product this is.
+ *
+ * There is no AppBar here on purpose: it carries navigation and an account
+ * menu, and none of it works without a session.
+ */
+export function AuthShell({
+ title,
+ lede,
+ children,
+ footnote,
+}: {
+ title: string;
+ lede?: React.ReactNode;
+ children?: React.ReactNode;
+ /** Sits outside the panel: orientation, not part of the task. */
+ footnote?: React.ReactNode;
+}) {
+ return (
+
+ {/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
+
+
+ Vantage
+ HQ
+
+
{title}
+ {lede &&
{lede}
}
+
+
+ {children &&
{children}
}
+
+ {footnote &&
{footnote}
}
+
+ );
+}
+
+/*
+ * A terminal state — verified, expired, already used, invalid. Always says what
+ * happened and what to do next: a dead end that only reports the failure leaves
+ * someone holding an email they cannot act on.
+ */
+export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) {
+ return (
+
+
- {days} days remaining
- Renews {formatDate(license.expires_at)}
-
-
-
-
-
- )}
+ {/* The term is drawn for an expired licence too. The old bar hid
+ itself once it lapsed, which removed the measurement at exactly
+ the moment it started mattering. */}
+ {license && }
{state === "expired" && (
diff --git a/adminsite/components/Panel.tsx b/adminsite/components/Panel.tsx
new file mode 100644
index 0000000..49406dd
--- /dev/null
+++ b/adminsite/components/Panel.tsx
@@ -0,0 +1,91 @@
+import clsx from "clsx";
+
+/*
+ * The surface every screen is built from.
+ *
+ * Before this there were four panel treatments in the app: `rounded border
+ * border-rule bg-panel p-5` with an `
`, the same thing
+ * with `text-[0.95rem] font-medium`, a bare ``
+ * with no border at all, and a table wrapper that was a panel in everything but
+ * name. They were all trying to be the same object.
+ *
+ * The header is title-left, meta-right. Meta is the keyed idiom — mono, small,
+ * tracked, dimmed — because it is always a count, a scope or an identifier,
+ * never prose.
+ */
+export function Panel({
+ title,
+ meta,
+ actions,
+ tone,
+ children,
+ bodyless,
+ className,
+}: {
+ title?: string;
+ meta?: React.ReactNode;
+ actions?: React.ReactNode;
+ /** Draws the panel's own border in a state colour. For a panel that IS the warning. */
+ tone?: "warn" | "expired";
+ children: React.ReactNode;
+ /** Skip the padded body — for a panel whose content is a full-bleed table. */
+ bodyless?: boolean;
+ className?: string;
+}) {
+ const head = title || meta || actions;
+
+ return (
+
+ {head && (
+
+ {title &&
{title}
}
+
+ {meta && {meta}}
+ {actions}
+
+
+ )}
+ {bodyless ? children :
{children}
}
+
+ );
+}
+
+/*
+ * An aside that is part of the argument rather than beside it: the consequence
+ * of the action on screen, or the constraint the reader is about to hit. The
+ * left rule carries the tone, so the note reads as annotation and never as a
+ * second panel competing with the one it sits in.
+ */
+export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/*
+ * An empty screen is an invitation to act. Every one of these says what the
+ * thing is before offering to make one — "No licences match those filters" on
+ * its own tells someone the filter worked, not what to do about it.
+ */
+export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
+ return (
+
+
{title}
+ {body &&
{body}
}
+ {action &&
{action}
}
+
+ );
+}
diff --git a/adminsite/components/Queue.tsx b/adminsite/components/Queue.tsx
index cb58231..64e08e5 100644
--- a/adminsite/components/Queue.tsx
+++ b/adminsite/components/Queue.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import clsx from "clsx";
+import type { ReactNode } from "react";
const TONE = {
expired: "border-l-expired text-expired",
@@ -16,7 +17,11 @@ export function Queue({
title: string;
count: number;
tone: keyof typeof TONE;
- items: { label: string; href: string; meta: string }[];
+ /* `meta` is a node rather than a string so a queue about time can carry the
+ * term measurement itself. A tier name told the reader what the instance
+ * was; the queue is sorted by how soon it lapses, and that was the one
+ * figure the row did not show. */
+ items: { label: string; href: string; meta: ReactNode }[];
}) {
return (
(
-
+
{i.label}
- {i.meta}
+ {i.meta}
))}
diff --git a/adminsite/components/Table.tsx b/adminsite/components/Table.tsx
new file mode 100644
index 0000000..3be9bc6
--- /dev/null
+++ b/adminsite/components/Table.tsx
@@ -0,0 +1,83 @@
+import clsx from "clsx";
+import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
+
+/*
+ * One table treatment for the whole console.
+ *
+ * There were four: billing, licences, accounts and catalogue each wrote their
+ * own thead, and they disagreed about the head's type size, its tracking,
+ * whether it sat on --panel-2, and whether numbers were tabular. Catalogue's
+ * heads were sentence-case body text. A registry whose columns are set four
+ * ways does not read as one product.
+ *
+ * The head is the keyed idiom — mono, small, uppercase, widely tracked — which
+ * is what a column head is: a key above a value, exactly as the record line is
+ * a key beside one.
+ */
+
+export function Table({ className, children, ...props }: HTMLAttributes) {
+ return (
+
+ );
+}
+
+/** The secondary line under a cell's main value — an ID, a deployment, a date. */
+export function Sub({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
diff --git a/adminsite/components/TermBar.tsx b/adminsite/components/TermBar.tsx
new file mode 100644
index 0000000..86ab0c9
--- /dev/null
+++ b/adminsite/components/TermBar.tsx
@@ -0,0 +1,99 @@
+import clsx from "clsx";
+import { daysRemaining, formatDate, type LicenceState } from "@/lib/format";
+
+/*
+ * A licence's life as a measured line: issued at the left, expiry at the right,
+ * today as a notch, the part you have not got yet hatched.
+ *
+ * This replaces a 1px progress rule and a "Renews 19 Aug 2026" caption. The
+ * date is still there, but a date alone makes the reader do the arithmetic that
+ * is the only question this product is ever asked — when does this stop
+ * working. The bar answers it before they read a word.
+ *
+ * The fill takes the state's colour, so the same vocabulary the pill uses
+ * carries through. State is never colour alone here either: the remaining span
+ * is hatched rather than tinted, the notch is a hard edge, and the days-left
+ * figure is written out.
+ */
+
+const TONE: Record = {
+ valid: "text-valid",
+ warn: "text-warn",
+ expired: "text-expired",
+ none: "text-accent",
+};
+
+function span(issuedAt: string, expiresAt: string) {
+ const start = new Date(issuedAt).getTime();
+ const end = new Date(expiresAt).getTime();
+ const total = end - start;
+ // A licence issued and expiring at the same instant is not a real record,
+ // but it must not divide by zero on the way to being rendered.
+ if (!Number.isFinite(total) || total <= 0) return 100;
+ const elapsed = Date.now() - start;
+ return Math.max(0, Math.min(100, (elapsed / total) * 100));
+}
+
+export function TermBar({
+ issuedAt,
+ expiresAt,
+ state,
+ className,
+}: {
+ issuedAt: string;
+ expiresAt: string;
+ state: LicenceState;
+ className?: string;
+}) {
+ const pct = span(issuedAt, expiresAt);
+ const days = daysRemaining(expiresAt);
+ const expired = days <= 0;
+
+ const remaining = expired
+ ? `Expired ${Math.abs(days)} ${Math.abs(days) === 1 ? "day" : "days"} ago`
+ : `${days} ${days === 1 ? "day" : "days"} left`;
+
+ return (
+
+
+
+ {/* The span still to come, drawn as absence rather than as a
+ second colour: it is the thing being bought. */}
+
+
+
+ );
+}
+
+/*
+ * The same measurement at 56px, for a row in a ledger. Licences, Billing and
+ * the staff expiry queue are all lists of terms, and a list of dates cannot be
+ * scanned for "which of these is nearly out" — a list of bars can.
+ *
+ * It carries a text alternative rather than a title: the row it sits in is
+ * being read, not hovered.
+ */
+export function TermSpark({ issuedAt, expiresAt, state }: { issuedAt: string; expiresAt: string; state: LicenceState }) {
+ const pct = span(issuedAt, expiresAt);
+ const days = daysRemaining(expiresAt);
+
+ return (
+
+
+
+
+
+ {days <= 0 ? `−${Math.abs(days)}d` : `${days}d`}
+
+ );
+}