Compare commits

..
6 Commits
Author SHA1 Message Date
mrhid6 1fe4ba5999 feat: HQ redesign
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 1m19s
2026-08-10 10:44:30 +01:00
mrhid6 e434beec7a fix(web): toasts survive an open dialog; empty-state actions can be gated
Both from the final review, and the first one overturns a call I got wrong.

The aria-hidden sweep that makes aria-modal true also swallowed the toasts.
ToastProvider renders inside the app root, and every modal-raised confirmation
is toasted *before* its dialog closes — "Saved …", "Deleted …", "Removed …" —
so each one was inserted into a hidden subtree and never announced. Un-hiding
a live region afterwards does not replay what it missed. The toast layer is
portalled to the body carrying the dialog-layer attribute, which exempts it
from the sweep, and sits above the dialog: a toast explaining why a dialog's
action failed is no use behind it.

EmptyState's action was narrower than the call site it replaced. The old
first-workflow button carried loading={isPending}; the new one carried
nothing, so a double click created two workflows. The action is a union now —
a link takes no pending state, a handler takes loading and disabled.
2026-08-10 10:01:47 +01:00
mrhid6 fe1dfe472a refactor(web): list pages share the async primitives; dialogs hide the page behind
Keys, secrets, audit, steps and workflows each carried the same
loading/error/empty ternary with its own copy of the spinner and its own
wording for a failed fetch — five of them had drifted to five different
sentences for "the request did not come back". They go through AsyncBoundary
now, which means a skeleton in place of a spinner, a retry button on failure,
and backend messages passed through friendlyMessage rather than printed raw.

Steps gets the filtered-empty state the fleet just got: "no steps match that
filter" is not "no steps yet", and only one of them should offer to create the
first one.

Two more from review:

Modal's effect ran before `mounted` flipped, so a dialog rendered already open
found null refs and took no focus at all. It depends on `mounted` now.

aria-modal was a claim with no mechanism behind it — portalled to the body,
the app tree is a sibling of the dialog and a screen reader's virtual cursor
still browsed the page underneath. The body's other children are marked
aria-hidden while any dialog is open, refcounted alongside the scroll lock.
This does mean a toast raised while a dialog is open is not announced, which
is the correct trade for a modal: ConfirmDialog shows its own errors inline.
2026-08-10 09:54:32 +01:00
mrhid6 0cfaf6670c fix(web): address review of the dialog, toast and async primitives
Two of these were real defects in the previous two commits.

friendlyMessage discarded exactly the messages it claimed to keep: the
"is this a bare reason phrase" test was a shape regex, and "Default steps
cannot be edited" has the same shape as "Not Found". It is an exact-match set
of reason phrases now.

Modal depended on onKeyDown, which is rebuilt whenever onClose changes
identity — and onClose is an inline arrow at every call site, so any parent
re-render (a 30s poll, a mutation flipping to pending) tore the effect down
and rebuilt it: cleanup restored focus to the trigger, setup then moved it to
the top of the dialog, mid-typing. onClose is held in a ref and the effect is
keyed on `open` alone.

Also in Modal: initial focus takes the first control in the body rather than
the panel, since the header comes first in DOM order and every dialog was
opening on its own dismiss button; the Tab trap pulls focus back when it has
escaped the panel entirely rather than only handling the two ends; the scroll
lock is refcounted, because per-instance save/restore released the page when
an outer dialog unmounted under an open inner one; and the whole thing is
portalled to the body so a nested confirm is not clipped by its parent's
overflow box.

The fleet's filtered-empty state keyed on the search alone, so a tag filter
matching nothing told a customer with a full fleet to add their first server.

Remaining: pending mutation errors are reset when a confirm dialog closes, so
one member's failure no longer greets the next; ConfirmDialog clears typed
confirmation when the target changes, not only when it reopens; deleting a
workflow closes its dialogs before navigating rather than carrying a scroll
lock onto the next page; toasts split into a polite and an assertive region,
since one polite wrapper demotes the role="alert" children inside it; and a
custom skeleton gets a live "Loading" beside it, having been aria-hidden with
nothing else to announce.
2026-08-10 09:44:29 +01:00
mrhid6 4d67341ba5 feat(web): fleet search and sort, shared empty/error states, no background polling
The fleet list had a tag filter and nothing else: no search, no sort, and an
unbounded list. Searching hostname/address/OS and sorting by hostname, status
or last seen are all client-side, since the browser already holds the fleet
the page just fetched. Sorting by status orders by how much attention each
state wants rather than alphabetically, which is the only reason to sort by it.

The filtered count is shown beside the total so a search does not read as the
fleet having shrunk, and "no results" is a distinct empty state from "no
servers", with a way back out of the search.

refetchIntervalInBackground defaults to false on the query client. Polling
pages kept refetching in a hidden tab — the fleet list pulls inventory blobs
every 30s — so a console left open in a background tab polled until its
session expired. It belongs in the defaults because the argument is identical
on every polling page.
2026-08-10 09:35:39 +01:00
mrhid6 1fa9160c59 fix(web,adminsite): accessible dialogs, real confirmations, shared async UI
Four correctness/accessibility defects and the destructive-action flow.

- Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg",
  a find/replace of "org" that landed inside a URL. Button also grows an href
  form, because <Link><Button> nested a button inside an anchor at nineteen
  call sites: invalid markup, two tab stops, and Enter firing only the anchor.

- Fleet status was four meanings carried by hue with the distinction living in
  a title attribute, which touch never shows and screen readers need not
  announce. It now carries a text label and an accessible name, which is the
  one rule the design system states outright.

- Modal had no focus management at all: no trap, no initial focus, no restore,
  no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so
  a stack decides which panel owns Escape and Tab.

