feat: Revamp instance page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 37s

This commit is contained in:
2026-08-11 10:32:59 +01:00
parent ed260cd86c
commit a20e165ac2
13 changed files with 253 additions and 107 deletions
+10 -6
View File
@@ -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."
/>
) : (
<Table>
<Table stack>
<THead>
<TR className="hover:bg-transparent">
<TH>Instance</TH>
@@ -97,14 +97,18 @@ export default function BillingPage() {
const name = nameFor(s.instance_id);
return (
<TR key={s.subscription_id}>
<TD>
<TD label="Instance">
{name ?? <span className="text-ink-3">Not linked yet</span>}
{name && <Sub>{s.instance_id?.slice(0, 8)}</Sub>}
</TD>
<TD>{s.tier.replace("_", " ")}</TD>
<TD className="text-ink-2">{s.term}</TD>
<TD className="text-ink-2">{s.status}</TD>
<TD>
<TD label="Plan">{s.tier.replace("_", " ")}</TD>
<TD label="Billing" className="text-ink-2">
{s.term}
</TD>
<TD label="Status" className="text-ink-2">
{s.status}
</TD>
<TD label="Renews">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
@@ -184,16 +184,16 @@ export default function InstancePage() {
</Panel>
)}
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<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>
)}
{/*
* 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 && <LicenceDelivery instanceId={instance.instance_id} blob={lic.blob ?? ""} downloadUrl={api.licenseBlobUrl(instance.instance_id)} />}
{cloud && <MembersPanel instanceId={instance.instance_id} />}
{/*
* "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 && (
<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>
<Panel title="Moves" meta={`${Math.max(0, maxRelinks - instance.relink_count)} of ${maxRelinks} left`}>
<p className="text-[0.86rem] text-ink-2">
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.
</p>
<RelinkPanel instanceId={instance.instance_id} used={instance.relink_count} max={maxRelinks} error={relinkError} onRelink={(newId) => relink.mutate(newId)} />
</Panel>
)}
{/*
* 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 && (
<Panel title="Who can sign in" meta="Managed in your install">
<p className="text-[0.86rem] text-ink-2">
You run this deployment, so its users live inside it rather than here. Add and remove them in the instance&rsquo;s own settings.
</p>
<p className="text-[0.82rem] text-ink-3">
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.
</p>
</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." action={<LinkButton href="/purchase">Get a licence</LinkButton>} />
@@ -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() {
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className={controlClass()}>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
@@ -6,6 +6,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { EmptyState, Panel } from "@/components/Panel";
import { controlClass } from "@/components/Button";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export function AccountSearch() {
@@ -27,7 +28,7 @@ export function AccountSearch() {
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, email, ctm_… or an instance UUID"
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()}
/>
</label>
</Panel>
+2 -1
View File
@@ -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()}
/>
</label>
</Panel>
@@ -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>("");