From 99cd2a8ec3318546cb61912e300a61a48d95d719 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 11 Aug 2026 09:59:27 +0100 Subject: [PATCH] feat: Updated admin instance page --- .../app/(customer)/instances/[id]/page.tsx | 235 ++++++++++-------- adminsite/components/RelinkPanel.tsx | 39 ++- 2 files changed, 158 insertions(+), 116 deletions(-) diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx index 855413b..c730e64 100644 --- a/adminsite/app/(customer)/instances/[id]/page.tsx +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -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 ( +
+
{label}
+
{value}
+
+ ); +} + +/* + * 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 ( +
+ Features +
+ {[...all, ...extras].map((f) => { + const on = granted.includes(f); + return ( + + {featureLabel(f)} + + ); + })} +
+
+ ); +} 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 ; @@ -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 (
+ {lic && !cloud && ( + + Download licence + + )} + {lic && Renew licence} + + } + record={[{ key: "Instance", value: instance.instance_id, copy: true }, ...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : [])]} status={} /> - - {lic ? ( - - ) : ( -

- No licence issued yet. -

- )} + host ? ( + +

Servers, workflows and monitors live in the instance itself.

+ + Open {instance.name || "instance"} → + +

{host}

- - {lic && ( - - - - )} - - {!cloud && ( - - -

- Moving a licence to a different install counts as one. -

-
- )} - + ) : 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 && ( - {state === "warn" && 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. - )} + {state === "warn" && Inside 14 days of expiry. Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.} + {state === "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.} + + )} + + {/* + * 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 && ( + + Change plan → + + } + > +
+
+ + + +
+
+ + + +
+
+ +
)} {cloud ? ( ) : ( - -

- Users for this install are managed inside it, in Settings → Instance. We have no access to your own deployment. -

+ +

Users for this install are managed inside it, in Settings → Instance. We have no access to your own deployment.

)} - {lic && !cloud && ( - <> - - relink.mutate(newId)} - /> - + {lic && !cloud && } + + {/* + * "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 && ( + +

A licence binds to one install. Moving it to different hardware is a relink, and each account gets {maxRelinks}.

+ relink.mutate(newId)} /> +
)} {!lic && ( - + Get a licence} /> )}
diff --git a/adminsite/components/RelinkPanel.tsx b/adminsite/components/RelinkPanel.tsx index c897667..0380cb6 100644 --- a/adminsite/components/RelinkPanel.tsx +++ b/adminsite/components/RelinkPanel.tsx @@ -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 ( -
-

Moved to a new server?

-

Relinking issues a replacement licence for the new install, covering the rest of your current term.

- {open && !exhausted && setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />} +
+ {open && !exhausted && ( + setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." /> + )}
- - - {exhausted ? "You have used every relink for this term contact support and we will sort it out." : `${remaining} of ${max} relinks left this term`} - + {exhausted ? ( + You have used every move for this term — contact support and we will sort it out. + ) : ( + open && Relinking issues a replacement licence covering the rest of your current term. + )}
-
+
); }