- Seven destructive actions went through window.confirm(). ConfirmDialog
  replaces them and can say what is about to happen; deleting a secret group,
  a shared base step or a workflow now requires typing the name, since those
  have no undo and a wide blast radius. adminsite keeps its own inline idiom
  rather than importing a dialog system it does not have.

Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and
friendlyMessage, replacing per-page loading ternaries and raw
(error as Error).message text. Wired here only where a call site was already
being edited; the remaining pages follow.
2026-08-10 09:25:53 +01:00
37 changed files with 2157 additions and 566 deletions
+34 -10
View File
@@ -6,7 +6,8 @@ import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { formatDate } from "@/lib/format";
import { TermSpark } from "@/components/TermBar";
import { formatDate, licenceState } from "@/lib/format";
export default function BillingPage() {
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
@@ -22,6 +23,21 @@ export default function BillingPage() {
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
/*
* A subscription reports when the period ends but not when it began, so the
* start is derived from the term. Only the two terms we actually sell are
* handled — anything else returns null and the row falls back to the date
* alone, because a bar drawn from a guessed span is worse than no bar.
*/
const periodStart = (end: string, term: string): string | null => {
const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0;
if (!months) return null;
const d = new Date(end);
if (Number.isNaN(d.getTime())) return null;
d.setMonth(d.getMonth() - months);
return d.toISOString();
};
return (
<div className="grid gap-6">
<PageHeader
@@ -71,15 +87,23 @@ export default function BillingPage() {
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
</tr>
))}
{rows.map((s) => {
const start = periodStart(s.current_period_end, s.term);
return (
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3">
<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>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -9,6 +9,7 @@ import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { TermBar } from "@/components/TermBar";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
@@ -136,6 +137,35 @@ export default function InstancePage() {
</>
}
>
{/*
* 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.
*/}
{lic && (
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-[0.95rem] font-bold">Licence</h2>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
{lic.tier.replace("_", " ")} · {cloud ? "Cloud" : "Self-hosted"}
</span>
</div>
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
{state === "warn" && (
<p className="rounded border border-rule border-l-[3px] border-l-warn bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.
</p>
)}
{state === "expired" && (
<p className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed.
</p>
)}
</section>
)}
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
+104 -27
View File
@@ -34,21 +34,55 @@ export default function OverviewPage() {
const live = data.instances.filter((i) => i.status !== "deleted");
// Work the customer has to do, gathered across every instance. This is the
// only account-level view of it each record only knows about itself.
/*
* Work the customer has to do, gathered across every instance. This is the
* only account-level view of it — each record only knows about itself.
*
* Each item carries the way out of it. It used to be a list of sentences in
* the rail, which told someone their licence was expiring and then made
* them go and find the instance that owned it; the fix for every one of
* these is one click, so the click belongs on the row.
*/
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "warn")
const name = i.name || "An instance";
if (state === "none")
return [
{
id: i.instance_id,
text: `${i.name} expires`,
note: `${daysRemaining(lic!.expires_at)}d`,
text: `${name} is waiting for an install ID`,
note: "You have paid for this. Paste the UUID from the install to get your licence.",
href: i.status === "awaiting_link" ? `/instances/link?claim=${i.instance_id}` : "/purchase",
action: i.status === "awaiting_link" ? "Link install" : "Get a licence",
tag: "",
},
];
if (state === "expired")
return [
{
id: i.instance_id,
text: `${name} has expired`,
note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: "now",
},
];
if (state === "warn") {
const d = daysRemaining(lic!.expires_at);
return [
{
id: i.instance_id,
text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`,
note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: `${d}d`,
},
];
}
return [];
});
@@ -71,32 +105,43 @@ export default function OverviewPage() {
/>
{live.length === 0 ? (
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
</p>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/purchase">Buy a plan</LinkButton>
/*
* An empty screen is an invitation to act, and the two ways in
* are genuinely different products — we host it, or you do. One
* button and a paragraph explaining the other option made the
* self-hosted path read as an afterthought, which it is not.
*/
<div className="grid gap-4 rounded border border-rule bg-panel p-6">
<div className="grid gap-2">
<h2 className="text-xl">No instances yet</h2>
<p className="max-w-[52ch] text-ink-2">An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Cloud</h3>
<p className="text-[0.82rem] text-ink-2">We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.</p>
<div className="pt-1">
<LinkButton href="/purchase">Create a cloud instance</LinkButton>
</div>
</div>
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Self-hosted</h3>
<p className="text-[0.82rem] text-ink-2">You host it. Get the licence here, then paste your install&rsquo;s ID to bind it.</p>
<div className="pt-1">
<LinkButton variant="line" href="/purchase">
License my own install
</LinkButton>
</div>
</div>
</div>
<p className="text-[0.78rem] text-ink-3">The Free tier covers 5 servers and needs no card.</p>
</div>
) : (
<PageFrame
aside={
<>
{attention.length > 0 && (
<RailCard title="Needs you" count={attention.length}>
<ul className="grid gap-2">
{attention.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
<span>{a.text}</span>
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
</li>
))}
</ul>
</RailCard>
)}
<RailCard title="Your team" count={people.data?.length}>
<ul className="grid gap-2">
{(people.data ?? []).slice(0, 5).map((p) => (
@@ -147,6 +192,38 @@ export default function OverviewPage() {
</>
}
>
{/*
* First in the main column, not in the rail. This is the
* reason the page is open; the rail is for things that are
* merely true. It disappears entirely when there is nothing
* in it rather than saying "all clear", which is a line
* nobody needs to read twice a week.
*/}
{attention.length > 0 && (
<section className="grid overflow-hidden rounded border border-rule bg-panel">
<div className="flex items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
<h2 className="text-[0.95rem] font-bold">Needs you</h2>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
{attention.length} {attention.length === 1 ? "item" : "items"}
</span>
</div>
<ul className="grid">
{attention.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3 last:border-b-0">
<div className="grid min-w-0 gap-0.5">
<span className="flex items-center gap-2 text-[0.9rem] font-semibold">
{a.text}
{a.tag && <span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-warn">{a.tag}</span>}
</span>
<span className="text-[0.8rem] text-ink-3">{a.note}</span>
</div>
<LinkButton href={a.href}>{a.action}</LinkButton>
</li>
))}
</ul>
</section>
)}
{live.map((i, n) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
+49 -18
View File
@@ -24,6 +24,7 @@ export function InvitePanel() {
const [email, setEmail] = useState("");
const [role, setRole] = useState<AccountRole>("member");
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
@@ -46,8 +47,14 @@ export function InvitePanel() {
});
const remove = useMutation({
mutationFn: (id: string) => api.removeAccountUser(id),
onSuccess: refresh,
onError: fail,
onSuccess: () => {
setConfirming(null);
refresh();
},
onError: (e) => {
setConfirming(null);
fail(e);
},
});
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
@@ -175,22 +182,46 @@ export function InvitePanel() {
: "Invitation pending"}
</td>
<td className="px-4 py-3 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
)
)
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
{canManage &&
!isSelf &&
/*
* Inline rather than window.confirm(): removing
* someone here revokes them from every instance
* on the account, which is more than the word
* "Remove" beside one row implies, and the
* browser dialog cannot show the consequence
* where the eye already is.
*/
(confirming === u.user_id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2">
<span className="text-[0.82rem] text-ink-2">
Removes access to every instance.
</span>
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline disabled:opacity-50"
disabled={remove.isPending}
onClick={() => remove.mutate(u.user_id)}
>
{remove.isPending ? "Removing…" : "Remove"}
</button>
<button
type="button"
className="text-[0.82rem] text-ink-2 underline"
onClick={() => setConfirming(null)}
>
Keep
</button>
</span>
) : (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => setConfirming(u.user_id)}
>
Remove<span className="sr-only"> {u.email}</span>
</button>
))}
</td>
</tr>
);
@@ -8,6 +8,8 @@ import clsx from "clsx";
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 { licenceState } from "@/lib/format";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
@@ -32,6 +34,7 @@ export default function StaffInstancePage() {
if (isLoading || !data) return <p className="text-ink-3">Loading</p>;
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
const current = data.licenses.find((l) => !l.superseded_by);
return (
<div className="grid gap-8">
@@ -59,6 +62,21 @@ export default function StaffInstancePage() {
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
</div>
{/*
* The live licence is the one nothing has superseded, which is the
* record's own statement of the fact — not its position in the
* array, which is the server's ordering and not a guarantee.
*/}
{current && (
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-xl">Current licence</h2>
<span className="font-mono text-[0.72rem] tabular-nums text-ink-3">{current.license_id}</span>
</div>
<TermBar issuedAt={current.issued_at} expiresAt={current.expires_at} state={licenceState(current.expires_at, true)} className="max-w-xl" />
</section>
)}
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
<Ledger licenses={data.licenses} />
+14 -1
View File
@@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { formatDate, licenceState } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { TermSpark } from "@/components/TermBar";
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
@@ -60,6 +61,7 @@ export default function LicensesPage() {
<th className="px-4 py-2.5">Instance</th>
<th className="px-4 py-2.5">Tier</th>
<th className="px-4 py-2.5">Reason</th>
<th className="px-4 py-2.5">Term</th>
<th className="px-4 py-2.5">Expires</th>
<th className="px-4 py-2.5">State</th>
</tr>
@@ -83,6 +85,17 @@ export default function LicensesPage() {
</td>
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
{/* 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. */}
<td className="px-4 py-3">
{l.superseded_by ? (
<span className="font-mono text-[0.72rem] text-ink-3">superseded</span>
) : (
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
)}
</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(l.expires_at)}
</td>
+48 -43
View File
@@ -1,12 +1,12 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
function AcceptForm() {
const token = useSearchParams().get("token") ?? "";
@@ -17,60 +17,65 @@ function AcceptForm() {
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
if (!token)
return (
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the invitation exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (done)
return (
<div className="grid gap-3">
<h1 className="text-3xl">You&apos;re in</h1>
<p className="text-ink-2">Sign in with your email address and new password.</p>
<Link href="/login" className="font-semibold text-accent underline">
Sign in
</Link>
</div>
<AuthMessage
title="You're in"
body="Sign in with your email address and the password you just set."
action={{ href: "/login", label: "Sign in" }}
/>
);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
<AuthShell
title="Choose a password"
lede="You have been invited to a Vantage HQ account."
footnote="Nobody who invited you can see this password, and it is never sent to them."
>
<h1 className="text-3xl">Choose a password</h1>
<p className="text-ink-2">
This password signs you into Vantage HQ and into every instance you are given
access to. Nobody who invited you can see it.
</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12}>
{accept.isPending ? "Setting…" : "Set password"}
</Button>
</form>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
>
<p className="text-[0.86rem] text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to.</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12} className="w-full justify-center">
{accept.isPending ? "Setting…" : "Set password and continue"}
</Button>
</form>
</AuthShell>
);
}
export default function AcceptInvitePage() {
return (
<main className="mx-auto max-w-rail px-5 py-16">
<Suspense fallback={<p className="text-ink-3">Loading</p>}>
<AcceptForm />
</Suspense>
</main>
<Suspense fallback={<AuthShell title="Choose a password" lede="One moment." />}>
<AcceptForm />
</Suspense>
);
}
+46 -71
View File
@@ -6,6 +6,7 @@ import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
@@ -38,82 +39,56 @@ export default function LoginPage() {
if (offline)
return (
<Main>
<AuthShell title="Sign in">
<NotConnectedPanel url={API_BASE} />
</Main>
</AuthShell>
);
return (
<Main>
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
<div className="mb-7 flex flex-col items-center gap-2 text-center">
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">
HQ
</span>
</span>
<h1 className="text-[1.16rem]">Sign in</h1>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Licences · instances · billing
</p>
</div>
<AuthShell
title="Sign in"
lede="Licences, instances and billing for your account."
/*
* HQ and the Vantage console are separate sign-ins on separate
* hosts, and the two get confused — someone lands here with their
* console password and reads the generic failure as a broken
* account. Saying which door this is costs one line.
*/
footnote="This is the portal for your licence and billing. Your servers are managed inside your Vantage instance, which signs in separately."
>
<form onSubmit={submit} className="grid gap-4">
<Field label="Email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input type="checkbox" checked={staff} onChange={(e) => setStaff(e.target.checked)} className="accent-[var(--accent)]" />
I work at Vantage
</label>
<Button type="submit" disabled={busy} className="w-full justify-center">
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
<form onSubmit={submit} className="grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input
type="checkbox"
checked={staff}
onChange={(e) => setStaff(e.target.checked)}
className="accent-[var(--accent)]"
/>
I work at Vantage
</label>
<Button type="submit" disabled={busy} className="w-full justify-center">
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
{SITE_URL && (
<>
<div className="h-px bg-rule-soft" />
{SITE_URL && (
<>
<div className="my-5 h-px bg-rule-soft" />
{/* Signup lives on the marketing site's /start, not here. */}
<p className="text-center text-[0.82rem] text-ink-3">
No account?{" "}
<a href={`${SITE_URL}/start`} className="text-accent underline">
Create one
</a>
</p>
</>
)}
</div>
</Main>
);
}
function Main({ children }: { children: React.ReactNode }) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{children}
</main>
{/* Signup lives on the marketing site's /start, not here. */}
<p className="text-center text-[0.82rem] text-ink-3">
No account?{" "}
<a href={`${SITE_URL}/start`} className="text-accent underline">
Create one
</a>
</p>
</>
)}
</AuthShell>
);
}
+38 -27
View File
@@ -2,9 +2,11 @@
import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense, useEffect } from "react";
import { api } from "@/lib/api";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
function Verify() {
const router = useRouter();
@@ -25,50 +27,59 @@ function Verify() {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
}
}, [needsPassword, token, router]);
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
if (needsPassword) return <AuthShell title="One moment…" lede="Taking you to set a password." />;
if (!token)
return (
<Message
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the email exactly as sent."
body="It is missing its token. Use the link in the email exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (isLoading) return <Message title="Verifying…" body="One moment." />;
if (isLoading) return <AuthShell title="Verifying…" lede="One moment." />;
if (error || !data?.verified)
return (
<Message
<AuthMessage
title="That link is invalid or has expired"
body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
body="Links last 24 hours and can only be used once. Signing in will send you a fresh one."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Email verified</h1>
<p className="text-ink-2">Your account is ready.</p>
<Link href="/login" className="justify-self-start text-accent underline">
<AuthShell
title="Email verified"
lede="Your account is ready."
footnote={
SITE_URL ? (
<>
New to Vantage? The{" "}
<a href={`${SITE_URL}/docs`} className="text-accent underline">
getting started guide
</a>{" "}
walks through your first instance.
</>
) : undefined
}
>
<p className="text-[0.9rem] text-ink-2">Sign in to create your first instance. The Free tier covers 5 servers and needs no card.</p>
<a
href="/login"
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
Sign in
</Link>
</div>
);
}
function Message({ title, body }: { title: string; body: string }) {
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">{title}</h1>
<p className="text-ink-2">{body}</p>
</div>
</a>
</AuthShell>
);
}
export default function VerifyPage() {
return (
<main className="mx-auto max-w-rail px-5 py-12">
<Suspense fallback={null}>
<Verify />
</Suspense>
</main>
<Suspense fallback={<AuthShell title="Verifying…" lede="One moment." />}>
<Verify />
</Suspense>
);
}
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
/*
* The frame for every screen you can reach without a session: sign in, email
* verification, and accepting an invitation.
*
* These three had drifted into three different layouts. Sign in was a centred
* 26rem card with the lockup above it; verify and accept-invite were bare
* left-aligned text on the full 1200px rail, with no masthead, no panel and no
* brand anywhere on the page. Those two are the first screens a new customer
* ever sees — arriving from an email, on a domain they have not visited before
* — and they were the two that did not say whose product this is.
*
* There is no AppBar here on purpose: it carries navigation and an account
* menu, and none of it works without a session.
*/
export function AuthShell({
title,
lede,
children,
footnote,
}: {
title: string;
lede?: React.ReactNode;
children?: React.ReactNode;
/** Sits outside the panel: orientation, not part of the task. */
footnote?: React.ReactNode;
}) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
<div className="mb-7 flex flex-col items-center gap-2 text-center">
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
</span>
<h1 className="text-[1.16rem]">{title}</h1>
{lede && <p className="text-[0.86rem] text-ink-2">{lede}</p>}
</div>
{children && <div className="grid gap-4 rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">{children}</div>}
{footnote && <div className="mt-5 text-center text-[0.8rem] text-ink-3">{footnote}</div>}
</main>
);
}
/*
* A terminal state — verified, expired, already used, invalid. Always says what
* happened and what to do next: a dead end that only reports the failure leaves
* someone holding an email they cannot act on.
*/
export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) {
return (
<AuthShell title={title}>
<p className="text-[0.9rem] text-ink-2">{body}</p>
{action && (
<Link
href={action.href}
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
{action.label}
</Link>
)}
</AuthShell>
);
}
+5 -17
View File
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { TermBar } from "./TermBar";
import { Button, LinkButton } from "./Button";
const STRIPE = {
@@ -35,7 +36,6 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
@@ -102,22 +102,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
</div>
</div>
{license && state !== "expired" && (
<div className="grid max-w-md gap-1.5">
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
<div className="h-1 overflow-hidden rounded-sm bg-rule-soft">
<div
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
style={{
width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%`,
}}
/>
</div>
</div>
)}
{/* The term is drawn for an expired licence too. The old bar hid
itself once it lapsed, which removed the measurement at exactly
the moment it started mattering. */}
{license && <TermBar issuedAt={license.issued_at} expiresAt={license.expires_at} state={state} className="max-w-md" />}
{state === "expired" && (
<div className="grid gap-1">
+44 -13
View File
@@ -18,6 +18,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const [selected, setSelected] = useState("");
const [role, setRole] = useState<InstanceRole>("member");
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const members = useQuery({
queryKey: ["members", instanceId],
@@ -44,8 +45,14 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
});
const revoke = useMutation({
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
onSuccess: refresh,
onError: fail,
onSuccess: () => {
setConfirming(null);
refresh();
},
onError: (e) => {
setConfirming(null);
fail(e);
},
});
const myRole = session?.account_role;
@@ -89,17 +96,41 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
) : (
<span className="font-mono text-[0.82rem]">{m.role}</span>
)}
{canManage && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
}}
>
Remove
</button>
)}
{canManage &&
/*
* Confirming inline rather than through
* window.confirm(), and in the row itself
* rather than a dialog: this is the panel's own
* idiom, the same one ConfirmPlanChange uses,
* and it can say what revoking actually does.
*/
(confirming === m.customer_user_id ? (
<span className="flex items-center gap-2">
<span className="text-[0.82rem] text-ink-2">Revoke access?</span>
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline disabled:opacity-50"
disabled={revoke.isPending}
onClick={() => revoke.mutate(m.customer_user_id)}
>
{revoke.isPending ? "Removing…" : "Remove"}
</button>
<button type="button" className="text-[0.82rem] text-ink-2 underline" onClick={() => setConfirming(null)}>
Keep
</button>
</span>
) : (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
setError(null);
setConfirming(m.customer_user_id);
}}
>
Remove<span className="sr-only"> {m.email}</span>
</button>
))}
</span>
</li>
))}
+91
View File
@@ -0,0 +1,91 @@
import clsx from "clsx";
/*
* The surface every screen is built from.
*
* Before this there were four panel treatments in the app: `rounded border
* border-rule bg-panel p-5` with an `<h2 className="text-xl">`, the same thing
* with `text-[0.95rem] font-medium`, a bare `<section className="space-y-2">`
* with no border at all, and a table wrapper that was a panel in everything but
* name. They were all trying to be the same object.
*
* The header is title-left, meta-right. Meta is the keyed idiom — mono, small,
* tracked, dimmed — because it is always a count, a scope or an identifier,
* never prose.
*/
export function Panel({
title,
meta,
actions,
tone,
children,
bodyless,
className,
}: {
title?: string;
meta?: React.ReactNode;
actions?: React.ReactNode;
/** Draws the panel's own border in a state colour. For a panel that IS the warning. */
tone?: "warn" | "expired";
children: React.ReactNode;
/** Skip the padded body — for a panel whose content is a full-bleed table. */
bodyless?: boolean;
className?: string;
}) {
const head = title || meta || actions;
return (
<section
className={clsx(
"grid overflow-hidden rounded border bg-panel",
tone === "warn" ? "border-warn" : tone === "expired" ? "border-expired" : "border-rule",
className,
)}
>
{head && (
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
{title && <h2 className="text-[0.95rem] font-bold tracking-[-0.01em]">{title}</h2>}
<div className="flex items-center gap-3">
{meta && <span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{meta}</span>}
{actions}
</div>
</header>
)}
{bodyless ? children : <div className="grid gap-3.5 p-4">{children}</div>}
</section>
);
}
/*
* An aside that is part of the argument rather than beside it: the consequence
* of the action on screen, or the constraint the reader is about to hit. The
* left rule carries the tone, so the note reads as annotation and never as a
* second panel competing with the one it sits in.
*/
export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
return (
<p
className={clsx(
"rounded border border-rule border-l-[3px] bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2",
tone === "warn" ? "border-l-warn" : tone === "expired" ? "border-l-expired" : "border-l-accent",
)}
>
{children}
</p>
);
}
/*
* An empty screen is an invitation to act. Every one of these says what the
* thing is before offering to make one — "No licences match those filters" on
* its own tells someone the filter worked, not what to do about it.
*/
export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="grid justify-items-center gap-2 px-5 py-12 text-center">
<p className="text-[1rem] font-bold">{title}</p>
{body && <p className="max-w-[46ch] text-[0.86rem] text-ink-2">{body}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
+9 -4
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import clsx from "clsx";
import type { ReactNode } from "react";
const TONE = {
expired: "border-l-expired text-expired",
@@ -16,7 +17,11 @@ export function Queue({
title: string;
count: number;
tone: keyof typeof TONE;
items: { label: string; href: string; meta: string }[];
/* `meta` is a node rather than a string so a queue about time can carry the
* term measurement itself. A tier name told the reader what the instance
* was; the queue is sorted by how soon it lapses, and that was the one
* figure the row did not show. */
items: { label: string; href: string; meta: ReactNode }[];
}) {
return (
<section
@@ -38,12 +43,12 @@ export function Queue({
{items.map((i) => (
<li
key={i.href}
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
className="flex items-center justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
>
<Link href={i.href} className="text-accent underline">
<Link href={i.href} className="truncate text-accent underline">
{i.label}
</Link>
<span className="tabular-nums">{i.meta}</span>
<span className="shrink-0 tabular-nums">{i.meta}</span>
</li>
))}
</ul>
+83
View File
@@ -0,0 +1,83 @@
import clsx from "clsx";
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
/*
* One table treatment for the whole console.
*
* There were four: billing, licences, accounts and catalogue each wrote their
* own thead, and they disagreed about the head's type size, its tracking,
* whether it sat on --panel-2, and whether numbers were tabular. Catalogue's
* heads were sentence-case body text. A registry whose columns are set four
* ways does not read as one product.
*
* The head is the keyed idiom — mono, small, uppercase, widely tracked — which
* is what a column head is: a key above a value, exactly as the record line is
* a key beside one.
*/
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table className={clsx("w-full border-collapse text-left text-[0.86rem]", className)} {...props}>
{children}
</table>
</div>
);
}
export function THead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-rule", className)} {...props}>
{children}
</thead>
);
}
export function TBody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={className} {...props}>
{children}
</tbody>
);
}
export function TR({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr className={clsx("border-b border-rule-soft last:border-0 hover:bg-panel-2", className)} {...props}>
{children}
</tr>
);
}
interface CellProps {
/** Right-aligns the cell. For quantities and money, which read down the column. */
numeric?: boolean;
}
export function TH({ className, numeric, children, ...props }: ThHTMLAttributes<HTMLTableCellElement> & CellProps) {
return (
<th
className={clsx(
"whitespace-nowrap px-4 py-2.5 font-mono text-[0.62rem] font-normal uppercase tracking-[0.13em] text-ink-3",
numeric && "text-right",
className,
)}
{...props}
>
{children}
</th>
);
}
export function TD({ className, numeric, children, ...props }: TdHTMLAttributes<HTMLTableCellElement> & CellProps) {
return (
<td className={clsx("px-4 py-3 align-middle", numeric && "text-right tabular-nums", className)} {...props}>
{children}
</td>
);
}
/** The secondary line under a cell's main value — an ID, a deployment, a date. */
export function Sub({ children }: { children: React.ReactNode }) {
return <div className="text-[0.78rem] text-ink-3">{children}</div>;
}
+99
View File
@@ -0,0 +1,99 @@
import clsx from "clsx";
import { daysRemaining, formatDate, type LicenceState } from "@/lib/format";
/*
* A licence's life as a measured line: issued at the left, expiry at the right,
* today as a notch, the part you have not got yet hatched.
*
* This replaces a 1px progress rule and a "Renews 19 Aug 2026" caption. The
* date is still there, but a date alone makes the reader do the arithmetic that
* is the only question this product is ever asked — when does this stop
* working. The bar answers it before they read a word.
*
* The fill takes the state's colour, so the same vocabulary the pill uses
* carries through. State is never colour alone here either: the remaining span
* is hatched rather than tinted, the notch is a hard edge, and the days-left
* figure is written out.
*/
const TONE: Record<LicenceState, string> = {
valid: "text-valid",
warn: "text-warn",
expired: "text-expired",
none: "text-accent",
};
function span(issuedAt: string, expiresAt: string) {
const start = new Date(issuedAt).getTime();
const end = new Date(expiresAt).getTime();
const total = end - start;
// A licence issued and expiring at the same instant is not a real record,
// but it must not divide by zero on the way to being rendered.
if (!Number.isFinite(total) || total <= 0) return 100;
const elapsed = Date.now() - start;
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
export function TermBar({
issuedAt,
expiresAt,
state,
className,
}: {
issuedAt: string;
expiresAt: string;
state: LicenceState;
className?: string;
}) {
const pct = span(issuedAt, expiresAt);
const days = daysRemaining(expiresAt);
const expired = days <= 0;
const remaining = expired
? `Expired ${Math.abs(days)} ${Math.abs(days) === 1 ? "day" : "days"} ago`
: `${days} ${days === 1 ? "day" : "days"} left`;
return (
<div className={clsx("grid gap-2", TONE[state], className)}>
<div className="relative h-[26px] overflow-hidden rounded-sm border border-rule bg-panel-2">
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.16]" style={{ width: `${pct}%` }} />
{/* The span still to come, drawn as absence rather than as a
second colour: it is the thing being bought. */}
<span
className="absolute inset-y-0 right-0 bg-[repeating-linear-gradient(45deg,transparent_0_5px,var(--rule-soft)_5px_6px)]"
style={{ width: `${100 - pct}%` }}
/>
<span className="absolute -inset-y-px w-0.5 bg-current" style={{ left: `${pct}%` }} />
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Issued {formatDate(issuedAt)}</span>
<span className="font-mono text-[0.74rem] font-bold tabular-nums">{remaining}</span>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
</div>
</div>
);
}
/*
* The same measurement at 56px, for a row in a ledger. Licences, Billing and
* the staff expiry queue are all lists of terms, and a list of dates cannot be
* scanned for "which of these is nearly out" — a list of bars can.
*
* It carries a text alternative rather than a title: the row it sits in is
* being read, not hovered.
*/
export function TermSpark({ issuedAt, expiresAt, state }: { issuedAt: string; expiresAt: string; state: LicenceState }) {
const pct = span(issuedAt, expiresAt);
const days = daysRemaining(expiresAt);
return (
<span className={clsx("inline-flex items-center gap-2", TONE[state])}>
<span aria-hidden className="relative inline-block h-[9px] w-14 overflow-hidden rounded-sm border border-rule bg-panel-2 align-middle">
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.45]" style={{ width: `${pct}%` }} />
<span className="absolute inset-y-0 w-px bg-current" style={{ left: `${pct}%` }} />
</span>
<span className="font-mono text-[0.72rem] tabular-nums">{days <= 0 ? `${Math.abs(days)}d` : `${days}d`}</span>
</span>
);
}
+17 -15
View File
@@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { Card } from "@/components/ui";
import { AsyncBoundary, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const EVENT_LABELS: Record<string, string> = {
@@ -42,7 +42,7 @@ function EventTypeBadge({ type }: { type: string }) {
}
export default function AuditPage() {
const { data: events, isLoading, error } = useQuery({
const { data: events, isLoading, error, refetch } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
@@ -58,13 +58,19 @@ export default function AuditPage() {
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load audit log.</div>
) : events && events.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!events || events.length === 0}
empty={
<EmptyState
title="No audit events recorded yet."
description="Every mutating action — a key assigned, a workflow run, a member added — is written here as it happens."
/>
}
>
<Table>
<Thead>
<Tr>
@@ -75,7 +81,7 @@ export default function AuditPage() {
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
{events?.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td label="Time">
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
@@ -95,11 +101,7 @@ export default function AuditPage() {
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
+31 -31
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, Key } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function UploadKeyModal({ onClose }: { onClose: () => void }) {
@@ -100,6 +100,7 @@ export default function KeysPage() {
data: keys,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["keys"],
queryFn: api.listKeys,
@@ -125,13 +126,29 @@ export default function KeysPage() {
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load keys. Is the backend running?</div>
) : keys && keys.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!keys || keys.length === 0}
empty={
<EmptyState
title="No SSH keys yet."
description="Upload a public key, or have an agent generate one on a server, then assign it to the servers that should accept it."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
}
action={{ label: "Upload your first key", onClick: () => setShowUpload(true) }}
/>
}
>
<Table>
<Thead>
<Tr>
@@ -144,7 +161,7 @@ export default function KeysPage() {
</Tr>
</Thead>
<Tbody>
{keys.map((key: Key) => (
{keys?.map((key: Key) => (
<Tr key={key.key_id}>
<Td label="Label">
<span className="font-medium text-text-primary">{key.label}</span>
@@ -164,33 +181,16 @@ export default function KeysPage() {
<span className="text-text-secondary text-xs">{new Date(key.created_at).toLocaleDateString()}</span>
</Td>
<Td>
<Link href={`/keys/${key.key_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
<Button href={`/keys/${key.key_id}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{key.label}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<p className="text-text-secondary">No SSH keys yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowUpload(true)}>
Upload your first key
</Button>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
+6 -8
View File
@@ -283,9 +283,9 @@ export default function MonitorDetailPage() {
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Link href={`/monitors/${monitorId}/edit`}>
<Button variant="secondary">Edit</Button>
</Link>
<Button href={`/monitors/${monitorId}/edit`} variant="secondary">
Edit
</Button>
<Button variant="secondary" loading={isToggling} onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Pause checks" : "Resume checks"}
</Button>
@@ -384,11 +384,9 @@ export default function MonitorDetailPage() {
))}
</dl>
)}
<Link href="/settings/notifications">
<Button variant="secondary" size="sm" className="mt-4">
Manage channels
</Button>
</Link>
<Button href="/settings/notifications" variant="secondary" size="sm" className="mt-4">
Manage channels
</Button>
</Panel>
<div className="rounded-lg border border-border bg-surface px-5 py-4">
+9 -11
View File
@@ -138,12 +138,12 @@ export default function MonitorsPage() {
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Monitors</h1>
</div>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notification channels</Button>
</Link>
<Link href="/monitors/new">
<Button variant="primary">New monitor</Button>
</Link>
<Button href="/settings/notifications" variant="secondary">
Notification channels
</Button>
<Button href="/monitors/new" variant="primary">
New monitor
</Button>
</div>
</div>
@@ -158,11 +158,9 @@ export default function MonitorsPage() {
Add a check and Vantage records uptime and response time on your interval, opens an incident when it fails, and tells the
channels you pick.
</p>
<Link href="/monitors/new">
<Button variant="primary" className="mt-4">
Add your first check
</Button>
</Link>
<Button href="/monitors/new" variant="primary" className="mt-4">
Add your first check
</Button>
</div>
) : (
<>
+95 -29
View File
@@ -5,7 +5,18 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Secret } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import {
AsyncBoundary,
Button,
Card,
CardHeader,
CardTitle,
ConfirmDialog,
EmptyState,
TableSkeleton,
friendlyMessage,
useToast,
} from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
@@ -116,17 +127,29 @@ spec:
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const toast = useToast();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [confirming, setConfirming] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
onError: toast.error,
});
const { mutate: remove, isPending: removing } = useMutation({
const {
mutate: remove,
isPending: removing,
error: removeError,
reset: resetRemove,
} = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setConfirming(false);
toast.success(`Deleted ${secret.key}.`);
},
});
async function copy() {
@@ -164,15 +187,34 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
onClick={() => setConfirming(true)}
>
Delete
Delete<span className="sr-only"> {secret.key}</span>
</Button>
</div>
<ConfirmDialog
open={confirming}
title="Delete key"
confirmLabel="Delete key"
loading={removing}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => {
resetRemove();
setConfirming(false);
}}
onConfirm={() => remove()}
body={
<>
<p>
<span className="font-mono text-text-primary">{secret.key}</span> will be removed from the{" "}
<span className="font-mono text-text-primary">{group}</span> group.
</p>
<p>Anything reading this key a workflow step, an External Secrets sync starts failing at its next run.</p>
</>
}
/>
</Td>
</Tr>
);
@@ -225,17 +267,24 @@ export default function SecretGroupPage() {
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const toast = useToast();
const [showYaml, setShowYaml] = useState(false);
const [confirmingGroup, setConfirmingGroup] = useState(false);
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
const {
mutate: deleteGroup,
isPending: deleting,
error: deleteError,
} = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
toast.success(`Deleted the ${group} group.`);
router.push("/secrets");
},
});
@@ -262,30 +311,49 @@ export default function SecretGroupPage() {
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
<Button variant="ghost" className="text-danger hover:text-danger" onClick={() => setConfirmingGroup(true)}>
Delete Group
</Button>
</div>
</div>
<ConfirmDialog
open={confirmingGroup}
title="Delete secret group"
confirmLabel="Delete group"
// No undo, and the blast radius is every consumer of the group
// rather than one key — so the name has to be typed.
requireTyped={group}
loading={deleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmingGroup(false)}
onConfirm={() => deleteGroup()}
body={
<>
<p>
This deletes <span className="font-mono text-text-primary">{group}</span> and all{" "}
{data ? `${data.secrets.length} of its keys` : "of its keys"}. The values cannot be recovered.
</p>
<p>
Every workflow step referencing this group, and any External Secrets sync reading{" "}
<span className="font-mono">/api/secrets/{group}/values</span>, fails at its next run.
</p>
</>
}
/>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!data || data.secrets.length === 0}
empty={<EmptyState title="This group has no keys yet." description="Add one above and it becomes available to workflow steps and External Secrets straight away." />}
>
<Table>
<Thead>
<Tr>
@@ -296,14 +364,12 @@ export default function SecretGroupPage() {
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
{data?.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</AsyncBoundary>
</Card>
</div>
</div>
+27 -28
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
@@ -93,7 +93,7 @@ function NewGroupModal({ onClose }: { onClose: () => void }) {
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
const { data: groups, isLoading, error, refetch } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
@@ -118,15 +118,25 @@ export default function SecretsPage() {
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!groups || groups.length === 0}
empty={
<EmptyState
title="No secret groups yet."
description="A group holds related key/value pairs, encrypted at rest, and is read by workflow steps and External Secrets."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
}
action={{ label: "Create your first group", onClick: () => setShowNew(true) }}
/>
}
>
<Table>
<Thead>
<Tr>
@@ -137,7 +147,7 @@ export default function SecretsPage() {
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
{groups?.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td label="Group">
<span className="font-mono font-medium text-text-primary">{g.group}</span>
@@ -153,27 +163,16 @@ export default function SecretsPage() {
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
<Button href={`/secrets/${encodeURIComponent(g.group)}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{g.group}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
+182 -58
View File
@@ -1,11 +1,10 @@
"use client";
import { Suspense } from "react";
import { Suspense, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { api, Server } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { AsyncBoundary, Button, Card, CenteredSpinner, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { TagChips } from "@/components/servers/TagChips";
import { TagFilterBar } from "@/components/servers/TagFilterBar";
@@ -13,6 +12,23 @@ import { TagFilterBar } from "@/components/servers/TagFilterBar";
type DotStatus = "offline" | "needs-update" | "has-package-updates" | "ok";
type SortKey = "hostname" | "status" | "last_seen";
const SORT_LABELS: Record<SortKey, string> = {
hostname: "Hostname",
status: "Status (worst first)",
last_seen: "Last seen (newest first)",
};
// Sorting by status means "show me what is wrong", so the order is by how much
// attention each state wants rather than alphabetical.
const STATUS_ORDER: Record<DotStatus, number> = {
offline: 0,
"needs-update": 1,
"has-package-updates": 2,
ok: 3,
};
function resolveStatus(server: Server, latestVersion: string | undefined): DotStatus {
if (server.status === "offline" || server.status === "pending") return "offline";
if (latestVersion && server.agent_version && server.agent_version !== latestVersion) return "needs-update";
@@ -37,10 +53,37 @@ const DOT_LABELS: Record<DotStatus, string> = {
ok: "OK",
};
const DOT_TEXT: Record<DotStatus, string> = {
offline: "text-danger",
"needs-update": "text-warning",
"has-package-updates": "text-accent",
ok: "text-success",
};
// Short forms for the desktop column, which is narrow. The full sentence is
// still the accessible name, so nothing is lost to a screen reader.
const DOT_SHORT: Record<DotStatus, string> = {
offline: "Offline",
"needs-update": "Agent stale",
"has-package-updates": "Updates",
ok: "OK",
};
/*
* The dot alone was the whole control: four meanings carried by hue, with the
* distinction living in a `title` a touch user never sees and a screen reader
* is not obliged to announce. This is the one rule the design system states
* outright — state never reads by colour alone — so the label is now part of
* the component rather than something each page remembers to add.
*/
function StatusDot({ status }: { status: DotStatus }) {
return (
<span title={DOT_LABELS[status]} className="flex items-center">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOT_CLASSES[status]}`} />
<span className={`inline-flex items-center gap-2 whitespace-nowrap ${DOT_TEXT[status]}`}>
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${DOT_CLASSES[status]}`} aria-hidden="true" />
<span className="font-mono text-[0.65rem] uppercase tracking-[0.08em]" aria-hidden="true">
{DOT_SHORT[status]}
</span>
<span className="sr-only">{DOT_LABELS[status]}</span>
</span>
);
}
@@ -82,7 +125,7 @@ function ServersPageBody() {
router.replace(qs ? `/servers?${qs}` : "/servers");
}
const { data: servers, isLoading, error } = useQuery({
const { data: servers, isLoading, error, refetch } = useQuery({
queryKey: ["servers", selected],
queryFn: () => api.listServers(selected),
refetchInterval: 30_000,
@@ -95,37 +138,130 @@ function ServersPageBody() {
});
const latestVersion = latestVersionData?.version;
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortKey>("hostname");
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
const matched = q
? (servers ?? []).filter((s) =>
[s.hostname, s.ip_address, s.os_info].some((field) => field?.toLowerCase().includes(q)),
)
: (servers ?? []);
// Sorted on a copy: the query cache's array is not ours to reorder.
return [...matched].sort((a, b) => {
switch (sort) {
case "status":
// Whatever needs attention first, which is the reason to sort by
// status at all.
return STATUS_ORDER[resolveStatus(a, latestVersion)] - STATUS_ORDER[resolveStatus(b, latestVersion)];
case "last_seen":
return new Date(b.last_seen ?? 0).getTime() - new Date(a.last_seen ?? 0).getTime();
default:
return a.hostname.localeCompare(b.hostname);
}
});
}, [servers, search, sort, latestVersion]);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Servers</h1>
<p className="mt-1 text-sm text-text-secondary">
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
<p className="mt-1 text-sm text-text-secondary" aria-live="polite">
{/* Showing the filtered count beside the total is what stops a
search reading as "the fleet shrank". */}
{visible.length === (servers?.length ?? 0)
? `${servers?.length ?? 0} registered server${servers?.length !== 1 ? "s" : ""}`
: `${visible.length} of ${servers?.length ?? 0} servers`}
</p>
</div>
<Link href="/servers/new">
<Button variant="primary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Server
</Button>
</Link>
<Button href="/servers/new" variant="primary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Server
</Button>
</div>
<TagFilterBar value={selected} onChange={setSelected} />
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<label htmlFor="fleet-search" className="sr-only">
Search servers by hostname, address or OS
</label>
<input
id="fleet-search"
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search hostname, address or OS…"
className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary placeholder-text-secondary/60 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<div className="flex items-center gap-2">
<label htmlFor="fleet-sort" className="font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary">
Sort
</label>
<select
id="fleet-sort"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
>
{(Object.keys(SORT_LABELS) as SortKey[]).map((k) => (
<option key={k} value={k}>
{SORT_LABELS[k]}
</option>
))}
</select>
</div>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load servers. Is the backend running?
</div>
) : servers && servers.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={6} />}
isEmpty={visible.length === 0}
empty={
/* Narrowed to nothing is not the same as owning nothing. Telling a
customer with a full fleet to "add your first server" because a
tag filter matched none of it is the version of this that gets
screenshotted. */
search.trim() || Object.keys(selected).length > 0 ? (
<EmptyState
title="No servers match those filters."
description={
Object.keys(selected).length > 0 && search.trim()
? "Nothing matches both the tag filter and the search."
: Object.keys(selected).length > 0
? "No server carries every tag selected above."
: "Clear the search to see the rest of the fleet."
}
action={
search.trim()
? { label: "Clear search", onClick: () => setSearch("") }
: { label: "Clear filters", onClick: () => setSelected({}) }
}
/>
) : (
<EmptyState
title="No servers registered yet."
description="Add one and Vantage gives you an install one-liner to run on it."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7" />
</svg>
}
action={{ label: "Add your first server", href: "/servers/new" }}
/>
)
}
>
<Table>
<Thead>
<Tr>
@@ -139,7 +275,7 @@ function ServersPageBody() {
</Tr>
</Thead>
<Tbody>
{servers.map((server: Server) => (
{visible.map((server: Server) => (
<Tr key={server.server_id}>
<Td label="Hostname">
<span className="font-medium text-text-primary">
@@ -161,38 +297,32 @@ function ServersPageBody() {
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td label="Last Seen">
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
{server.last_seen ? (
// "3d ago" is the useful reading; the exact instant is
// what someone correlating an incident needs, so it is on
// the element rather than gone.
<time
dateTime={server.last_seen}
title={new Date(server.last_seen).toLocaleString()}
className="text-text-secondary"
>
{formatLastSeen(server.last_seen)}
</time>
) : (
<span className="text-text-secondary">Never</span>
)}
</Td>
<Td>
<Link href={`/servers/${server.server_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
<Button href={`/servers/${server.server_id}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{server.hostname}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
</div>
<p className="text-text-secondary">No servers registered yet.</p>
<Link href="/servers/new">
<Button variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</Link>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
@@ -200,13 +330,7 @@ function ServersPageBody() {
export default function ServersPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center p-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
}
>
<Suspense fallback={<CenteredSpinner label="Loading fleet" />}>
<ServersPageBody />
</Suspense>
);
+6 -6
View File
@@ -229,12 +229,12 @@ export default function SettingsPage() {
<Group label="Monitoring">
<SectionCard title="Alerting" description="Alerts are delivered through notification channels, triggered by service monitors and by servers going offline." icon={<BellIcon />}>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage notification channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View monitors</Button>
</Link>
<Button href="/settings/notifications" variant="secondary">
Manage notification channels
</Button>
<Button href="/monitors" variant="ghost">
View monitors
</Button>
</div>
<p className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
+31 -24
View File
@@ -3,7 +3,7 @@
import { useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { EditStepModal } from "@/components/workflows/EditStepModal";
@@ -18,7 +18,7 @@ function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
export default function StepsPage() {
const qc = useQueryClient();
const { data: steps, isLoading, error: loadError } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: steps, isLoading, error: loadError, refetch } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage });
const [search, setSearch] = useState("");
@@ -134,13 +134,34 @@ export default function StepsPage() {
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : loadError ? (
<div className="py-20 text-center text-danger">Failed to load steps. Is the backend running?</div>
) : rows.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={loadError}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={rows.length === 0}
empty={
// Filtered to nothing and owning nothing are different
// situations and want different ways out.
steps && steps.length > 0 ? (
<EmptyState
title="No steps match that filter."
description="Clear the search or pick a different source to see the rest of the library."
/>
) : (
<EmptyState
title="No steps yet."
description="A step is one script with declared inputs and outputs. Workflows are built by composing them."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
</svg>
}
action={{ label: "Create your first step", onClick: openNew }}
/>
)
}
>
<Table>
<Thead>
<Tr>
@@ -204,21 +225,7 @@ export default function StepsPage() {
})}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">{steps && steps.length > 0 ? "No steps match that filter." : "No steps yet."}</p>
{(!steps || steps.length === 0) && (
<Button variant="primary" size="sm" className="mt-4" onClick={openNew}>
Create your first step
</Button>
)}
</div>
)}
</AsyncBoundary>
</Card>
<EditStepModal
+30 -32
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { resolveTargets } from "@/lib/targets";
@@ -18,6 +18,7 @@ export default function WorkflowsPage() {
data: workflows,
isLoading,
error: loadError,
refetch,
} = useQuery({
queryKey: ["workflows"],
queryFn: api.listWorkflows,
@@ -57,13 +58,25 @@ export default function WorkflowsPage() {
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : loadError ? (
<div className="py-20 text-center text-danger">Failed to load workflows. Is the backend running?</div>
) : workflows && workflows.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={loadError}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!workflows || workflows.length === 0}
empty={
<EmptyState
title="No workflows yet."
description="A workflow composes library steps and runs them across the servers you target, by name or by tag."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
action={{ label: "Create your first workflow", onClick: () => create(), loading: isPending }}
/>
}
>
<Table>
<Thead>
<Tr>
@@ -75,7 +88,7 @@ export default function WorkflowsPage() {
</Tr>
</Thead>
<Tbody>
{workflows.map((w: Workflow) => (
{workflows?.map((w: Workflow) => (
<Tr key={w.workflow_id}>
<Td label="Name">
<span className="font-medium text-text-primary">{w.name}</span>
@@ -113,35 +126,20 @@ export default function WorkflowsPage() {
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<Link href={`/workflows/${w.workflow_id}/runs`}>
<Button variant="ghost" size="sm">
Runs
</Button>
</Link>
<Link href={`/workflows/${w.workflow_id}`}>
<Button variant="ghost" size="sm">
Open
</Button>
</Link>
<Button href={`/workflows/${w.workflow_id}/runs`} variant="ghost" size="sm">
Runs<span className="sr-only"> for {w.name}</span>
</Button>
<Button href={`/workflows/${w.workflow_id}`} variant="ghost" size="sm">
Open <span aria-hidden="true"></span>
<span className="sr-only">{w.name}</span>
</Button>
</div>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p className="text-text-secondary">No workflows yet.</p>
<Button variant="primary" size="sm" className="mt-4" loading={isPending} onClick={() => create()}>
Create your first workflow
</Button>
</div>
)}
</AsyncBoundary>
</Card>
</div>
);
+4 -1
View File
@@ -2,9 +2,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
import { ToastProvider } from "@/components/ui/Toast";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
</QueryClientProvider>
);
}
+51 -9
View File
@@ -4,12 +4,16 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type InstanceUser, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
const ROLES: Role[] = ["owner", "admin", "member"];
/** The member a pending removal refers to, carried so the dialog and the
* confirmation message name a person rather than a user_id. */
type Member = { id: string; email: string };
function UsersIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -31,7 +35,9 @@ function roleVariant(role: Role) {
export function MembersCard() {
const queryClient = useQueryClient();
const { user } = useAuth();
const toast = useToast();
const [addOpen, setAddOpen] = useState(false);
const [removing, setRemoving] = useState<Member | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("member");
@@ -48,6 +54,7 @@ export function MembersCard() {
mutationFn: () => api.createInstanceUser({ email, password, role }),
onSuccess: () => {
invalidate();
toast.success(`Added ${email} as ${role}.`);
setAddOpen(false);
setEmail("");
setPassword("");
@@ -63,12 +70,24 @@ export function MembersCard() {
onError: invalidate,
});
const { mutate: removeUser, error: removeError } = useMutation({
mutationFn: (userId: string) => api.deleteInstanceUser(userId),
onSuccess: invalidate,
const {
mutate: removeUser,
isPending: isRemoving,
error: removeError,
reset: resetRemove,
} = useMutation({
mutationFn: (member: Member) => api.deleteInstanceUser(member.id),
onSuccess: (_data, member) => {
invalidate();
toast.success(`Removed ${member.email}.`);
setRemoving(null);
},
});
const actionError = (roleError ?? removeError) as Error | null;
// Removal failures are shown inside the confirm dialog that raised them, so
// only the inline role change lands here — otherwise the same sentence
// appears twice on screen.
const actionError = roleError as Error | null;
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
@@ -154,11 +173,10 @@ export function MembersCard() {
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove
Remove<span className="sr-only"> {u.email}</span>
</Button>
)
)}
@@ -170,6 +188,30 @@ export function MembersCard() {
</Table>
)}
<ConfirmDialog
open={removing !== null}
title="Remove member"
confirmLabel="Remove member"
loading={isRemoving}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => {
// Without this the next member's dialog opens showing the
// previous member's failure.
resetRemove();
setRemoving(null);
}}
onConfirm={() => removing && removeUser(removing)}
body={
<>
<p>
<span className="text-text-primary">{removing?.email}</span> loses access to this instance immediately, including any
open session.
</p>
<p>Their audit history is kept. Adding them again later creates a new member.</p>
</>
}
/>
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { clsx } from "clsx";
import { Button } from "./Button";
/*
* Twenty-three copies of the same spinner div existed across app/ and
* components/, each with the loading / error / empty branch rewritten by hand
* beside it. They had already drifted: some said "Failed to load servers. Is
* the backend running?", some rendered the raw exception message, some showed
* nothing at all while a list was empty.
*/
export function Spinner({ className, label = "Loading" }: { className?: string; label?: string }) {
return (
<span role="status" className="inline-flex items-center">
<span
className={clsx("inline-block animate-spin rounded-full border-2 border-border border-t-accent", className ?? "h-8 w-8")}
aria-hidden="true"
/>
<span className="sr-only">{label}</span>
</span>
);
}
export function CenteredSpinner({ label }: { label?: string }) {
return (
<div className="flex items-center justify-center py-20">
<Spinner label={label} />
</div>
);
}
/*
* A skeleton rather than a spinner wherever the shape of what is coming is
* already known: the table does not collapse and re-expand, so the page stops
* jumping under the pointer as data lands.
*/
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="animate-pulse p-4" aria-hidden="true">
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex gap-4 border-b border-border/40 py-3 last:border-0">
{Array.from({ length: columns }).map((_, c) => (
<div
key={c}
className="h-3 rounded bg-surface-2"
style={{ width: `${[28, 20, 16, 12, 10, 8][c % 6]}%` }}
/>
))}
</div>
))}
</div>
);
}
export function EmptyState({
title,
description,
icon,
action,
}: {
title: string;
description?: string;
icon?: React.ReactNode;
/*
* `loading` matters rather than being decoration: the empty-state button is
* usually the one that creates the first of something, and without it a
* double click creates two. A link action takes neither — there is no
* pending state to show for a navigation.
*/
action?:
| { label: string; href: string; onClick?: never; loading?: never; disabled?: never }
| { label: string; href?: never; onClick: () => void; loading?: boolean; disabled?: boolean };
}) {
return (
<div className="px-6 py-16 text-center">
{icon && (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2 text-text-secondary">
{icon}
</div>
)}
<p className="text-[15px] font-semibold text-text-primary">{title}</p>
{description && <p className="mx-auto mt-2 max-w-[46ch] text-sm text-text-secondary">{description}</p>}
{action &&
(action.href ? (
<Button href={action.href} variant="primary" size="sm" className="mt-4">
{action.label}
</Button>
) : (
<Button
variant="primary"
size="sm"
className="mt-4"
onClick={action.onClick}
loading={action.loading}
disabled={action.disabled}
>
{action.label}
</Button>
))}
</div>
);
}
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
return (
<div className="px-6 py-16 text-center" role="alert">
<p className="text-[15px] font-semibold text-text-primary">{friendlyMessage(error)}</p>
{detailOf(error) && <p className="mx-auto mt-2 max-w-[52ch] font-mono text-xs text-text-secondary">{detailOf(error)}</p>}
{onRetry && (
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
Try again
</Button>
)}
</div>
);
}
/**
* One loading/error/empty decision instead of the same ternary chain rewritten
* in every list page. `isEmpty` is passed rather than inferred, because only
* the caller knows whether an empty array is empty or simply filtered to
* nothing.
*/
export function AsyncBoundary({
isLoading,
error,
isEmpty,
onRetry,
skeleton,
empty,
children,
}: {
isLoading: boolean;
error?: unknown;
isEmpty?: boolean;
onRetry?: () => void;
skeleton?: React.ReactNode;
empty?: React.ReactNode;
children: React.ReactNode;
}) {
if (isLoading) {
// A skeleton is aria-hidden decoration, so on its own it hands a screen
// reader an empty region and no indication anything is coming. The
// spinner carries its own role="status"; a custom skeleton needs one
// supplied beside it.
return skeleton ? (
<>
<span role="status" className="sr-only">
Loading
</span>
{skeleton}
</>
) : (
<CenteredSpinner />
);
}
if (error) return <ErrorState error={error} onRetry={onRetry} />;
if (isEmpty && empty) return <>{empty}</>;
return <>{children}</>;
}
/*
* Backend messages went straight to the screen. Some are written for an
* operator and are the most useful thing available; some are a Go error string
* or a bare "Failed to fetch" from a dropped connection, which tells the
* customer nothing and reads as a crash. Classify first, then show the detail
* underneath rather than instead of an explanation.
*/
/* The reason phrases request() falls back to when the response body was empty.
An exact-match set, not a shape test: a pattern loose enough to catch
"Not Found" also catches "Default steps cannot be edited", which is the
opposite of what this is for. */
const STATUS_TEXT = new Set([
"Bad Request",
"Unauthorized",
"Forbidden",
"Not Found",
"Method Not Allowed",
"Conflict",
"Unprocessable Entity",
"Too Many Requests",
"Internal Server Error",
"Bad Gateway",
"Service Unavailable",
"Gateway Timeout",
]);
export function friendlyMessage(error: unknown): string {
const status = (error as { status?: number } | null)?.status;
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
// The backend writes its 4xx messages for an operator and they are usually
// the most specific thing available ("default steps cannot be edited",
// "vulnerability scanning is not licensed"). Keep them; only replace the
// ones that are a status code wearing a coat — "HTTP 409", or the bare
// reason phrase fetch() falls back to when the body was empty.
const useful = raw && !/^HTTP \d{3}$/.test(raw) && !STATUS_TEXT.has(raw) ? raw : "";
if (status === 401) return "Your session has expired. Sign in again to continue.";
if (status === 403) return useful || "You do not have permission to do this.";
if (status === 404) return useful || "That is no longer here.";
if (status === 409) return useful || "That conflicts with the current state.";
if (status === 429) return "Too many requests. Wait a moment and try again.";
if (typeof status === "number" && status >= 500) return "The server could not complete that. Try again shortly.";
if (typeof status === "number" && status >= 400) return useful || "That request was rejected.";
// fetch() rejects with a TypeError and no status when the request never
// reached the server at all.
if (!status && /failed to fetch|networkerror|load failed/i.test(raw)) {
return "Cannot reach the server. Check your connection.";
}
return raw || "Something went wrong.";
}
function detailOf(error: unknown): string | null {
const raw = error instanceof Error ? error.message : null;
if (!raw) return null;
return raw === friendlyMessage(error) ? null : raw;
}
+78 -37
View File
@@ -1,13 +1,32 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { AnchorHTMLAttributes, ButtonHTMLAttributes, forwardRef } from "react";
import Link from "next/link";
import { clsx } from "clsx";
type Variant = "primary" | "secondary" | "danger" | "ghost";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
interface CommonProps {
variant?: Variant;
size?: Size;
className?: string;
children?: React.ReactNode;
}
interface ButtonProps extends CommonProps, Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof CommonProps> {
loading?: boolean;
href?: undefined;
}
interface LinkButtonProps extends CommonProps, Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof CommonProps> {
/**
* Renders a next/link styled as this button instead of a <button>.
*
* <Link><Button/></Link> nests an interactive element inside an anchor: the
* markup is invalid, the pair takes two tab stops, and a keyboard Enter fires
* only the outer anchor. Nineteen call sites did that. Pass href here instead.
*/
href: string;
loading?: undefined;
}
/*
@@ -32,45 +51,67 @@ const sizeClasses: Record<Size, string> = {
lg: "px-5 py-2.5 text-base",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = "primary", size = "md", loading, className, children, disabled, ...props },
ref
) => {
const baseClasses =
"inline-flex items-center gap-2 rounded border font-semibold no-underline transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px";
function classesFor(variant: Variant, size: Size, className?: string) {
return clsx(baseClasses, variantClasses[variant], sizeClasses[size], className);
}
function Spinner() {
return (
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
focusable="false"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps | LinkButtonProps>(
({ variant = "primary", size = "md", className, children, ...rest }, ref) => {
if (typeof rest.href === "string") {
const { href, ...anchorProps } = rest as LinkButtonProps;
return (
<Link
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
className={classesFor(variant, size, className)}
{...anchorProps}
>
{children}
</Link>
);
}
const { loading, disabled, ...buttonProps } = rest as ButtonProps;
return (
<button
ref={ref}
ref={ref as React.Ref<HTMLButtonElement>}
disabled={disabled || loading}
className={clsx(
"inline-flex items-center gap-2 rounded border font-semibold transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
// A control that is busy is still a control; announce it rather than
// leaving a screen reader on the pre-click label with nothing happening.
aria-busy={loading || undefined}
className={classesFor(variant, size, className)}
{...buttonProps}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.instance/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{loading && <Spinner />}
{children}
</button>
);
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useEffect, useId, useState } from "react";
import { Button } from "./Button";
import { Modal } from "./Modal";
/*
* Destructive actions used to go through window.confirm(). That dialog is
* browser chrome: it cannot say what is about to be deleted beyond one line of
* plain text, it looks nothing like the product, it cannot show the error when
* the delete then fails, and it offers the same two buttons whether the action
* removes one key or an entire secret group.
*
* `requireTyped` is for the cases with no undo — deleting a secret group, a
* step used by every workflow. Typing the name is not friction for its own
* sake: it is what stops a muscle-memory Enter from destroying something whose
* name the operator never actually read.
*/
export function ConfirmDialog({
open,
title,
body,
confirmLabel = "Delete",
requireTyped,
destructive = true,
loading,
error,
onConfirm,
onClose,
}: {
open: boolean;
title: string;
body: React.ReactNode;
confirmLabel?: string;
/** When set, the confirm button stays disabled until this exact string is typed. */
requireTyped?: string;
destructive?: boolean;
loading?: boolean;
error?: string | null;
onConfirm: () => void;
onClose: () => void;
}) {
const [typed, setTyped] = useState("");
const inputId = useId();
// A reopened dialog must not carry the previous attempt's typing — nor may
// a row reused for a different item stay armed with the name it matched
// before, which is why `requireTyped` is a dependency and not just `open`.
useEffect(() => {
setTyped("");
}, [open, requireTyped]);
const armed = !requireTyped || typed === requireTyped;
return (
<Modal open={open} title={title} onClose={onClose}>
<div className="space-y-4 text-sm text-text-secondary">
<div className="space-y-2">{body}</div>
{requireTyped && (
<div className="space-y-1.5">
<label htmlFor={inputId} className="block text-xs text-text-secondary">
Type <span className="font-mono text-text-primary">{requireTyped}</span> to confirm
</label>
<input
id={inputId}
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary outline-none focus:border-accent"
/>
</div>
)}
{error && (
<p role="alert" className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-danger">
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant={destructive ? "danger" : "primary"}
onClick={onConfirm}
loading={loading}
disabled={!armed}
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
);
}
+228 -36
View File
@@ -1,45 +1,237 @@
"use client";
import { useEffect } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
/*
* Dialogs nest: a confirm sits on top of the edit modal that raised it. Both
* listen on document, so without a stack Escape would close the pair at once
* and the trap of the covered dialog would fight the top one for focus. Only
* the last opened panel acts.
*/
const stack: symbol[] = [];
/*
* The scroll lock is refcounted rather than saved and restored per dialog.
* Per-instance save/restore breaks when the outer dialog unmounts first — which
* a dialog that navigates away on success does — since the outer's cleanup then
* releases the lock while the inner one is still on screen.
*/
let lockCount = 0;
let lockedOverflow = "";
let lockedPadding = "";
let hidden: HTMLElement[] = [];
/*
* Marks a body child as belonging to the dialog layer rather than the page, so
* the aria-hidden sweep below skips it. Exported because the toast layer needs
* the same exemption: a confirmation raised by a dialog is raised *before* that
* dialog closes, so a toast rendered inside the app tree would be inserted into
* a hidden subtree and never announced — and un-hiding a live region later does
* not replay what it missed.
*/
export const DIALOG_LAYER_ATTR = "data-vantage-dialog";
const PORTAL_ATTR = DIALOG_LAYER_ATTR;
function lockScroll() {
const { body } = document;
if (lockCount === 0) {
lockedOverflow = body.style.overflow;
lockedPadding = body.style.paddingRight;
// Padding replaces the scrollbar's width so the layout does not jump
// sideways as it disappears.
const gap = window.innerWidth - document.documentElement.clientWidth;
body.style.overflow = "hidden";
if (gap > 0) body.style.paddingRight = `${gap}px`;
/*
* aria-modal is a claim, not a mechanism. Portalled to the body, the
* app tree is a plain sibling of the dialog, so a screen reader's
* virtual cursor happily browses the page underneath — which is the
* exact thing the overlay exists to prevent. Hiding the siblings is
* what makes the claim true.
*/
hidden = Array.from(body.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && !el.hasAttribute(PORTAL_ATTR),
);
for (const el of hidden) el.setAttribute("aria-hidden", "true");
}
lockCount++;
}
function unlockScroll() {
lockCount = Math.max(0, lockCount - 1);
if (lockCount === 0) {
document.body.style.overflow = lockedOverflow;
document.body.style.paddingRight = lockedPadding;
for (const el of hidden) el.removeAttribute("aria-hidden");
hidden = [];
}
}
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function focusableIn(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
}
export function Modal({
open,
title,
onClose,
children,
wide,
open,
title,
onClose,
children,
wide,
}: {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const panelRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const restoreRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const idRef = useRef<symbol>(Symbol("modal"));
if (!open) return null;
/*
* onClose is an inline arrow at every call site, so its identity changes on
* each render of the parent — and a parent re-renders on every react-query
* poll and every mutation state flip. Holding it in a ref is what keeps the
* effect below keyed on `open` alone: depending on the handler tore the
* whole thing down and rebuilt it mid-interaction, which yanked focus out
* of whatever the user was typing in and back to the top of the dialog.
*/
const closeRef = useRef(onClose);
closeRef.current = onClose;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
);
// Portals need a DOM that exists, which it does not during SSR.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
// `mounted` is a dependency, not just a guard: on the first client
// render it is false and the component returns null, so a Modal that
// mounts already open would run this against null refs and never take
// focus at all.
if (!open || !mounted) return;
const id = idRef.current;
stack.push(id);
restoreRef.current = document.activeElement as HTMLElement | null;
lockScroll();
const onKeyDown = (e: KeyboardEvent) => {
// Only the topmost dialog reacts.
if (stack[stack.length - 1] !== id) return;
const panel = panelRef.current;
if (!panel) return;
if (e.key === "Escape") {
e.stopPropagation();
closeRef.current();
return;
}
if (e.key !== "Tab") return;
const items = focusableIn(panel);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
// Focus can be outside the panel entirely — on <body> after a
// control unmounted, or on the page behind. Pull it back rather
// than letting Tab continue out into content the overlay covers.
if (!active || !panel.contains(active)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
return;
}
if (e.shiftKey && (active === first || active === panel)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
/*
* Focus the first control in the body, not in the panel: the header
* comes first in DOM order, so querying the whole panel opens every
* dialog on its own dismiss button, which reads as "are you sure you
* want to be here".
*/
const target = (bodyRef.current && focusableIn(bodyRef.current)[0]) ?? panelRef.current;
target?.focus();
return () => {
const at = stack.lastIndexOf(id);
if (at !== -1) stack.splice(at, 1);
document.removeEventListener("keydown", onKeyDown, true);
unlockScroll();
// Return focus to whatever opened the dialog, if it is still there.
if (restoreRef.current?.isConnected) restoreRef.current.focus();
restoreRef.current = null;
};
}, [open, mounted]);
if (!open || !mounted) return null;
/*
* Portalled to the body. A nested confirm would otherwise render inside its
* parent panel's overflow-auto box and be clipped by it, and a dialog is
* not part of the content it covers.
*/
return createPortal(
<div {...{ [PORTAL_ATTR]: "" }} className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} aria-hidden="true" />
<div
ref={panelRef}
tabIndex={-1}
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel focus:outline-none sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 id={titleId} className="text-sm font-bold text-text-primary">
{title}
</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
aria-label="Close dialog"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div ref={bodyRef} className="p-5">
{children}
</div>
</div>
</div>,
document.body,
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { clsx } from "clsx";
import { friendlyMessage } from "./Async";
import { DIALOG_LAYER_ATTR } from "./Modal";
/*
* Mutations succeeded silently. Copying an install one-liner, generating a key,
* restarting a container, rotating the ESO token — all of them changed
* something and said nothing, so the only way to know it worked was to watch
* for the list to redraw. Failures were worse: each page wired its own
* `onError: setError` into its own inline div, so an error raised by a modal
* that then closed had nowhere to land at all.
*
* No dependency for this. It is a context, a list and a fixed div; sonner would
* be 12KB to render three lines of text in a palette we would then have to
* override anyway.
*/
type ToastKind = "success" | "error" | "info";
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
interface ToastApi {
success: (message: string) => void;
info: (message: string) => void;
/** Accepts a thrown value directly, so call sites do not each re-derive a message. */
error: (error: unknown) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
const DURATION: Record<ToastKind, number> = {
// An error stays four times as long as a confirmation: it is the one the
// reader has to act on, and it may be the only record of what failed.
success: 4000,
info: 5000,
error: 12000,
};
const KIND_CLASSES: Record<ToastKind, string> = {
success: "border-success/40 text-success",
error: "border-danger/40 text-danger",
info: "border-accent/40 text-accent",
};
const KIND_LABEL: Record<ToastKind, string> = {
success: "Success",
error: "Error",
info: "Note",
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
// Portals need a DOM, which SSR has not got.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) {
clearTimeout(timer);
timers.current.delete(id);
}
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(kind: ToastKind, message: string) => {
const id = nextId.current++;
// Cap the stack. A mutation looping on a failing endpoint would
// otherwise paper over the screen with the same sentence.
setToasts((list) => [...list.slice(-2), { id, kind, message }]);
timers.current.set(
id,
setTimeout(() => dismiss(id), DURATION[kind]),
);
},
[dismiss],
);
const api = useMemo<ToastApi>(
() => ({
success: (message) => push("success", message),
info: (message) => push("info", message),
error: (error) => push("error", friendlyMessage(error)),
}),
[push],
);
return (
<ToastContext.Provider value={api}>
{children}
{/*
* Portalled to the body and marked as dialog layer, so an open
* modal's aria-hidden sweep leaves it alone. Every modal-raised
* confirmation ("Saved …", "Deleted …", "Removed …") is toasted
* before the dialog closes, and inside the app tree all of them
* would land in a hidden subtree and go unannounced.
*
* Two regions, not one polite container holding role="alert"
* children: live-region politeness is taken from the nearest
* ancestor that declares it, so a single polite wrapper demotes the
* errors inside it. Errors interrupt because they mean the thing
* the operator asked for did not happen; a confirmation can wait
* for a pause in speech.
*
* z-index sits above the dialog layer: a toast reporting why a
* dialog's action failed is no use behind it.
*/}
{mounted &&
createPortal(
<div
{...{ [DIALOG_LAYER_ATTR]: "" }}
className="pointer-events-none fixed inset-x-0 bottom-0 z-[70] flex flex-col items-center gap-2 p-4 sm:items-end"
>
<ToastRegion toasts={toasts.filter((t) => t.kind !== "error")} politeness="polite" onDismiss={dismiss} />
<ToastRegion toasts={toasts.filter((t) => t.kind === "error")} politeness="assertive" onDismiss={dismiss} />
</div>,
document.body,
)}
</ToastContext.Provider>
);
}
function ToastRegion({
toasts,
politeness,
onDismiss,
}: {
toasts: Toast[];
politeness: "polite" | "assertive";
onDismiss: (id: number) => void;
}) {
return (
<div className="flex w-full flex-col items-center gap-2 sm:items-end" aria-live={politeness} aria-atomic="false">
{toasts.map((t) => (
<div
key={t.id}
role={t.kind === "error" ? "alert" : "status"}
className={clsx(
"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded border bg-surface px-4 py-3 text-sm shadow-panel",
KIND_CLASSES[t.kind],
)}
>
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
<span className="min-w-0 flex-1 break-words text-text-primary">
<span className="sr-only">{KIND_LABEL[t.kind]}: </span>
{t.message}
</span>
<button
type="button"
onClick={() => onDismiss(t.id)}
aria-label="Dismiss notification"
className="shrink-0 rounded p-0.5 text-text-secondary transition-colors hover:text-text-primary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
))}
</div>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
return ctx;
}
+11
View File
@@ -3,4 +3,15 @@ export { Badge } from "./Badge";
export { Card, CardHeader, CardTitle } from "./Card";
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
export { Modal } from "./Modal";
export { ConfirmDialog } from "./ConfirmDialog";
export { Pagination, usePagination, PAGE_SIZES } from "./Pagination";
export {
AsyncBoundary,
CenteredSpinner,
EmptyState,
ErrorState,
Spinner,
TableSkeleton,
friendlyMessage,
} from "./Async";
export { ToastProvider, useToast } from "./Toast";
+31 -5
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep, InputParam } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
import { Button, ConfirmDialog, Modal, friendlyMessage, useToast } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
@@ -16,6 +16,8 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const toast = useToast();
// Default steps are re-seeded from the image on every boot, so the server
// refuses to update or delete them. The form mirrors that rather than
@@ -33,19 +35,22 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
if (step) await api.updateStep(step.step_id, payload);
else await api.createStep(payload);
qc.invalidateQueries({ queryKey: ["steps"] });
toast.success(step ? `Saved ${payload.name}.` : `Created ${payload.name}.`);
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
} catch (e) { setError(friendlyMessage(e)); } finally { setBusy(false); }
};
const del = async () => {
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
if (!step) return;
setBusy(true); setError(null);
try {
await api.deleteStep(step.step_id);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["workflow"] });
toast.success(`Deleted ${step.name}.`);
setConfirmingDelete(false);
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
} catch (e) { setError(friendlyMessage(e)); setConfirmingDelete(false); } finally { setBusy(false); }
};
return (
@@ -106,13 +111,34 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
)}
</div>
<div className="flex items-center justify-between pt-2">
{step && !locked ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
{step && !locked ? <Button variant="danger" onClick={() => setConfirmingDelete(true)}>Delete step</Button> : <span />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>{locked ? "Close" : "Cancel"}</Button>
{!locked && <Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>}
</div>
</div>
</div>
<ConfirmDialog
open={confirmingDelete}
title="Delete base step"
confirmLabel="Delete step"
// A base step is shared: deleting it edits every workflow that uses it,
// which is not what "delete this one thing" usually implies.
requireTyped={step?.name}
loading={busy}
onClose={() => setConfirmingDelete(false)}
onConfirm={del}
body={
<>
<p>
<span className="font-mono text-text-primary">{step?.name}</span> is a shared library step. Deleting it removes it from every
workflow that references it.
</p>
<p>Runs already recorded keep their snapshot of the script and are not affected.</p>
</>
}
/>
</Modal>
);
}
+34 -5
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
import { Button, ConfirmDialog, Modal, friendlyMessage, useToast } from "@/components/ui";
import { ScheduleCard } from "./ScheduleCard";
import { DualListBox } from "./DualListBox";
@@ -20,6 +20,8 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(workflow.target_tags ?? {}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const toast = useToast();
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
@@ -42,23 +44,31 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
target_tags: Object.fromEntries(tagRows.filter(([k, v]) => k && v)),
});
onSaved(updated);
toast.success(`Saved ${updated.name}.`);
onClose();
} catch (e) {
setError((e as Error).message);
setError(friendlyMessage(e));
} finally {
setBusy(false);
}
};
const del = async () => {
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
setBusy(true);
setError(null);
try {
await api.deleteWorkflow(workflow.workflow_id);
toast.success(`Deleted ${workflow.name}.`);
// Close both dialogs before navigating. Leaving them mounted takes
// their scroll lock and focus trap onto the workflows list and
// holds it there until the route change happens to unmount them.
setConfirmingDelete(false);
setBusy(false);
onClose();
router.push("/workflows");
} catch (e) {
setError((e as Error).message);
setError(friendlyMessage(e));
setConfirmingDelete(false);
setBusy(false);
}
};
@@ -154,7 +164,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
<ScheduleCard workflow={workflow} />
<div className="flex items-center justify-between border-t border-border-soft pt-4">
<Button variant="danger" onClick={del} loading={busy}>
<Button variant="danger" onClick={() => setConfirmingDelete(true)}>
Delete workflow
</Button>
<div className="flex gap-2">
@@ -167,6 +177,25 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
</div>
</div>
</div>
<ConfirmDialog
open={confirmingDelete}
title="Delete workflow"
confirmLabel="Delete workflow"
requireTyped={workflow.name}
loading={busy}
onClose={() => setConfirmingDelete(false)}
onConfirm={del}
body={
<>
<p>
<span className="font-mono text-text-primary">{workflow.name}</span> and its schedule are removed. Its base steps stay
in the library.
</p>
<p>Past runs and their logs are kept, but nothing new can be run from this workflow.</p>
</>
}
/>
</Modal>
);
}
+7
View File
@@ -7,6 +7,13 @@ export const queryClient = new QueryClient({
queries: {
staleTime: 30_000,
retry: 1,
// Several pages poll on an interval — the fleet list every 30s, run logs
// faster than that. A hidden tab was still doing all of it, so a console
// left open overnight in a background tab kept refetching the fleet and
// its inventory blobs until the session expired. The default here rather
// than per page, because the argument is the same everywhere and the
// pages that poll are exactly the ones nobody remembers to check.
refetchIntervalInBackground: false,
},
},
});