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 (
+
- 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." />
+ )}