From 727bb09eff0a1b688d50e653d7011b4e9b30da04 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 10 Aug 2026 13:56:59 +0100 Subject: [PATCH] feat: More updates to hq --- adminsite/app/(customer)/billing/page.tsx | 63 ++++--- .../app/(customer)/instances/[id]/page.tsx | 40 ++-- adminsite/app/(customer)/page.tsx | 11 +- .../(staff)/staff/accounts/AccountSearch.tsx | 100 +++++----- .../app/(staff)/staff/accounts/[id]/page.tsx | 176 ++++++++++++------ adminsite/app/(staff)/staff/audit/page.tsx | 86 ++++++--- .../app/(staff)/staff/catalogue/page.tsx | 67 +++---- .../app/(staff)/staff/instances/[id]/page.tsx | 14 +- adminsite/app/(staff)/staff/licenses/page.tsx | 176 +++++++++--------- adminsite/app/(staff)/staff/page.tsx | 71 ++++--- adminsite/app/(staff)/staff/plans/page.tsx | 33 ++-- 11 files changed, 455 insertions(+), 382 deletions(-) diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx index 70c3cee..b5f1fc1 100644 --- a/adminsite/app/(customer)/billing/page.tsx +++ b/adminsite/app/(customer)/billing/page.tsx @@ -7,6 +7,8 @@ import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; import { PageHeader } from "@/components/PageHeader"; import { ManageBillingButton } from "@/components/ManageBillingButton"; import { TermSpark } from "@/components/TermBar"; +import { EmptyState, Panel } from "@/components/Panel"; +import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table"; import { formatDate, licenceState } from "@/lib/format"; export default function BillingPage() { @@ -72,42 +74,49 @@ export default function BillingPage() { } > - {rows.length === 0 ? ( -

You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.

