From a20e165ac244e7f5f8efa4933e87aade1096bb07 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 11 Aug 2026 10:32:59 +0100 Subject: [PATCH] feat: Revamp instance page --- adminsite/app/(customer)/billing/page.tsx | 16 ++-- .../app/(customer)/instances/[id]/page.tsx | 41 +++++++--- .../app/(customer)/users/InvitePanel.tsx | 8 +- .../(staff)/staff/accounts/AccountSearch.tsx | 3 +- adminsite/app/(staff)/staff/audit/page.tsx | 3 +- adminsite/app/(staff)/staff/licenses/page.tsx | 3 +- adminsite/components/Button.tsx | 27 +++++- adminsite/components/Field.tsx | 24 +++--- adminsite/components/LicenceDelivery.tsx | 82 +++++++++++++------ adminsite/components/MembersPanel.tsx | 73 ++++++++++------- adminsite/components/PageHeader.tsx | 14 +++- adminsite/components/Table.tsx | 53 ++++++++++-- adminsite/components/TermBar.tsx | 13 ++- 13 files changed, 253 insertions(+), 107 deletions(-) diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx index b5f1fc1..fe3a2ab 100644 --- a/adminsite/app/(customer)/billing/page.tsx +++ b/adminsite/app/(customer)/billing/page.tsx @@ -81,7 +81,7 @@ export default function BillingPage() { body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately." /> ) : ( - +
@@ -97,14 +97,18 @@ export default function BillingPage() { const name = nameFor(s.instance_id); return ( - - - - - + + + + {children} ); @@ -35,7 +47,7 @@ export function THead({ className, children, ...props }: HTMLAttributes) { return ( - + {children} ); @@ -43,7 +55,19 @@ export function TBody({ className, children, ...props }: HTMLAttributes) { return ( - + {children} ); @@ -69,9 +93,24 @@ export function TH({ className, numeric, children, ...props }: ThHTMLAttributes< ); } -export function TD({ className, numeric, children, ...props }: TdHTMLAttributes & CellProps) { +export function TD({ className, numeric, label, children, ...props }: TdHTMLAttributes & CellProps & { label?: string }) { return ( - ); diff --git a/adminsite/components/TermBar.tsx b/adminsite/components/TermBar.tsx index 86ab0c9..5f2d56f 100644 --- a/adminsite/components/TermBar.tsx +++ b/adminsite/components/TermBar.tsx @@ -66,10 +66,15 @@ export function TermBar({ -
- Issued {formatDate(issuedAt)} - {remaining} - Expires {formatDate(expiresAt)} + {/* + * On a phone the three ends stack, and the figure someone actually + * came for goes first — wrapping a justify-between row left "9 days + * left" marooned between two dates in the middle of the stack. + */} +
+ {remaining} + Issued {formatDate(issuedAt)} + Expires {formatDate(expiresAt)}
);
Instance
+ {name ?? Not linked yet} {name && {s.instance_id?.slice(0, 8)}} {s.tier.replace("_", " ")}{s.term}{s.status} + {s.tier.replace("_", " ")} + {s.term} + + {s.status} +
{start && } {formatDate(s.current_period_end)} diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx index c730e64..1b42b76 100644 --- a/adminsite/app/(customer)/instances/[id]/page.tsx +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -184,16 +184,16 @@ export default function InstancePage() { )} - {cloud ? ( - - ) : ( - -

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

-
- )} - + {/* + * On a self-hosted instance the licence is the errand: someone + * opens this page to fetch the blob and paste it. It sits + * directly under the term, above the panels that only explain + * things. + */} {lic && !cloud && } + {cloud && } + {/* * "Moves" rather than "Relinks": the count is rationed, so the * headline is how many are left, and the panel explains what @@ -201,12 +201,33 @@ export default function InstancePage() { * 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}.

+ +

+ A licence binds to one install. Rebuilding the host, or moving to different hardware, needs a replacement licence bound to the new ID — + that is a move, and it covers the rest of your current term. +

relink.mutate(newId)} />
)} + {/* + * A panel holding one sentence has not decided what it is for. + * For a self-hosted install the useful content is not "we don't + * do this" but where the thing they came looking for actually + * lives — and why the people on their HQ account are not it. + */} + {!cloud && ( + +

+ You run this deployment, so its users live inside it rather than here. Add and remove them in the instance’s own settings. +

+

+ People on your Vantage HQ account can see billing and this licence. That is separate from who can sign in to the instance, and granting + one never grants the other. +

+
+ )} + {!lic && ( Get a licence} /> diff --git a/adminsite/app/(customer)/users/InvitePanel.tsx b/adminsite/app/(customer)/users/InvitePanel.tsx index 4e95500..7b4e202 100644 --- a/adminsite/app/(customer)/users/InvitePanel.tsx +++ b/adminsite/app/(customer)/users/InvitePanel.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api"; import { useSession } from "@/lib/session"; import { NotConnectedPanel } from "@/components/NotConnected"; -import { Button } from "@/components/Button"; +import { Button, controlClass } from "@/components/Button"; import { Field } from "@/components/Field"; import { PageFrame, RailCard } from "@/components/PageFrame"; import { formatDate } from "@/lib/format"; @@ -89,11 +89,7 @@ export function InvitePanel() { Account role - setRole(e.target.value as AccountRole)} className={controlClass()}> {assignable.map((r) => ( diff --git a/adminsite/app/(staff)/staff/audit/page.tsx b/adminsite/app/(staff)/staff/audit/page.tsx index 1bb2c5c..365ed99 100644 --- a/adminsite/app/(staff)/staff/audit/page.tsx +++ b/adminsite/app/(staff)/staff/audit/page.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { api } from "@/lib/api"; import { formatDate, formatStamp } from "@/lib/format"; import { PageHeader } from "@/components/PageHeader"; +import { controlClass } from "@/components/Button"; import { EmptyState, Panel } from "@/components/Panel"; import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table"; @@ -30,7 +31,7 @@ export default function AuditPage() { value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Action, actor or target" - className="w-full rounded border border-rule bg-panel-2 px-3 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none" + className={controlClass()} />
diff --git a/adminsite/app/(staff)/staff/licenses/page.tsx b/adminsite/app/(staff)/staff/licenses/page.tsx index 893dc6f..5e62a32 100644 --- a/adminsite/app/(staff)/staff/licenses/page.tsx +++ b/adminsite/app/(staff)/staff/licenses/page.tsx @@ -6,11 +6,12 @@ import { useState } from "react"; import { api, type Tier } from "@/lib/api"; import { formatDate, licenceState } from "@/lib/format"; import { PageHeader } from "@/components/PageHeader"; +import { controlClass } from "@/components/Button"; import { EmptyState, Panel } from "@/components/Panel"; import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table"; import { TermSpark } from "@/components/TermBar"; -const SELECT = "rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none"; +const SELECT = controlClass("w-auto"); export default function LicensesPage() { const [tier, setTier] = useState<"" | Tier>(""); diff --git a/adminsite/components/Button.tsx b/adminsite/components/Button.tsx index 1904b65..b416464 100644 --- a/adminsite/components/Button.tsx +++ b/adminsite/components/Button.tsx @@ -8,9 +8,34 @@ type Variant = "solid" | "line"; * border on the secondary variant. site/ does not have an accent-outlined * button and this app should not invent one. */ +/* + * The height every form control resolves to, buttons included. + * + * Padding alone cannot align them: a select is mono at 0.84rem and a button is + * sans at 0.94rem, so identical padding still leaves them ~7px apart and a + * filter row looks assembled from two different kits. It is the height the + * button's own padding already computed to, so buttons do not move — everything + * else comes up to meet them. + */ +export const CONTROL_HEIGHT = "h-11"; + +/* + * An input or select that sits on a form row with a button. Mono, because in + * this product the values typed into these are addresses, UUIDs and price IDs. + */ +export function controlClass(className?: string) { + return clsx( + CONTROL_HEIGHT, + "w-full rounded border border-rule bg-panel-2 px-2.5 font-mono text-[0.88rem] text-ink", + "focus:border-accent focus:outline-none", + className, + ); +} + export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) { return clsx( - "inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold", + "inline-flex items-center gap-2 rounded border px-4 text-[0.94rem] font-semibold", + CONTROL_HEIGHT, "transition-[filter,border-color] duration-150 hover:brightness-110", variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3", disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100", diff --git a/adminsite/components/Field.tsx b/adminsite/components/Field.tsx index 04f07af..852eb79 100644 --- a/adminsite/components/Field.tsx +++ b/adminsite/components/Field.tsx @@ -1,7 +1,10 @@ +import { controlClass } from "./Button"; + export function Field({ label, hint, error, + className, ...input }: React.InputHTMLAttributes & { label: string; @@ -10,18 +13,15 @@ export function Field({ }) { return ( ); } diff --git a/adminsite/components/LicenceDelivery.tsx b/adminsite/components/LicenceDelivery.tsx index 26c009f..92543d1 100644 --- a/adminsite/components/LicenceDelivery.tsx +++ b/adminsite/components/LicenceDelivery.tsx @@ -1,56 +1,86 @@ "use client"; import { useState } from "react"; -import { Button } from "./Button"; +import { Panel } from "./Panel"; /* - * A licence blob is signed public data, not a secret it is useless on any + * A licence blob is signed public data, not a secret — it is useless on any * instance other than the one it names. So it is safe to show inline, and * showing it is what stops a blocked download from blocking a paying customer. + * That is also why it is never collapsed behind a toggle: someone whose + * clipboard and download are both blocked has to be able to select it by hand. + * + * It is evidence rather than content, so it is set in a well with a keyed strip + * saying what it is and how much of it there is, and given a fixed height. It + * used to run to 250px of base64 and was the largest thing on the page, which + * is a strange amount of room to give a string nobody reads. + * + * The download lives in the page header beside Renew, not here — it was in both + * places, which is one button too many for one file. */ -export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) { +export function LicenceDelivery({ blob }: { instanceId: string; blob: string; downloadUrl: string }) { const [copied, setCopied] = useState(false); async function copy() { - await navigator.clipboard.writeText(blob); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + try { + await navigator.clipboard.writeText(blob); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard is refused without a secure context or a gesture the + // browser trusts. The blob is on screen and selectable either way, + // so this needs no error state. + } } const steps = [ <> - Open Settings → Licence on your install. + Open Settings → Licence on your install. , <>Paste the licence into the box and save., <> - The page reports Valid straight away no restart. + The page reports Valid straight away — no restart. , ]; return ( -
-

Your licence

-
- - Download licence - - + +
+
+ Licence key + {blob.length.toLocaleString()} characters +
+ +
+ {/* Dashed, because this is data to be carried somewhere else + rather than a surface to read. */} +
+                        {blob}
+                    
+ +
-
{blob}
+ + {/* Numbered because this is an actual sequence — each step is only + possible once the one before it is done. */}
    {steps.map((body, i) => ( -
  1. - {i + 1} - {body} +
  2. + {i + 1} + {body}
  3. ))}
-
+ ); } + +function Code({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/adminsite/components/MembersPanel.tsx b/adminsite/components/MembersPanel.tsx index 59a47f1..d9e6823 100644 --- a/adminsite/components/MembersPanel.tsx +++ b/adminsite/components/MembersPanel.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { ApiError, api, type InstanceRole } from "@/lib/api"; import { useSession } from "@/lib/session"; -import { Button } from "@/components/Button"; +import { Button, controlClass } from "@/components/Button"; import { EmptyState, Panel } from "@/components/Panel"; const ROLES: InstanceRole[] = ["owner", "admin", "member"]; @@ -23,7 +23,8 @@ const ROLE_GRANTS: Record = { const SELECT_QUIET = "rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none"; -const SELECT = "rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.84rem] text-ink focus:border-accent focus:outline-none"; +/* Same height as the Grant access button beside it — see controlClass. */ +const SELECT = controlClass("bg-panel"); /* * The access roster for one instance. @@ -110,34 +111,46 @@ export function MembersPanel({ instanceId }: { instanceId: string }) { /> ) : (
    + {/* + * Two columns on a phone — monogram and address — with the + * controls dropping to their own full-width row beneath; + * three columns from sm up, controls right-aligned. As one + * wrapping flex row the address competed with a select and + * two buttons for 320px and lost, and the confirm step put + * three more elements into the same row. + */} {rows.map((m) => ( -
  • +
  • {m.email.slice(0, 2).toUpperCase()} - {m.email} + {m.email} - {canManage ? ( - - ) : ( - {m.role} - )} +
    + {canManage ? ( + + ) : ( + {m.role} + )} - {canManage && + {canManage && /* * Confirming inline rather than through * window.confirm(), and in the row itself rather @@ -145,7 +158,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) { * where the eye already is. */ (confirming === m.customer_user_id ? ( - + Revoke access? ))} +
  • ))}
@@ -180,15 +194,18 @@ export function MembersPanel({ instanceId }: { instanceId: string }) { {canManage && (
+ {/* Stacked and full width on a phone; one row from sm up. + Three controls side by side left the person select about + 90px wide, which is not enough to read an address in. */}
{ e.preventDefault(); setError(null); if (selected) grant.mutate(); }} > -
+ + {label && ( + {label} + )} {children}