- ) : ( -
- - - - - - - - - - - + + {rows.length === 0 ? ( + + ) : ( +
InstancePlanTermStatusRenews
+ + + + + + + + + + {rows.map((s) => { const start = periodStart(s.current_period_end, s.term); + const name = nameFor(s.instance_id); return ( - - - - - - + + + + + - + + ); })} - -
InstancePlanBillingStatusRenews
{nameFor(s.instance_id) ?? Not linked yet}{s.tier.replace("_", " ")}{s.term}{s.status} +
+ {name ?? Not linked yet} + {name && {s.instance_id?.slice(0, 8)}} + {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 4f5e7ee..855413b 100644 --- a/adminsite/app/(customer)/instances/[id]/page.tsx +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -10,6 +10,7 @@ import { MembersPanel } from "@/components/MembersPanel"; 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 { PageHeader } from "@/components/PageHeader"; import { formatDate, licenceState, limitLabel } from "@/lib/format"; @@ -143,36 +144,24 @@ export default function InstancePage() { * text — which is the arithmetic this bar does for the reader. */} {lic && ( -
-
-

Licence

- - {lic.tier.replace("_", " ")} · {cloud ? "Cloud" : "Self-hosted"} - -
- + - {state === "warn" && ( -

- Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early. -

- )} + {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. -

+ Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed. )} -
+ )} {cloud ? ( ) : ( -

- Users for this install are managed inside it, in Settings → Instance. We do - not have 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 && ( @@ -193,9 +182,12 @@ export default function InstancePage() { )} {!lic && ( -

- No licence has been issued for this instance yet. -

+ + + )} diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx index 73727a5..b329d4c 100644 --- a/adminsite/app/(customer)/page.tsx +++ b/adminsite/app/(customer)/page.tsx @@ -6,6 +6,7 @@ import { API_BASE, NotConnected, api, type License } from "@/lib/api"; import { NotConnectedPanel } from "@/components/NotConnected"; import { InstanceRecord } from "@/components/InstanceRecord"; import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; +import { Panel } from "@/components/Panel"; import { PageHeader } from "@/components/PageHeader"; import { LinkButton } from "@/components/Button"; import { StatePill } from "@/components/StatePill"; @@ -200,13 +201,7 @@ export default function OverviewPage() { * nobody needs to read twice a week. */} {attention.length > 0 && ( -
-
-

Needs you

- - {attention.length} {attention.length === 1 ? "item" : "items"} - -
+
    {attention.map((a) => (
  • @@ -221,7 +216,7 @@ export default function OverviewPage() {
  • ))}
-
+ )} {live.map((i, n) => { diff --git a/adminsite/app/(staff)/staff/accounts/AccountSearch.tsx b/adminsite/app/(staff)/staff/accounts/AccountSearch.tsx index a91d302..8e0f9f6 100644 --- a/adminsite/app/(staff)/staff/accounts/AccountSearch.tsx +++ b/adminsite/app/(staff)/staff/accounts/AccountSearch.tsx @@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useState } from "react"; import { api } from "@/lib/api"; -import { Field } from "@/components/Field"; import { formatDate } from "@/lib/format"; +import { EmptyState, Panel } from "@/components/Panel"; +import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table"; export function AccountSearch() { const [q, setQ] = useState(""); @@ -14,56 +15,65 @@ export function AccountSearch() { queryFn: () => api.staff.accounts(q || undefined), }); + const rows = data ?? []; + return (
- setQ(e.target.value)} - hint="Name, email, Paddle customer ID, or an instance UUID." - /> -
- - - - - - - - - - - {(data ?? []).map((a) => ( - - +
AccountBilling emailStatusCreated
- + + + + + + + + + + + + + + + + {rows.map((a) => ( + + - - - - + + {a.account_id} + + + + + + + ))} - -
AccountBilling emailStatusCreated +
+ {a.name} - - {a.billing_email} - {a.status} - {formatDate(a.created_at)} -
{a.billing_email}{a.status}{formatDate(a.created_at)} + + Open + +
- {!isFetching && (data ?? []).length === 0 && ( -

- No account matches that. Try the instance UUID from the customer’s - email. -

+
+ + {!isFetching && rows.length === 0 && ( + )} -
+
); } diff --git a/adminsite/app/(staff)/staff/accounts/[id]/page.tsx b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx index 9877007..cd44cf2 100644 --- a/adminsite/app/(staff)/staff/accounts/[id]/page.tsx +++ b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx @@ -6,6 +6,8 @@ import Link from "next/link"; import { api } from "@/lib/api"; import { formatDate } from "@/lib/format"; import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, Panel } from "@/components/Panel"; +import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table"; export default function AccountDetailPage() { const id = String(useParams().id); @@ -17,7 +19,7 @@ export default function AccountDetailPage() { if (isLoading || !data) return

Loading…

; return ( -
+
- -
    - {data.instances.map((i) => ( -
  • - - {i.name || i.instance_id} - - - {i.deployment} · {i.tier ?? "no tier"} · {i.status} - -
  • - ))} - {data.instances.length === 0 &&
  • None.
  • } -
+ {/* + * Four lists of "thing · thing · thing" became four tables. Each row + * held three or four separate facts run into one string with + * middots, which cannot be scanned down a column — and a staff + * screen is read by scanning down a column. + */} + + + + + + + + + + + + {data.instances.map((i) => ( + + + + + + + + ))} + +
InstanceDeploymentTierStatus +
+ + {i.name || "Unnamed instance"} + + + {i.instance_id.slice(0, 8)} + + {i.deployment === "cloud" ? "Cloud" : "Self-hosted"}{i.tier?.replace("_", " ") ?? "—"}{i.status} + + Open + +
+ {data.instances.length === 0 && }
- -
    - {data.subscriptions.map((s) => ( -
  • - - {s.tier.replace("_", " ")} · {s.term} - - - {s.status} · renews {formatDate(s.current_period_end)} - -
  • - ))} - {data.subscriptions.length === 0 &&
  • None.
  • } -
+ + + + + + + + + + + + {data.subscriptions.map((s) => ( + + + + + + + ))} + +
TierBillingStatusRenews
{s.tier.replace("_", " ")}{s.term}{s.status}{formatDate(s.current_period_end)}
+ {data.subscriptions.length === 0 && }
- -
    - {data.users.map((u) => ( -
  • - {u.email} - {u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"} -
  • - ))} - {data.users.length === 0 &&
  • None this is a cloud account, so its people sign in with their control-plane details.
  • } -
+ + + + + + + + + + + {data.users.map((u) => ( + + + + + + ))} + +
EmailRoleVerified
{u.email}{u.account_role} + {u.verified_at ? ( + {formatDate(u.verified_at)} + ) : ( + Not verified + )} +
+ {data.users.length === 0 && ( + + )}
- -
    - {data.audit.map((e, n) => ( -
  • - - {e.action} · {e.actor} - - {formatDate(e.created_at)} -
  • - ))} - {data.audit.length === 0 &&
  • Nothing yet.
  • } -
+ + + + + + + + + + + + {data.audit.map((e, n) => ( + + + + + + + ))} + +
DateActorActionTarget
{formatDate(e.created_at)}{e.actor}{e.action}{e.target ?? "—"}
+ {data.audit.length === 0 && }
); } - -function Panel({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} diff --git a/adminsite/app/(staff)/staff/audit/page.tsx b/adminsite/app/(staff)/staff/audit/page.tsx index 46bd27c..1bb2c5c 100644 --- a/adminsite/app/(staff)/staff/audit/page.tsx +++ b/adminsite/app/(staff)/staff/audit/page.tsx @@ -4,18 +4,15 @@ import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { api } from "@/lib/api"; import { formatDate, formatStamp } from "@/lib/format"; -import { Field } from "@/components/Field"; import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, Panel } from "@/components/Panel"; +import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table"; export default function AuditPage() { const [filter, setFilter] = useState(""); const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() }); - const rows = (data ?? []).filter((e) => - filter - ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) - : true, - ); + const rows = (data ?? []).filter((e) => (filter ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) : true)); return (
@@ -24,30 +21,59 @@ export default function AuditPage() { subtitle="Every mutating action across every account, newest first." record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]} /> - setFilter(e.target.value)} - hint="Action, actor or target." - /> -
    - {rows.map((e, n) => ( -
  • - - {formatDate(e.created_at)} {formatStamp(e.created_at)} - - - {e.action} · {e.actor} - {e.target && ` · ${e.target}`} - {e.detail && ` · ${e.detail}`} - -
  • - ))} - {rows.length === 0 &&
  • Nothing matches that.
  • } -
+ + + + + + {/* + * A table, not a list of mono sentences joined by middots. Every row + * held five separate facts run together into one string, so nothing + * could be scanned down a column — which is the only way anyone + * reads an audit log looking for "who did this". + */} + + + + + + + + + + + + + {rows.map((e, n) => ( + + + + + + + + ))} + +
TimeActorActionTargetDetail
+ {formatStamp(e.created_at)} + {formatDate(e.created_at)} + {e.actor}{e.action}{e.target ?? "—"}{e.detail ?? "—"}
+ {rows.length === 0 && ( + + )} +
); } diff --git a/adminsite/app/(staff)/staff/catalogue/page.tsx b/adminsite/app/(staff)/staff/catalogue/page.tsx index b233cca..82f6a5c 100644 --- a/adminsite/app/(staff)/staff/catalogue/page.tsx +++ b/adminsite/app/(staff)/staff/catalogue/page.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { PageHeader } from "@/components/PageHeader"; import { PageFrame } from "@/components/PageFrame"; +import { Panel } from "@/components/Panel"; +import { TBody, TD, TH, THead, TR, Table } from "@/components/Table"; import { api, type CatalogueRow, type Term } from "@/lib/api"; const ENVS = ["sandbox", "production"] as const; @@ -69,35 +71,27 @@ export default function CataloguePage() { {isLoading ? (

Loading…

) : ( -
+
{groups.map((g) => { const [deployment, tier] = g.split("/"); const terms = termsFor(deployment); return ( -
-

- {deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "} - {tier} -

-
- - - - + +
Component
+ + + {ENVS.map((env) => terms.map((t) => ( - + )), )} - - - + + + {rows .filter( (r) => @@ -111,19 +105,11 @@ export default function CataloguePage() { JSON.stringify(ids) !== JSON.stringify(r.price_ids ?? {}); return ( - - + + {ENVS.map((env) => terms.map((t) => ( - + )), )} - - + + ); })} - -
Component + {env} / {t} - -
+
- {componentLabel(r)} -
{componentLabel(r)} + - + -
-
-
+ + + ); })}
diff --git a/adminsite/app/(staff)/staff/instances/[id]/page.tsx b/adminsite/app/(staff)/staff/instances/[id]/page.tsx index 8d3c087..64dc8d4 100644 --- a/adminsite/app/(staff)/staff/instances/[id]/page.tsx +++ b/adminsite/app/(staff)/staff/instances/[id]/page.tsx @@ -9,6 +9,7 @@ import { api, type Deployment, type InjectionState } from "@/lib/api"; import { Ledger } from "@/components/Ledger"; import { PageHeader } from "@/components/PageHeader"; import { TermBar } from "@/components/TermBar"; +import { Panel } from "@/components/Panel"; import { licenceState } from "@/lib/format"; import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator"; import { IssuePanel } from "./IssuePanel"; @@ -68,20 +69,15 @@ export default function StaffInstancePage() { * 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 4c7fd3c..893dc6f 100644 --- a/adminsite/app/(staff)/staff/licenses/page.tsx +++ b/adminsite/app/(staff)/staff/licenses/page.tsx @@ -6,8 +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 { 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"; + export default function LicensesPage() { const [tier, setTier] = useState<"" | Tier>(""); const [reason, setReason] = useState(""); @@ -15,9 +19,8 @@ export default function LicensesPage() { // Filtered here rather than server-side: the endpoint caps at 500 rows and // staff are narrowing a list they can already see. - const rows = (data ?? []).filter( - (l) => (!tier || l.tier === tier) && (!reason || l.reason === reason), - ); + const rows = (data ?? []).filter((l) => (!tier || l.tier === tier) && (!reason || l.reason === reason)); + const filtered = Boolean(tier || reason); return (
@@ -26,90 +29,93 @@ export default function LicensesPage() { subtitle="Append-only. A renewal writes a new row and supersedes the old one." record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]} /> -
- - -
-
- - - - - - - - - - - - - - {rows.map((l) => ( - - - - - - {/* 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. */} - - - - - ))} - -
IssuedInstanceTierReasonTermExpiresState
- {formatDate(l.issued_at)} - - - {l.instance_id.slice(0, 8)} - - {l.tier.replace("_", " ")}{l.reason.replace("_", " ")} - {l.superseded_by ? ( - superseded - ) : ( - - )} - - {formatDate(l.expires_at)} - - {l.superseded_by ? "superseded" : "current"} -
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + {rows.map((l) => { + const dead = Boolean(l.superseded_by); + return ( + // A superseded row is overprinted rather than hidden: + // it is the only record of why an instance stopped + // working on a given date. + + + + + + {/* A superseded row's term is not a countdown to + anything — it ended when its successor was + issued, so drawing a bar would invite a + comparison that means nothing. */} + + + + + ); + })} + +
IssuedInstanceTierReasonTermExpiresState
{formatDate(l.issued_at)} + + {l.instance_id.slice(0, 8)} + + + {l.license_id.slice(0, 8)} + + {l.tier.replace("_", " ")}{l.reason.replace("_", " ")} + {dead ? ( + + ) : ( + + )} + {formatDate(l.expires_at)} + {dead ? "superseded" : "current"} +
+ {rows.length === 0 && ( -

No licences match those filters.

+ )} -
+
); } diff --git a/adminsite/app/(staff)/staff/page.tsx b/adminsite/app/(staff)/staff/page.tsx index 1a9d315..05d4338 100644 --- a/adminsite/app/(staff)/staff/page.tsx +++ b/adminsite/app/(staff)/staff/page.tsx @@ -7,6 +7,8 @@ import { NotConnectedPanel } from "@/components/NotConnected"; import { Queue } from "@/components/Queue"; import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, Panel } from "@/components/Panel"; +import { TBody, TD, TH, THead, TR, Table } from "@/components/Table"; import { StatePill } from "@/components/StatePill"; import { LinkButton } from "@/components/Button"; import { daysRemaining, formatStamp } from "@/lib/format"; @@ -123,46 +125,37 @@ export default function StaffDashboard() { } > -
-
-
-

Recent activity

-

Every licence issued, relinked or reaped, newest first.

-
- - Full audit + + Full audit → -
-
- - - - - - - - - - - {(audit.data ?? []).slice(0, 12).map((e, n) => ( - - - - - - - ))} - {audit.data?.length === 0 && ( - - - - )} - -
TimeActionTargetActor
{new Date(e.created_at).toISOString().slice(11, 16)}{e.action}{e.target ?? "—"}{e.actor}
- Nothing yet today. -
-
-
+ } + bodyless + > + + + + + + + + + + + {(audit.data ?? []).slice(0, 12).map((e, n) => ( + + + + + + + ))} + +
TimeActorActionTarget
{new Date(e.created_at).toISOString().slice(11, 16)}{e.actor}{e.action}{e.target ?? "—"}
+ {audit.data?.length === 0 && } +
); diff --git a/adminsite/app/(staff)/staff/plans/page.tsx b/adminsite/app/(staff)/staff/plans/page.tsx index d93d617..53e76f3 100644 --- a/adminsite/app/(staff)/staff/plans/page.tsx +++ b/adminsite/app/(staff)/staff/plans/page.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { api, type Deployment, type Plan, type Tier } from "@/lib/api"; import { ConfirmPlanChange } from "@/components/ConfirmPlanChange"; import { PageHeader } from "@/components/PageHeader"; +import { Panel } from "@/components/Panel"; const SUPPORT_LEVELS = [ { value: "community", label: "Community" }, @@ -31,7 +32,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl const dirty = JSON.stringify(draft) !== JSON.stringify(plan); return ( -
+
{LIMIT_FIELDS.map((f) => ( @@ -58,7 +59,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl