Compare commits

...
13 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
mrhid6 d559cccd44 feat: Updated server page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 45s
2026-08-07 16:21:17 +01:00
mrhid6 0684d84609 fix: Fixed vuln score
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m40s
2026-08-07 15:33:50 +01:00
mrhid6 78f1bf853c fix: More fixes to vuln matching
Chart Release / chart (push) Successful in 31s
Server Deploy / deploy (push) Successful in 1m42s
2026-08-07 13:29:15 +01:00
mrhid6 e28238191d feat: Added vuln filter
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m29s
2026-08-07 11:58:42 +01:00
mrhid6 82bcc5776f fix: Fixed vuln scanning
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 4m7s
2026-08-07 11:13:58 +01:00
mrhid6 1993802c38 feat: Updated rescan button text 2026-08-07 10:52:16 +01:00
mrhid6 5db49b6b0e feat: Vuln debug logs
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m47s
2026-08-07 10:50:08 +01:00
57 changed files with 3755 additions and 1170 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
View File
@@ -32,6 +32,7 @@ func listVulnerabilities(c *gin.Context) {
State: c.DefaultQuery("state", models.FindingOpen),
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
HasFix: hasFixFromQuery(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -82,6 +83,22 @@ func groupByCVE(findings []models.VulnFinding) []vulnGroup {
return out
}
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
// or malformed value, is no filter — a filter nobody asked for must never hide
// findings, and the wrong direction here hides the unfixable ones.
func hasFixFromQuery(c *gin.Context) *bool {
switch c.Query("has_fix") {
case "true":
v := true
return &v
case "false":
v := false
return &v
default:
return nil
}
}
// tagsFromQuery reads repeated tag=key:value parameters.
func tagsFromQuery(c *gin.Context) map[string]string {
out := map[string]string{}
+56 -9
View File
@@ -52,6 +52,9 @@ func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now ti
Installed: r.Installed,
FixedIn: r.FixedIn,
Severity: r.Severity,
CVSSScore: r.CVSSScore,
Title: r.Title,
References: r.References,
State: models.FindingOpen,
FirstSeen: now,
LastSeen: now,
@@ -112,6 +115,10 @@ type FindingFilter struct {
State string
ServerID string
Tags map[string]string
// HasFix nil is no filter. true is "a vendor fix exists, this is
// patchable"; false is the unfixable set — remove the package, disable the
// service, or accept it, but do not wait for an update.
HasFix *bool
}
// ListInstanceFindings returns findings across the whole fleet.
@@ -130,6 +137,17 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
filter["server_id"] = f.ServerID
}
// fixed_in is omitempty, so a finding with no vendor fix carries no such
// field at all rather than an empty string. Both forms must be matched, or
// the unfixable set reads as empty on any document written before this.
if f.HasFix != nil {
if *f.HasFix {
filter["fixed_in"] = bson.M{"$nin": bson.A{"", nil}}
} else {
filter["fixed_in"] = bson.M{"$in": bson.A{"", nil}}
}
}
// The tag selector resolves through ResolveTargets, the single answer to
// which servers a selector touches. A second matcher here could disagree
// with what a workflow means by env:prod.
@@ -207,8 +225,11 @@ func MarkInstanceForRescan(instanceID string) (int64, error) {
bson.M{"$set": bson.M{"scan_pending": true}},
)
if err != nil {
log.Printf("vulnsched: mark rescan for instance %s: %v", instanceID, err)
return 0, err
}
log.Printf("vulnsched: rescan requested for instance %s, %d of %d server(s) flagged",
instanceID, res.ModifiedCount, res.MatchedCount)
return res.ModifiedCount, nil
}
@@ -300,19 +321,40 @@ func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.Vu
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
col := db.Col("vuln_findings")
// One BulkWrite per batch, not one UpdateOne per finding. A freshly scanned
// Ubuntu host opens tens of thousands of findings, and at one round trip each
// that is minutes of sequential latency during which the tick holds the
// leader and every other server waits its turn. Unordered, because the
// upserts are independent and one duplicate-key race must not abandon the
// rest of the batch.
const bulkBatch = 1000
ops := make([]mongo.WriteModel, 0, bulkBatch)
flush := func() error {
if len(ops) == 0 {
return nil
}
_, err := col.BulkWrite(ctx, ops, options.BulkWrite().SetOrdered(false))
ops = ops[:0]
return err
}
for _, f := range d.Upserts {
_, err := col.UpdateOne(ctx,
bson.M{
ops = append(ops, mongo.NewUpdateOneModel().
SetFilter(bson.M{
"instance_id": instanceID,
"server_id": serverID,
"cve_id": f.CVEID,
"package_name": f.PackageName,
},
bson.M{
}).
SetUpdate(bson.M{
"$set": bson.M{
"installed_version": f.Installed,
"fixed_in": f.FixedIn,
"severity": f.Severity,
"cvss_score": f.CVSSScore,
"title": f.Title,
"references": f.References,
"state": models.FindingOpen,
"last_seen": now,
},
@@ -326,13 +368,18 @@ func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d Findin
"first_seen": f.FirstSeen,
},
"$unset": bson.M{"fixed_at": "", "accepted": ""},
},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}).
SetUpsert(true))
if len(ops) >= bulkBatch {
if err := flush(); err != nil {
return err
}
}
}
if err := flush(); err != nil {
return err
}
if len(d.FixedIDs) > 0 {
if _, err := col.UpdateMany(ctx,
+9
View File
@@ -1,6 +1,7 @@
package vulndb
import (
"log"
"strings"
trivydb "github.com/aquasecurity/trivy-db/pkg/db"
@@ -14,6 +15,11 @@ type Advisory struct {
// state, not an absence of data, and callers must treat it as vulnerable.
FixedVersion string
Severity string
// Status is filled by trivy-db ONLY when FixedVersion is empty — when there
// is a fix, "fixed" is the obvious state and the field is left zero. It is
// what separates "the vendor confirms this package is affected and has not
// fixed it" from "nobody has looked yet".
Status string
}
// VulnInfo is the CVE's own metadata, shared across every server it affects.
@@ -33,8 +39,10 @@ type Store struct {
// it appends "trivy.db" itself.
func Open(dir string) (*Store, error) {
if err := trivydb.Init(dir); err != nil {
log.Printf("vulndb: open %s: %v", dir, err)
return nil, err
}
log.Printf("vulndb: opened database in %s", dir)
return &Store{cfg: trivydb.Config{}}, nil
}
@@ -55,6 +63,7 @@ func (s *Store) Advisories(bucket, srcName string) ([]Advisory, error) {
// Vulnerability.Severity which is a string. They are genuinely
// different types in trivy-db, not an inconsistency here.
Severity: severityFromLevel(a.Severity),
Status: a.Status.String(),
})
}
return out, nil
+64
View File
@@ -0,0 +1,64 @@
package vulndb
// VulnSource is the CVE-metadata lookup the enricher needs. *Store satisfies it.
type VulnSource interface {
Vulnerability(cveID string) (VulnInfo, error)
}
// MetaCache enriches match results with the CVE's own metadata.
//
// This is not an optimisation, it is where severity comes from. Debian, Ubuntu
// and Alpine advisories carry no severity of their own — trivy-db leaves
// Advisory.Severity zero for those buckets and keeps the rating in the
// vulnerability bucket's VendorSeverity map instead. Taking the advisory's
// value alone reported an entire fleet as "unknown".
//
// One cache per tick, shared across servers: a CVE affects every host running
// the package, and the bolt read is the same read every time.
type MetaCache struct {
src VulnSource
seen map[string]VulnInfo
}
func NewMetaCache(src VulnSource) *MetaCache {
return &MetaCache{src: src, seen: make(map[string]VulnInfo)}
}
// Enrich fills severity, title, score and references in place.
//
// The advisory's severity is kept when the vulnerability bucket has nothing
// better to say — RHEL does publish it per advisory — so this can only raise
// the quality of the answer, never lower it.
func (m *MetaCache) Enrich(results []Result) {
if m == nil {
return
}
for i := range results {
info, ok := m.lookup(results[i].CVEID)
if !ok {
continue
}
if info.Severity != "" && info.Severity != "unknown" {
results[i].Severity = info.Severity
}
results[i].Title = info.Title
results[i].CVSSScore = info.CVSSScore
results[i].References = info.References
}
}
func (m *MetaCache) lookup(cveID string) (VulnInfo, bool) {
if info, ok := m.seen[cveID]; ok {
return info, true
}
info, err := m.src.Vulnerability(cveID)
if err != nil {
// A CVE with an advisory but no vulnerability document is a real state
// in trivy-db, not a fault. Cache the miss so it is asked once.
Debugf("no vulnerability record for %s: %v", cveID, err)
m.seen[cveID] = VulnInfo{}
return VulnInfo{}, false
}
m.seen[cveID] = info
return info, true
}
+126 -6
View File
@@ -14,12 +14,19 @@ type AdvisorySource interface {
}
// Result is one vulnerable package on one server, before it becomes a finding.
//
// Severity, Title, CVSSScore and References are only as good as the advisory
// until MetaCache.Enrich has run over them — for the Debian-family buckets the
// advisory carries no severity at all, so an unenriched Result reads "unknown".
type Result struct {
CVEID string
PackageName string // the BINARY package, which is what is installed
Installed string
FixedIn string
Severity string
Title string
CVSSScore float64
References []string
}
// Match returns every advisory that the installed packages do not satisfy.
@@ -32,11 +39,22 @@ type Result struct {
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
bucket, err := Bucket(os.Family, os.VersionID)
if err != nil {
log.Printf("vulndb: no bucket for family=%q version=%q: %v", os.Family, os.VersionID, err)
return nil, err
}
log.Printf("vulndb: matching %d packages against bucket %q", len(pkgs), bucket)
var advisoryCount, skipped, unactionable, noFix int
// Advisories are keyed on the SOURCE package, and several hundred binary
// packages on a host resolve to the same few hundred sources — linux-modules,
// linux-image and linux-headers all ask about "linux", whose advisory list is
// thousands long. Without this the same bolt read is repeated once per binary
// package, which is most of what made a single Ubuntu host take minutes.
cache := make(map[string][]Advisory, len(pkgs))
var out []Result
for _, p := range pkgs {
for _, p := range newestPerSource(os.Family, pkgs) {
// Debian and Ubuntu advisories are keyed on the source package: one
// advisory against "openssl" covers libssl3, openssl and libssl-dev.
srcName := p.SourceName
@@ -44,15 +62,30 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
srcName = p.Name
}
advs, err := src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
advs, cached := cache[srcName]
if !cached {
var err error
advs, err = src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
}
cache[srcName] = advs
}
advisoryCount += len(advs)
if len(advs) > 0 {
Debugf("%s (src %s, installed %s): %d advisories", p.Name, srcName, p.Version, len(advs))
}
for _, a := range advs {
// No published fix. Vulnerable, and the finding most in need of
// acceptance, since there is nothing to patch.
// No published fix. Whether that is a finding depends entirely on the
// status the vendor attached to it — see actionable().
if a.FixedVersion == "" {
if !actionable(a.Status) {
unactionable++
Debugf("%s (src %s): %s skipped, status %q", p.Name, srcName, a.CVEID, a.Status)
continue
}
noFix++
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
Installed: p.Version, Severity: a.Severity,
@@ -67,8 +100,10 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
// on the host. Log it — a silent skip is a silent false
// negative, which is the direction that hurts.
log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
skipped++
continue
}
Debugf("%s %s vs fixed %s (%s): vulnerable=%t", p.Name, p.Version, a.FixedVersion, a.CVEID, older)
if older {
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
@@ -77,5 +112,90 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
}
}
}
log.Printf("vulndb: bucket %q done: %d packages, %d sources, %d advisories considered, "+
"%d results (%d with no vendor fix), %d skipped as not-yet-triaged, %d unparseable comparisons",
bucket, len(pkgs), len(cache), advisoryCount, len(out), noFix, unactionable, skipped)
return out, nil
}
// newestPerSource collapses the installed set to one binary package per source
// package: the one carrying the highest version.
//
// Advisories are keyed on the source, so every binary package of a source asks
// the same question. Normally they all carry the same version and the answer is
// the same, so collapsing is free. The kernel is the exception that makes it
// necessary: Ubuntu encodes the ABI in the binary name, so an upgrade INSTALLS
// linux-headers-6.8.0-137 beside linux-headers-6.8.0-124 rather than replacing
// it, and the old one lingers until an autoremove. Matched per binary package,
// a fully patched host reports every superseded ABI package as vulnerable —
// which is the noise this exists to stop — and reports it twice over, once for
// linux-headers-6.8.0-124 and again for its -generic sibling.
//
// The version, not the name, decides. There is no kernel special case here: a
// source's newest installed version is what the fix landed as, whatever the
// source is.
//
// A comparison that cannot be made keeps the incumbent rather than guessing;
// the loser is dropped either way, and dropping the parseable one would be the
// false-negative direction.
func newestPerSource(family string, pkgs []models.InstalledPackage) []models.InstalledPackage {
best := make(map[string]models.InstalledPackage, len(pkgs))
order := make([]string, 0, len(pkgs))
for _, p := range pkgs {
src := p.SourceName
if src == "" {
src = p.Name
}
cur, seen := best[src]
if !seen {
best[src] = p
order = append(order, src)
continue
}
older, err := LessThan(family, cur.Version, p.Version)
if err != nil {
log.Printf("vulndb: newest for source %s: compare %s vs %s: %v",
src, cur.Version, p.Version, err)
continue
}
if older {
Debugf("source %s: %s %s supersedes %s %s",
src, p.Name, p.Version, cur.Name, cur.Version)
best[src] = p
}
}
out := make([]models.InstalledPackage, 0, len(order))
for _, src := range order {
out = append(out, best[src])
}
return out
}
// actionable decides whether an advisory with no fixed version is a finding.
//
// trivy-db fills Status only when FixedVersion is empty, and Ubuntu publishes a
// status for every CVE against every source package it ships — the vast
// majority being "under_investigation" (the tracker's needs-triage), meaning
// nobody has yet established that the package is affected at all. Reporting
// those produced ~24,000 findings for a single 797-package host, which is not a
// security report, it is a wall. A "not_affected" is the vendor stating the
// opposite of a finding, so it is never one.
//
// What survives is what the vendor has confirmed: affected, will_not_fix,
// fix_deferred, end_of_life. Those are exactly the findings the CLAUDE.md rule
// is about — an empty fixed_in that means "no fix exists", the one most in need
// of acceptance rather than patching.
// Only the two statuses that positively say "this is not a finding" are
// dropped. "unknown" is kept: a feed that sets no status at all must not become
// a silent false negative, and it is not what generates the noise — Ubuntu
// states under_investigation explicitly.
func actionable(status string) bool {
switch status {
case "not_affected", "under_investigation":
return false
default:
return true
}
}
+29 -1
View File
@@ -7,9 +7,11 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
@@ -49,6 +51,24 @@ func Disabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULNDB_DISABLED"), "true")
}
// DebugEnabled turns on per-package and per-advisory tracing.
//
// It is a switch rather than always-on because a single scan asks the store one
// question per installed package — ~2000 lines per server, per tick — which
// would bury every other subsystem's logs on a fleet of any size. The lifecycle
// logs (pull, tick, per-server totals) are unconditional; only the inner loop
// is gated.
func DebugEnabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULN_DEBUG"), "true")
}
// Debugf logs only when VANTAGE_VULN_DEBUG=true.
func Debugf(format string, args ...any) {
if DebugEnabled() {
log.Printf("vulndb[debug]: "+format, args...)
}
}
// dbMetadata is the subset of trivy-db's metadata.json we read.
type dbMetadata struct {
Version int `json:"Version"`
@@ -62,6 +82,8 @@ type dbMetadata struct {
// one that Open would happily accept and scan against.
func Pull(ctx context.Context, dir string) (int, error) {
ref := Ref()
started := time.Now()
log.Printf("vulndb: pull starting ref=%s dir=%s", ref, dir)
parsed, err := registry.ParseReference(ref)
if err != nil {
@@ -92,6 +114,8 @@ func Pull(ctx context.Context, dir string) (int, error) {
if len(man.Layers) == 0 {
return 0, fmt.Errorf("artifact %s has no layers", ref)
}
log.Printf("vulndb: manifest resolved layers=%d digest=%s size=%dB",
len(man.Layers), man.Layers[0].Digest, man.Layers[0].Size)
// Streamed rather than buffered: the layer is ~50MB and there is no reason
// to hold it in memory on the way to disk.
@@ -110,6 +134,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
if err := extractTarGz(rc, staging); err != nil {
return 0, fmt.Errorf("extract layer: %w", err)
}
log.Printf("vulndb: layer extracted into %s after %s", staging, time.Since(started).Round(time.Millisecond))
metaBytes, err := os.ReadFile(filepath.Join(staging, metaFileName))
if err != nil {
@@ -123,9 +148,11 @@ func Pull(ctx context.Context, dir string) (int, error) {
return 0, fmt.Errorf("trivy-db schema %d is not supported (want %d)", meta.Version, SupportedSchema)
}
if _, err := os.Stat(filepath.Join(staging, dbFileName)); err != nil {
fi, err := os.Stat(filepath.Join(staging, dbFileName))
if err != nil {
return 0, fmt.Errorf("artifact has no %s: %w", dbFileName, err)
}
log.Printf("vulndb: %s is %dB, schema %d accepted", dbFileName, fi.Size(), meta.Version)
// Both files present and the schema accepted, so it is safe to replace.
for _, name := range []string{dbFileName, metaFileName} {
@@ -139,6 +166,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
}
}
log.Printf("vulndb: pull complete ref=%s schema=%d in %s", ref, meta.Version, time.Since(started).Round(time.Millisecond))
return meta.Version, nil
}
+44 -3
View File
@@ -51,11 +51,17 @@ func Start(ctx context.Context, deps Deps) {
dir, err := os.MkdirTemp("", "vantage-vulndb-")
if err != nil {
log.Printf("vulnsched: temp dir: %v", err)
// The classic form of this is "stat /tmp: no such file or directory" on
// the scratch runtime image. It is logged once at boot while everything
// else runs normally, so the only other symptom is a fleet that never
// reports a finding.
log.Printf("vulnsched: temp dir: %v (scan loop NOT started)", err)
return
}
s := &scheduler{deps: deps, dir: dir}
log.Printf("vulnsched: started, ref=%s tick=%s dir=%s debug=%t",
vulndb.Ref(), tickInterval, dir, vulndb.DebugEnabled())
go func() {
defer os.RemoveAll(dir)
@@ -66,6 +72,7 @@ func Start(ctx context.Context, deps Deps) {
for {
select {
case <-ctx.Done():
log.Println("vulnsched: leadership lost or shutting down, scan loop stopping")
return
case <-ticker.C:
s.tick(ctx)
@@ -75,16 +82,22 @@ func Start(ctx context.Context, deps Deps) {
}
func (s *scheduler) tick(ctx context.Context) {
started := time.Now()
vulndb.Debugf("vulnsched tick starting (store loaded=%t, db version=%d, pulled %s ago)",
s.store != nil, s.version, time.Since(s.pulled).Round(time.Second))
if err := s.ensureDB(ctx); err != nil {
// Keep the last good database and carry on scanning against it. A
// network blip must never clear findings or read as "all fixed".
log.Printf("vulnsched: database unavailable: %v", err)
s.recordDBError(ctx, err)
if s.store == nil {
log.Println("vulnsched: no database loaded at all, nothing can be scanned this tick")
return
}
}
s.scanPending(ctx)
vulndb.Debugf("vulnsched tick finished in %s", time.Since(started).Round(time.Millisecond))
}
// ensureDB pulls a fresh database when the local copy is stale, and marks the
@@ -93,8 +106,11 @@ func (s *scheduler) tick(ctx context.Context) {
// next agent report.
func (s *scheduler) ensureDB(ctx context.Context) error {
if s.store != nil && time.Since(s.pulled) < dbMaxAge {
vulndb.Debugf("database is %s old, under the %s limit; not pulling",
time.Since(s.pulled).Round(time.Second), dbMaxAge)
return nil
}
log.Printf("vulnsched: pulling database (age %s, max %s)", time.Since(s.pulled).Round(time.Second), dbMaxAge)
version, err := vulndb.Pull(ctx, s.dir)
if err != nil {
@@ -110,6 +126,7 @@ func (s *scheduler) ensureDB(ctx context.Context) error {
s.pulled = time.Now()
changed := version != s.version
log.Printf("vulnsched: database ready, schema %d (previous %d, changed=%t)", version, s.version, changed)
s.version = version
_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
@@ -152,19 +169,29 @@ func (s *scheduler) scanPending(ctx context.Context) {
return
}
if len(pending) == 0 {
vulndb.Debugf("no servers pending scan")
return
}
log.Printf("vulnsched: %d server(s) pending scan", len(pending))
// Newly opened findings are collected across the whole tick and sent as one
// digest per instance. A database refresh can open several hundred findings
// at once; one message per finding would rate-limit the webhook or get the
// channel muted, and either way the alerts stop being read.
newly := map[string][]models.VulnFinding{}
// One metadata cache for the whole tick: a CVE affects every host running
// the package, and the lookup is the same read each time.
meta := vulndb.NewMetaCache(s.store)
for _, sp := range pending {
if ctx.Err() != nil {
// Leadership lost. scan_pending is still set, so the next leader
// picks these up — which is why it lives on the document.
return
}
opened := s.scanOne(ctx, sp)
opened := s.scanOne(ctx, sp, meta)
newly[sp.InstanceID] = append(newly[sp.InstanceID], opened...)
}
@@ -180,8 +207,10 @@ func (s *scheduler) scanPending(ctx context.Context) {
)
}
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []models.VulnFinding {
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages, meta *vulndb.MetaCache) []models.VulnFinding {
now := time.Now()
log.Printf("vulnsched: scanning server %s (instance %s, os %s %s, %d packages)",
sp.ServerID, sp.InstanceID, sp.OS.Family, sp.OS.VersionID, len(sp.Packages))
results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
if err != nil {
@@ -193,11 +222,19 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
if !errors.Is(err, vulndb.ErrUnsupportedFamily) {
log.Printf("vulnsched: scan %s: %v", sp.ServerID, err)
status = sp.Status
} else {
log.Printf("vulnsched: server %s marked unsupported: no feed for %s %s",
sp.ServerID, sp.OS.Family, sp.OS.VersionID)
}
s.clearPending(ctx, sp.ID, status, now)
return nil
}
// Severity for the Debian-family buckets lives on the CVE, not the
// advisory. Without this every finding is stored as "unknown", which also
// silences every alert rule with a minimum severity.
meta.Enrich(results)
existing, err := services.ListFindings(ctx, sp.InstanceID, sp.ServerID)
if err != nil {
log.Printf("vulnsched: list findings %s: %v", sp.ServerID, err)
@@ -210,6 +247,10 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
return nil
}
log.Printf("vulnsched: server %s scanned: %d matches, %d existing, %d upserts, %d newly opened, %d reopened, %d fixed",
sp.ServerID, len(results), len(existing), len(diff.Upserts),
len(diff.NewlyOpened), len(diff.ReopenIDs), len(diff.FixedIDs))
s.clearPending(ctx, sp.ID, models.ScanStatusOK, now)
for i := range diff.NewlyOpened {
+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>
);
+212 -537
View File
@@ -1,17 +1,37 @@
"use client";
import { useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate, Inventory } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { api, GenerateKeyOptions, ServerStatus, vulnerabilities, workloads as workloadsApi } from "@/lib/api";
import { Badge } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
import { TagChips } from "@/components/servers/TagChips";
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
import { WorkloadList } from "@/components/workloads/WorkloadList";
import { useAuth } from "@/components/AuthProvider";
import { GenerateKeyModal } from "@/components/servers/GenerateKeyModal";
import { VitalsRail } from "@/components/servers/VitalsRail";
import { ServerTabs, type TabId, type TabSpec } from "@/components/servers/ServerTabs";
import { ServerActionsMenu, type ServerAction } from "@/components/servers/ServerActionsMenu";
import { ArrowUpCircleIcon, ConsoleIcon, KeyIcon, RefreshIcon, ShieldIcon, TrashIcon } from "@/components/servers/icons";
import { OverviewTab, type Attention } from "@/components/servers/tabs/OverviewTab";
import { AccessTab } from "@/components/servers/tabs/AccessTab";
import { MaintenanceTab } from "@/components/servers/tabs/MaintenanceTab";
/*
* One server, as a faceplate over five tabs.
*
* The page used to stack every panel it had — agent updater, inventory,
* details, vulnerabilities, workloads, keys — so the answer to "is this machine
* healthy" was several screens below the answer to "which agent build is on
* it". Identity, status and the four live readings now stay pinned; everything
* else is a tab, and the tab labels carry counts so a problem on a tab nobody
* is looking at still announces itself.
*/
const TAB_IDS: TabId[] = ["overview", "workloads", "security", "access", "maintenance"];
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -24,300 +44,39 @@ function statusVariant(status: ServerStatus) {
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
function formatBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
}
function UsageBar({ used, total }: { used: number; total: number }) {
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
</div>
);
}
function InventoryPanel({ inv }: { inv: Inventory }) {
return (
<Card>
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-1 flex justify-between text-sm">
<span className="text-text-secondary">CPU</span>
<span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span>
</div>
<UsageBar used={inv.cpu.usage_pct} total={100} />
<p className="mt-1 text-xs text-text-secondary">
{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}
</p>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span className="text-text-secondary">Memory</span>
<span className="text-text-primary">
{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}
</span>
</div>
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
<div className="mb-1 mt-3 flex justify-between text-sm">
<span className="text-text-secondary">Swap</span>
<span className="text-text-primary">
{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}
</span>
</div>
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
</div>
</div>
{inv.partitions && inv.partitions.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
<div className="space-y-3">
{inv.partitions.map((p) => (
<div key={p.mountpoint}>
<div className="mb-1 flex justify-between text-xs">
<span className="font-mono text-text-primary">{p.mountpoint}</span>
<span className="text-text-secondary">
{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}
</span>
</div>
<UsageBar used={p.used_bytes} total={p.total_bytes} />
</div>
))}
</div>
</div>
)}
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
</Card>
);
}
const KEY_SIZES: Record<string, number[]> = {
rsa: [2048, 3072, 4096],
ecdsa: [256, 384, 521],
};
const DEFAULT_SIZE: Record<string, number> = {
rsa: 4096,
ecdsa: 256,
};
function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
const [label, setLabel] = useState("");
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
const [keySize, setKeySize] = useState<number>(4096);
const [passphrase, setPassphrase] = useState("");
const [comment, setComment] = useState("");
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
setKeyType(t);
if (t !== "ed25519") {
setKeySize(DEFAULT_SIZE[t]);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
label: label || "generated",
key_type: keyType,
key_size: keyType !== "ed25519" ? keySize : undefined,
passphrase: passphrase || undefined,
comment: comment || undefined,
});
}
const sizes = KEY_SIZES[keyType];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
<button onClick={onClose} className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. server-deploy-key"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => handleKeyTypeChange(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t}
</button>
))}
</div>
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>}
</div>
{sizes && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
<select
value={keySize}
onChange={(e) => setKeySize(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="e.g. user@hostname"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="Optional passphrase"
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
Generate Key
</Button>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
}
function UpdatesModal({ updates, onClose, onApply, isApplying, applySuccess }: { updates: PackageUpdate[]; onClose: () => void; onApply: () => void; isApplying: boolean; applySuccess: boolean }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-2xl rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-text-primary">Available OS Updates</h2>
<p className="mt-0.5 text-sm text-text-secondary">
{updates.length} package{updates.length !== 1 ? "s" : ""} available
</p>
</div>
<button onClick={onClose} className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-80 overflow-y-auto rounded-lg border border-border">
<Table>
<Thead>
<Tr>
<Th>Package</Th>
<Th>Current</Th>
<Th>Available</Th>
</Tr>
</Thead>
<Tbody>
{updates.map((u) => (
<Tr key={u.name}>
<Td label="Package">
<span className="font-medium font-mono text-sm">{u.name}</span>
</Td>
<Td label="Current">
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
</Td>
<Td label="Available">
<span className="font-mono text-xs text-success">{u.new_version}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
</div>
<div className="mt-5 flex items-center gap-3">
<Button variant="primary" loading={isApplying} onClick={onApply}>
{applySuccess ? "Sent!" : "Apply Updates"}
</Button>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
<p className="ml-auto text-xs text-text-tertiary">Upgrade runs in the background. This may take several minutes.</p>
</div>
</div>
</div>
);
}
export default function ServerDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const serverId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [copiedUpdate, setCopiedUpdate] = useState(false);
const [updateSuccess, setUpdateSuccess] = useState(false);
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
const [applySuccess, setApplySuccess] = useState(false);
const panelsRef = useRef<HTMLDivElement>(null);
const { hasFeature } = useLicense();
// Control actions and log reads are owner|admin server-side; the UI matches
// so a member is not offered buttons the API will refuse.
const { isAdmin } = useAuth();
const consoleAllowed = hasFeature("console");
const tabParam = searchParams.get("tab") as TabId | null;
const activeTab: TabId = tabParam && TAB_IDS.includes(tabParam) ? tabParam : "overview";
/** The tab lives in the URL so an alert, a bookmark or a browser Back can
* name one. replace, not push — five tabs of history between two pages is
* a Back button that does not go back. */
function selectTab(tab: TabId) {
const next = new URLSearchParams(searchParams.toString());
if (tab === "overview") next.delete("tab");
else next.set("tab", tab);
const query = next.toString();
router.replace(query ? `?${query}` : `/servers/${serverId}`, { scroll: false });
panelsRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
}
const {
data: server,
isLoading,
@@ -328,6 +87,24 @@ export default function ServerDetailPage() {
refetchInterval: 30_000,
});
const { data: latestVersion } = useQuery({
queryKey: ["agent-latest-version"],
queryFn: () => api.getLatestAgentVersion(),
staleTime: 5 * 60_000,
});
// Both share their tab component's query key, so the count on the label and
// the list inside the tab are one fetch, not two.
const { data: findings } = useQuery({
queryKey: ["vulnerabilities", "server", serverId],
queryFn: () => vulnerabilities.forServer(serverId),
});
const { data: workloadSnapshot } = useQuery({
queryKey: ["workloads", serverId],
queryFn: () => workloadsApi.forServer(serverId),
});
const { mutate: generateKey, isPending: isGenerating } = useMutation({
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
onSuccess: () => {
@@ -337,12 +114,6 @@ export default function ServerDetailPage() {
},
});
const { data: latestVersion } = useQuery({
queryKey: ["agent-latest-version"],
queryFn: () => api.getLatestAgentVersion(),
staleTime: 5 * 60_000,
});
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
mutationFn: () => api.updateAgent(serverId),
onSuccess: () => {
@@ -355,12 +126,15 @@ export default function ServerDetailPage() {
mutationFn: () => api.applyUpdates(serverId),
onSuccess: () => {
setApplySuccess(true);
setTimeout(() => {
setApplySuccess(false);
setShowUpdatesModal(false);
}, 2000);
setTimeout(() => setApplySuccess(false), 4000);
},
});
const { mutate: refreshWorkloads } = useMutation({
mutationFn: () => workloadsApi.refresh(serverId),
onSuccess: () => setTimeout(() => queryClient.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500),
});
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteServer(serverId),
onSuccess: () => {
@@ -369,6 +143,62 @@ export default function ServerDetailPage() {
},
});
const openFindings = useMemo(() => (findings ?? []).filter((f) => f.state === "open"), [findings]);
const seriousFindings = openFindings.filter((f) => f.severity === "critical" || f.severity === "high").length;
const updateCount = server?.available_updates?.length ?? 0;
const workloadCount = workloadSnapshot?.workloads?.length ?? 0;
const activeKeys = (server?.keys ?? []).filter((a) => a.key && !a.revoked_at).length;
const agentOutOfDate = !!latestVersion && !!server?.agent_version && server.agent_version !== latestVersion.version;
const attention: Attention[] = useMemo(() => {
if (!server) return [];
const items: Attention[] = [];
for (const p of server.inventory?.partitions ?? []) {
const pct = p.total_bytes > 0 ? (p.used_bytes / p.total_bytes) * 100 : 0;
if (pct >= 90) {
items.push({
tone: "danger",
title: `${p.mountpoint} is ${pct.toFixed(0)}% full`,
detail: `${((p.total_bytes - p.used_bytes) / 1024 ** 3).toFixed(1)} GB free`,
goTo: "overview",
action: "View storage",
});
}
}
if (seriousFindings > 0) {
items.push({
tone: "danger",
title: `${seriousFindings} critical or high severity finding${seriousFindings !== 1 ? "s" : ""}`,
detail: openFindings
.slice(0, 3)
.map((f) => f.package_name)
.join(", "),
goTo: "security",
action: "Review",
});
}
if (updateCount > 0) {
items.push({
tone: "warning",
title: `${updateCount} OS update${updateCount !== 1 ? "s" : ""} pending`,
detail: server.updates_checked_at ? `checked ${new Date(server.updates_checked_at).toLocaleString()}` : "never checked",
goTo: "maintenance",
action: "Apply",
});
}
if (agentOutOfDate) {
items.push({
tone: "warning",
title: `Agent is behind v${latestVersion!.version}`,
detail: `running v${server.agent_version}`,
goTo: "maintenance",
action: "Update",
});
}
return items;
}, [server, seriousFindings, openFindings, updateCount, agentOutOfDate, latestVersion]);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
@@ -380,259 +210,104 @@ export default function ServerDetailPage() {
if (error || !server) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
<div className="rounded border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
</div>
);
}
const protocols = server.console_protocols ?? [];
const actions: ServerAction[] = [
...(protocols.length > 0
? protocols.map((p, i) => ({
group: i === 0 ? "Console" : undefined,
label: `Connect ${p.toUpperCase()}`,
icon: <ConsoleIcon />,
href: `/servers/${serverId}/console?protocol=${p}`,
// Offered disabled rather than hidden when the licence does not
// include the console: a customer cannot buy what they cannot see.
disabled: !consoleAllowed,
title: consoleAllowed ? undefined : "Upgrade to use the browser console",
}))
: [{ group: "Console", label: "No console protocol", icon: <ConsoleIcon />, disabled: true, title: "This host reports no console protocol" }]),
{ group: "Manage", label: "Generate SSH key", icon: <KeyIcon />, onSelect: () => setShowGenerateModal(true), separated: true },
{ label: "Refresh workloads", icon: <RefreshIcon />, onSelect: () => refreshWorkloads() },
{
label: "Update agent",
icon: <ArrowUpCircleIcon />,
onSelect: () => triggerUpdate(),
disabled: server.status !== "active" || isUpdating,
title: server.status !== "active" ? "Agent must be online to update" : undefined,
},
...(updateCount > 0 ? [{ label: `Apply ${updateCount} OS update${updateCount !== 1 ? "s" : ""}`, icon: <ShieldIcon />, onSelect: () => selectTab("maintenance") }] : []),
{ label: "Remove server", icon: <TrashIcon />, onSelect: () => selectTab("maintenance"), danger: true, separated: true },
];
const tabs: TabSpec[] = [
{ id: "overview", label: "Overview", count: attention.length, tone: attention.some((a) => a.tone === "danger") ? "danger" : "warning" },
{ id: "workloads", label: "Workloads", count: workloadCount },
{ id: "security", label: "Security", count: openFindings.length, tone: seriousFindings > 0 ? "danger" : "neutral" },
{ id: "access", label: "Access", count: activeKeys },
{ id: "maintenance", label: "Maintenance", count: updateCount, tone: "warning" },
];
return (
<div className="p-4 sm:p-6 lg:p-8">
<>
{showGenerateModal && <GenerateKeyModal onClose={() => setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />}
{showUpdatesModal && server.available_updates && (
<UpdatesModal updates={server.available_updates} onClose={() => setShowUpdatesModal(false)} onApply={() => applyUpdates()} isApplying={isApplying} applySuccess={applySuccess} />
)}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-3">
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
Servers
</Link>
</div>
<div className="mt-2 flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
</div>
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
<div className="mt-2">
<TagChips serverId={server.server_id} tags={server.tags} editable />
{/*
* The faceplate sticks under whichever chrome is above it: the mobile
* top bar below lg, nothing above it. z-20 keeps it under that bar
* (z-40) and under the nav drawer (z-50). The scroll container is
* AppShell's column, not the window, which is what sticky anchors to.
*/}
<div className="sticky top-14 z-20 border-b border-border bg-background/90 px-4 pt-4 backdrop-blur sm:px-6 lg:top-0 lg:px-8">
<Link href="/servers" className="text-sm text-text-secondary transition-colors hover:text-text-primary">
Servers
</Link>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
<span className="font-mono text-sm text-text-secondary">{server.ip_address}</span>
<div className="ml-auto">
<ServerActionsMenu actions={actions} />
</div>
</div>
<div className="flex flex-wrap gap-2">
{/* Rendered disabled rather than hidden when the licence does not
include the console: a customer cannot buy what they cannot see,
and a feature that vanishes reads as a bug. */}
{server.console_protocols?.map((p) => (
<Link
key={p}
href={consoleAllowed ? `/servers/${serverId}/console?protocol=${p}` : "#"}
aria-disabled={!consoleAllowed}
title={consoleAllowed ? undefined : "Upgrade to use the browser console"}
onClick={(e) => {
if (!consoleAllowed) e.preventDefault();
}}
className={consoleAllowed ? undefined : "pointer-events-none opacity-50"}
>
<Button variant="secondary" disabled={!consoleAllowed}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
/>
</svg>
Connect {p.toUpperCase()}
</Button>
</Link>
))}
{server.available_updates && server.available_updates.length > 0 && (
<Button variant="secondary" onClick={() => setShowUpdatesModal(true)} className="border-warning/50 text-warning hover:border-warning hover:bg-warning/10">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
{server.available_updates.length} OS Update{server.available_updates.length !== 1 ? "s" : ""}
</Button>
)}
<Button variant="secondary" onClick={() => setShowGenerateModal(true)}>
<svg className="h-4 w-4" 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>
Generate SSH Key
</Button>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Remove Server
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">Are you sure?</span>
<Button variant="danger" loading={isDeleting} onClick={() => deleteServer()}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
<div className="mt-2">
<TagChips serverId={server.server_id} tags={server.tags} editable />
</div>
<VitalsRail server={server} agentUpToDate={latestVersion && server.agent_version ? !agentOutOfDate : undefined} />
<div className="mt-3">
<ServerTabs tabs={tabs} active={activeTab} onSelect={selectTab} />
</div>
</div>
<div ref={panelsRef} className="p-4 sm:p-6 lg:p-8">
<div role="tabpanel" id={`server-panel-${activeTab}`} aria-labelledby={`server-tab-${activeTab}`}>
{activeTab === "overview" && <OverviewTab server={server} attention={attention} onGoTo={selectTab} />}
{activeTab === "workloads" && <WorkloadList serverId={server.server_id} canControl={isAdmin} />}
{activeTab === "security" && <ServerVulnerabilities serverId={server.server_id} />}
{activeTab === "access" && <AccessTab server={server} onGenerateKey={() => setShowGenerateModal(true)} />}
{activeTab === "maintenance" && (
<MaintenanceTab
server={server}
latestVersion={latestVersion?.version}
onApplyUpdates={() => applyUpdates()}
isApplying={isApplying}
applySuccess={applySuccess}
onUpdateAgent={() => triggerUpdate()}
isUpdatingAgent={isUpdating}
updateAgentSuccess={updateSuccess}
onDelete={() => deleteServer()}
isDeleting={isDeleting}
/>
)}
</div>
</div>
<div className="mb-6">
<Card>
<CardHeader>
<CardTitle>Update Agent</CardTitle>
</CardHeader>
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
<div>
<span className="text-text-secondary">Installed: </span>
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
</div>
<div>
<span className="text-text-secondary">Latest: </span>
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion.version}` : "n/a"}</span>
</div>
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && <Badge variant="warning">update available</Badge>}
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && <Badge variant="success">up to date</Badge>}
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="primary"
loading={isUpdating}
onClick={() => triggerUpdate()}
disabled={server.status !== "active"}
title={server.status !== "active" ? "Agent must be online to update" : undefined}
>
{updateSuccess ? "Update Sent!" : "Update Agent"}
</Button>
<div className="relative flex-1 min-w-0 overflow-x-auto rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm">
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span>{" "}
<span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
<button
onClick={async () => {
await navigator.clipboard.writeText(api.getUpdateCommand(server.os_info));
setCopiedUpdate(true);
setTimeout(() => setCopiedUpdate(false), 2000);
}}
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
</div>
</Card>
</div>
{server.inventory && (
<div className="mb-6">
<InventoryPanel inv={server.inventory} />
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Server ID</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">OS</dt>
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Agent Version</dt>
<dd className="mt-0.5 font-mono text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Last Seen</dt>
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Registered</dt>
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
</div>
</dl>
</Card>
<div className="lg:col-span-2">
<ServerVulnerabilities serverId={server.server_id} />
</div>
<div className="lg:col-span-3">
<WorkloadList serverId={server.server_id} canControl={isAdmin} />
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">
Installed SSH Keys
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">{server.keys?.filter((k) => !k.revoked_at).length ?? 0} active</span>
</h2>
<Link href="/keys">
<Button variant="ghost" size="sm">
Manage Keys
</Button>
</Link>
</div>
{!server.keys || server.keys.length === 0 ? (
<div className="py-16 text-center">
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
<Link href="/keys">
<Button variant="secondary" size="sm" className="mt-3">
Assign a key
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Label</Th>
<Th>Fingerprint</Th>
<Th>Source</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{server.keys
.filter((a) => a.key)
.map((assignment) => (
<Tr key={assignment.key_id}>
<Td label="Label">
<span className="font-medium">{assignment.key.label}</span>
</Td>
<Td label="Fingerprint">
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
</Td>
<Td label="Source">
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
</Td>
<Td label="Assigned">
<span className="text-text-secondary text-xs">{formatDate(assignment.assigned_at)}</span>
</Td>
<Td>
<Link href={`/keys/${assignment.key_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
</div>
</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
+51 -46
View File
@@ -26,17 +26,33 @@ import { groupByPackage } from "@/lib/vulnPackages";
const STATES: FindingState[] = ["open", "accepted", "fixed"];
/*
* The fix filter. "Unfixable" is not a synonym for "ignorable": those findings
* are the ones whose action is to remove the package, disable the service or
* move off an end-of-life release, and they are invisible in a list sorted for
* patching. Splitting them is what lets the patchable list be worked top to
* bottom without them quietly disappearing.
*/
const FIX_FILTERS: { key: string; label: string; hasFix: boolean | undefined }[] = [
{ key: "all", label: "All", hasFix: undefined },
{ key: "fixable", label: "Fix available", hasFix: true },
{ key: "nofix", label: "No fix", hasFix: false },
];
export default function VulnerabilitiesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const [state, setState] = useState<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
const [fixFilter, setFixFilter] = useState("all");
const [accepting, setAccepting] = useState<VulnFinding | null>(null);
const hasFix = FIX_FILTERS.find((f) => f.key === fixFilter)?.hasFix;
const groups = useQuery({
queryKey: ["vulnerabilities", state, severity],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined }),
queryKey: ["vulnerabilities", state, severity, fixFilter],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined, hasFix }),
});
const summary = useQuery({
@@ -99,7 +115,7 @@ export default function VulnerabilitiesPage() {
</div>
{isAdmin && (
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
Rescan fleet
Rescan
</Button>
)}
</div>
@@ -120,9 +136,7 @@ export default function VulnerabilitiesPage() {
paged.reset();
}}
aria-pressed={severity === s}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${
severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"
}`}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"}`}
>
<SeverityBadge severity={s} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
@@ -130,7 +144,7 @@ export default function VulnerabilitiesPage() {
))}
</div>
<div className="mb-4 flex gap-2">
<div className="mb-4 flex flex-wrap items-center gap-2">
{STATES.map((s) => (
<button
key={s}
@@ -139,20 +153,30 @@ export default function VulnerabilitiesPage() {
paged.reset();
}}
aria-pressed={state === s}
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"}`}
>
{s}
</button>
))}
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
{FIX_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => {
setFixFilter(f.key);
paged.reset();
}}
aria-pressed={fixFilter === f.key}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${fixFilter === f.key ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"}`}
>
{f.label}
</button>
))}
</div>
{groups.error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(groups.error as Error).message}
</div>
)}
{groups.error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(groups.error as Error).message}</div>}
<Card padding={false}>
{groups.isLoading ? (
@@ -162,49 +186,30 @@ export default function VulnerabilitiesPage() {
) : packages.length > 0 ? (
<>
{paged.slice.map((g) => (
<PackageRow
key={g.package_name}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
<PackageRow key={g.package_name} group={g} serverName={serverName} canAct={isAdmin} onAccept={setAccepting} onUnaccept={(f) => unaccept.mutate(f.id)} onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)} applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined} />
))}
<Pagination
page={paged.page}
pageCount={paged.pageCount}
size={paged.size}
total={paged.total}
onPage={paged.setPage}
onSize={paged.setSize}
unit="packages"
/>
<Pagination page={paged.page} pageCount={paged.pageCount} size={paged.size} total={paged.total} onPage={paged.setPage} onSize={paged.setSize} unit="packages" />
</>
) : (
<div className="px-6 py-14 text-center">
<p className="text-[15px] font-semibold text-text-primary">
No {state} findings{severity ? ` at ${severity} severity` : ""}.
No {state} findings{severity ? ` at ${severity} severity` : ""}
{hasFix === true ? " with a fix available" : hasFix === false ? " without a vendor fix" : ""}.
</p>
{/* Named explicitly, because "no findings" under a filter
the reader has forgotten setting reads as a clean
fleet — the one claim this page must never make by
accident. */}
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">
Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported
on its own page rather than counted as clean here.
{hasFix !== undefined
? "This is a filtered view. Switch to All to see every finding in this state."
: "Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own page rather than counted as clean here."}
</p>
</div>
)}
</Card>
{accepting && (
<AcceptDialog
finding={accepting}
serverName={serverName(accepting.server_id)}
pending={accept.isPending}
onClose={() => setAccepting(null)}
onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })}
/>
)}
{accepting && <AcceptDialog finding={accepting} serverName={serverName(accepting.server_id)} pending={accept.isPending} onClose={() => setAccepting(null)} onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })} />}
</div>
);
}
+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>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { GenerateKeyOptions } from "@/lib/api";
import { Button } from "@/components/ui";
const KEY_SIZES: Record<string, number[]> = {
rsa: [2048, 3072, 4096],
ecdsa: [256, 384, 521],
};
const DEFAULT_SIZE: Record<string, number> = {
rsa: 4096,
ecdsa: 256,
};
export function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
const [label, setLabel] = useState("");
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
const [keySize, setKeySize] = useState<number>(4096);
const [passphrase, setPassphrase] = useState("");
const [comment, setComment] = useState("");
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
setKeyType(t);
if (t !== "ed25519") {
setKeySize(DEFAULT_SIZE[t]);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
label: label || "generated",
key_type: keyType,
key_size: keyType !== "ed25519" ? keySize : undefined,
passphrase: passphrase || undefined,
comment: comment || undefined,
});
}
const sizes = KEY_SIZES[keyType];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
<button onClick={onClose} className="rounded-md p-1 text-text-secondary transition-colors hover:text-text-primary">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. server-deploy-key"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => handleKeyTypeChange(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t}
</button>
))}
</div>
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>}
</div>
{sizes && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
<select
value={keySize}
onChange={(e) => setKeySize(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="e.g. user@hostname"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="Optional passphrase"
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
Generate Key
</Button>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import { Button } from "@/components/ui";
/*
* Every action this page can take, behind one control.
*
* The header used to carry up to six buttons — a Connect per console protocol,
* OS updates, Generate key, Remove — and which of them appeared depended on the
* server, so the row an operator reached for moved between machines. One button
* in one place is worth more than a shortcut that is sometimes there.
*
* An action the licence or the host does not allow is rendered disabled with a
* reason rather than hidden: a customer cannot buy what they cannot see, and a
* control that vanishes reads as a bug.
*/
export interface ServerAction {
/** Grouping heading. Items sharing one carry it once, on the first. */
group?: string;
label: string;
icon: React.ReactNode;
onSelect?: () => void;
href?: string;
disabled?: boolean;
/** Why it is disabled, or what it will do. Shown as the native tooltip. */
title?: string;
danger?: boolean;
/** Draws a rule above this item. */
separated?: boolean;
}
export function ServerActionsMenu({ actions }: { actions: ServerAction[] }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const router = useRouter();
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
menuRef.current?.querySelector<HTMLElement>("[role=menuitem]:not([aria-disabled=true])")?.focus();
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function onMenuKeyDown(e: React.KeyboardEvent) {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>("[role=menuitem]:not([aria-disabled=true])") ?? [])];
const i = items.indexOf(document.activeElement as HTMLElement);
items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
}
function run(action: ServerAction) {
if (action.disabled) return;
setOpen(false);
buttonRef.current?.focus();
if (action.href) router.push(action.href);
action.onSelect?.();
}
return (
<div ref={wrapRef} className="relative">
<Button ref={buttonRef} variant="primary" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
Actions
<svg className={clsx("h-4 w-4 transition-transform", open && "rotate-180")} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
</Button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label="Server actions"
onKeyDown={onMenuKeyDown}
className="absolute right-0 z-50 mt-1.5 w-60 rounded border border-border bg-surface p-1 shadow-panel"
>
{actions.map((action, i) => (
<div key={action.label}>
{action.separated && i > 0 && <div className="my-1 h-px bg-border-soft" />}
{action.group && <p className="px-2 pb-1 pt-1.5 font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-tertiary">{action.group}</p>}
<button
type="button"
role="menuitem"
aria-disabled={action.disabled}
disabled={action.disabled}
title={action.title}
onClick={() => run(action)}
className={clsx(
"flex w-full items-center gap-2.5 rounded px-2 py-2 text-left text-sm font-medium transition-colors",
action.disabled
? "cursor-not-allowed text-text-tertiary"
: action.danger
? "text-danger hover:bg-danger/10"
: "text-text-primary hover:bg-surface-2 [&>svg]:hover:text-accent",
!action.disabled && !action.danger && "[&>svg]:text-text-tertiary",
action.danger && "[&>svg]:text-danger",
action.disabled && "[&>svg]:text-text-tertiary",
)}
>
{action.icon}
{action.label}
</button>
</div>
))}
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useRef } from "react";
import { clsx } from "clsx";
/*
* The tab bar under the faceplate.
*
* Below md it is a select instead. Five tabs do not fit a phone, and the two
* usual answers are both worse: wrapping to a second row changes the height of
* the sticky header as the selection moves, and a horizontally scrolling strip
* hides tabs off the right edge with nothing saying they are there. A select
* shows every section and its count in one list, and it is the platform's own
* picker, so it needs no scroll affordance of ours.
*
* Counts live on the labels because that is the only way an operator learns
* there is something wrong on a tab they are not looking at. A count with a
* tone is still labelled by its tab name, so tone is never the whole message —
* and in the select, where tone cannot survive, the count still does.
*/
export type TabId = "overview" | "workloads" | "security" | "access" | "maintenance";
export interface TabSpec {
id: TabId;
label: string;
count?: number;
tone?: "neutral" | "warning" | "danger";
}
export function ServerTabs({ tabs, active, onSelect }: { tabs: TabSpec[]; active: TabId; onSelect: (id: TabId) => void }) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
function onKeyDown(e: React.KeyboardEvent) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = tabs.findIndex((t) => t.id === active);
const next = tabs[(i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length];
onSelect(next.id);
refs.current[next.id]?.focus();
}
const current = tabs.find((t) => t.id === active);
return (
<>
{/* Phone: the whole set in one picker, sitting on the same row as the
section it names so the header keeps its height. */}
<div className="pb-3 md:hidden">
<label htmlFor="server-tab-select" className="sr-only">
Server section
</label>
<div className="relative">
<select
id="server-tab-select"
value={active}
onChange={(e) => onSelect(e.target.value as TabId)}
className="w-full appearance-none rounded border border-border bg-surface-2 py-2 pl-3 pr-9 text-sm font-semibold text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
{tab.label}
{tab.count !== undefined && tab.count > 0 ? ` (${tab.count})` : ""}
</option>
))}
</select>
<svg className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
</div>
{current?.count !== undefined && current.count > 0 && current.tone && current.tone !== "neutral" && (
<p className={clsx("mt-1.5 text-xs", current.tone === "danger" ? "text-danger" : "text-warning")}>
{current.count} item{current.count !== 1 ? "s" : ""} in {current.label.toLowerCase()}
</p>
)}
</div>
<div role="tablist" aria-label="Server sections" onKeyDown={onKeyDown} className="-mb-px hidden gap-1 md:flex">
{tabs.map((tab) => {
const isActive = tab.id === active;
return (
<button
key={tab.id}
ref={(el) => {
refs.current[tab.id] = el;
}}
type="button"
role="tab"
id={`server-tab-${tab.id}`}
aria-selected={isActive}
aria-controls={`server-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(tab.id)}
className={clsx(
"flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-3 py-2.5 text-sm transition-colors",
isActive ? "border-accent font-semibold text-text-primary" : "border-transparent font-medium text-text-secondary hover:text-text-primary",
)}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span
className={clsx(
"rounded-full border px-1.5 py-px font-mono text-[0.62rem] tabular-nums",
tab.tone === "danger"
? "border-danger/40 bg-danger/10 text-danger"
: tab.tone === "warning"
? "border-warning/40 bg-warning/10 text-warning"
: "border-border bg-surface-2 text-text-secondary",
)}
>
{tab.count}
</span>
)}
</button>
);
})}
</div>
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { clsx } from "clsx";
import { Inventory, Server } from "@/lib/api";
import { formatBytes, relativeAge } from "./format";
/*
* The four numbers an operator opens a server for, kept above the tabs so they
* are true on every tab rather than living inside one of them. This is the only
* part of the page that does not move when the tab changes.
*
* A meter is a hairline, not a bar: four of them across the top would otherwise
* out-shout the hostname, and the number beside each is the value being read —
* the meter only says how close to full it is.
*/
function Meter({ pct }: { pct: number }) {
const clamped = Math.max(0, Math.min(100, pct));
return (
<div className="mt-2 h-[3px] w-full overflow-hidden rounded-full bg-well">
<div
className={clsx("h-full rounded-full transition-[width] duration-500", clamped >= 90 ? "bg-danger" : clamped >= 75 ? "bg-warning" : "bg-accent")}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
function Vital({ label, value, pct, sub }: { label: string; value: string; pct?: number; sub?: string }) {
return (
<div className="min-w-0 bg-surface px-4 py-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-secondary">{label}</span>
<span className="font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</span>
</div>
{pct !== undefined ? <Meter pct={pct} /> : <div className="mt-2 h-[3px] w-full rounded-full bg-well" />}
{sub && <p className="mt-1.5 truncate text-xs text-text-tertiary">{sub}</p>}
</div>
);
}
/** The partition an operator means by "the disk": the root filesystem, or the
* fullest one if there is no root — a Windows agent reports no `/`. */
function primaryPartition(inv: Inventory) {
const parts = inv.partitions ?? [];
if (parts.length === 0) return undefined;
return parts.find((p) => p.mountpoint === "/") ?? parts.reduce((worst, p) => (p.used_bytes / (p.total_bytes || 1) > worst.used_bytes / (worst.total_bytes || 1) ? p : worst));
}
export function VitalsRail({ server, agentUpToDate }: { server: Server; agentUpToDate?: boolean }) {
const inv = server.inventory;
const disk = inv ? primaryPartition(inv) : undefined;
const memPct = inv && inv.memory.total_bytes > 0 ? (inv.memory.used_bytes / inv.memory.total_bytes) * 100 : 0;
const diskPct = disk && disk.total_bytes > 0 ? (disk.used_bytes / disk.total_bytes) * 100 : 0;
const agentSub = server.agent_version ? `agent v${server.agent_version}${agentUpToDate === undefined ? "" : agentUpToDate ? " · up to date" : " · update available"}` : "agent version unknown";
return (
// One hairline grid rather than four cards: these are readings off one
// machine, and four bordered panels would read as four subjects.
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded border border-border-soft bg-border-soft lg:grid-cols-4">
{inv ? (
<>
<Vital
label="CPU"
value={`${inv.cpu.usage_pct.toFixed(0)}%`}
pct={inv.cpu.usage_pct}
sub={[inv.cpu.cores ? `${inv.cpu.cores} cores` : null, inv.cpu.load1 !== undefined ? `load ${inv.cpu.load1.toFixed(2)}` : null].filter(Boolean).join(" · ") || inv.cpu.model}
/>
<Vital
label="Memory"
value={`${formatBytes(inv.memory.used_bytes)} / ${formatBytes(inv.memory.total_bytes)}`}
pct={memPct}
sub={inv.swap_total_bytes > 0 ? `swap ${formatBytes(inv.swap_used_bytes)} / ${formatBytes(inv.swap_total_bytes)}` : "no swap"}
/>
<Vital
label={disk ? `Disk ${disk.mountpoint}` : "Disk"}
value={disk ? `${diskPct.toFixed(0)}%` : "—"}
pct={disk ? diskPct : undefined}
sub={disk ? `${formatBytes(disk.used_bytes)} / ${formatBytes(disk.total_bytes)}${disk.fstype ? ` · ${disk.fstype}` : ""}` : "no partitions reported"}
/>
</>
) : (
<>
<Vital label="CPU" value="—" sub="no metrics reported" />
<Vital label="Memory" value="—" sub="no metrics reported" />
<Vital label="Disk" value="—" sub="no metrics reported" />
</>
)}
<Vital label="Last seen" value={relativeAge(server.last_seen)} pct={server.status === "active" ? 100 : 0} sub={agentSub} />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
/** Formatting shared by the server detail panels. One copy, because the rail
* and the storage panel must round the same bytes the same way. */
export function formatBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
}
export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
export function relativeAge(iso?: string): string {
if (!iso) return "never";
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${Math.round(secs)}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
if (secs < 86_400) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86_400)}d ago`;
}
+72
View File
@@ -0,0 +1,72 @@
/** The line icons the server actions menu uses. Heroicons outline, 1.5 stroke,
* the same set and weight the sidebar draws. */
const props = { className: "h-4 w-4 shrink-0 transition-colors", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5 } as const;
export function ConsoleIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
/>
</svg>
);
}
export function KeyIcon() {
return (
<svg {...props}>
<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>
);
}
export function ArrowUpCircleIcon() {
return (
<svg {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75l-3 3m3-3l3 3m-3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
export function ShieldIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.998 2.25a.75.75 0 01.298.062l7.5 3.214a.75.75 0 01.454.69v5.034c0 4.63-2.94 8.75-7.5 10.25a.75.75 0 01-.5 0c-4.56-1.5-7.5-5.62-7.5-10.25V6.216a.75.75 0 01.454-.69l7.5-3.214a.75.75 0 01.294-.062z"
/>
</svg>
);
}
export function TrashIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166M18.16 19.673A2.25 2.25 0 0115.916 21.75H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-11 .397c.34-.059.68-.114 1.022-.165M15.75 5.393v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
}
export function RefreshIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992V4.356m-4.992 4.992l3.181-3.03a8.25 8.25 0 00-13.803 3.03M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.03a8.25 8.25 0 0013.803-3.03"
/>
</svg>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { formatDate } from "../format";
export function AccessTab({ server, onGenerateKey }: { server: ServerWithKeys; onGenerateKey: () => void }) {
const assignments = (server.keys ?? []).filter((a) => a.key);
const active = assignments.filter((a) => !a.revoked_at).length;
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="flex items-center gap-2 text-lg font-semibold text-text-primary">
Installed SSH keys
<span className="rounded-full bg-surface-2 px-2 py-0.5 font-mono text-[0.68rem] tabular-nums text-text-secondary">{active} active</span>
</h2>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onGenerateKey}>
Generate key
</Button>
<Link href="/keys">
<Button variant="ghost" size="sm">
Manage keys
</Button>
</Link>
</div>
</div>
{assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-text-secondary">No keys assigned to this server.</p>
<Link href="/keys">
<Button variant="secondary" size="sm" className="mt-3">
Assign a key
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Label</Th>
<Th>Fingerprint</Th>
<Th>Source</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{assignments.map((assignment) => (
<Tr key={assignment.key_id}>
<Td label="Label">
<span className="font-medium">{assignment.key.label}</span>
</Td>
<Td label="Fingerprint">
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
</Td>
<Td label="Source">
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
</Td>
<Td label="Assigned">
<span className="text-xs text-text-secondary">{formatDate(assignment.assigned_at)}</span>
</Td>
<Td>
<Link href={`/keys/${assignment.key_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { api, ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
/*
* Everything that changes what is installed on the machine: its OS packages,
* its agent, and its existence.
*
* The OS update list is a panel here rather than the modal it used to be. A
* modal made the list a detour off a header button; the work of patching a
* server is the reason this tab exists, so the list is the tab.
*/
export function MaintenanceTab({
server,
latestVersion,
onApplyUpdates,
isApplying,
applySuccess,
onUpdateAgent,
isUpdatingAgent,
updateAgentSuccess,
onDelete,
isDeleting,
}: {
server: ServerWithKeys;
latestVersion?: string;
onApplyUpdates: () => void;
isApplying: boolean;
applySuccess: boolean;
onUpdateAgent: () => void;
isUpdatingAgent: boolean;
updateAgentSuccess: boolean;
onDelete: () => void;
isDeleting: boolean;
}) {
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const updates = server.available_updates ?? [];
const command = api.getUpdateCommand(server.os_info);
const isWindows = server.os_info?.toLowerCase().includes("windows");
const agentCurrent = !!latestVersion && !!server.agent_version && server.agent_version === latestVersion;
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">OS updates</h2>
{updates.length > 0 ? <Badge variant="warning">{updates.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
</div>
{updates.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">No pending package updates. The agent checks hourly.</p>
) : (
<>
{/* Capped so a host with 400 pending packages does not make the
Apply button a scroll away. The count is on the badge. */}
<div className="max-h-80 overflow-y-auto">
<Table>
<Thead>
<Tr>
<Th>Package</Th>
<Th>Current</Th>
<Th>Available</Th>
</Tr>
</Thead>
<Tbody>
{updates.map((u) => (
<Tr key={u.name}>
<Td label="Package">
<span className="font-mono text-sm font-medium">{u.name}</span>
</Td>
<Td label="Current">
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
</Td>
<Td label="Available">
<span className="font-mono text-xs text-success">{u.new_version}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border px-6 py-4">
<Button variant="primary" loading={isApplying} onClick={onApplyUpdates} disabled={server.status !== "active"} title={server.status !== "active" ? "Agent must be online to apply updates" : undefined}>
{applySuccess ? "Sent!" : "Apply updates"}
</Button>
<p className="text-xs text-text-tertiary">Upgrade runs in the background and may take several minutes.</p>
</div>
</>
)}
</Card>
<div className="space-y-6">
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Agent</h2>
{latestVersion && server.agent_version && <Badge variant={agentCurrent ? "success" : "warning"}>{agentCurrent ? "up to date" : "update available"}</Badge>}
</div>
<div className="space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-text-secondary">Installed: </span>
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
</div>
<div>
<span className="text-text-secondary">Latest: </span>
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion}` : "n/a"}</span>
</div>
</div>
<div className="relative overflow-x-auto rounded border border-border bg-well px-4 py-2.5 font-mono text-sm">
<span className="text-accent">{isWindows ? "PS>" : "$"}</span> <span className="text-text-primary">{command}</span>
<button
onClick={async () => {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="absolute right-2 top-1.5 rounded border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
<Button
variant="primary"
loading={isUpdatingAgent}
onClick={onUpdateAgent}
disabled={server.status !== "active"}
title={server.status !== "active" ? "Agent must be online to update" : undefined}
>
{updateAgentSuccess ? "Update sent!" : "Update agent"}
</Button>
</div>
</Card>
<Card padding={false} className="border-danger/30">
<div className="border-b border-danger/30 px-6 py-4">
<h2 className="text-lg font-semibold text-danger">Remove server</h2>
</div>
<div className="space-y-4 px-6 py-5">
<p className="text-sm text-text-secondary">
Deletes this server and its history from Vantage. The agent stays installed on the machine and keeps trying to connect until you uninstall it there.
</p>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Remove server
</Button>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-danger">Remove {server.hostname}?</span>
<Button variant="danger" loading={isDeleting} onClick={onDelete}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
</div>
</Card>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { clsx } from "clsx";
import { ServerWithKeys } from "@/lib/api";
import { Card, CardHeader, CardTitle } from "@/components/ui";
import { formatBytes, formatDate } from "../format";
import type { TabId } from "../ServerTabs";
/*
* Overview answers one question: is anything wrong with this machine, and where
* do I go about it. The detail lives on the other tabs — everything here either
* states a fact about the host or points at the tab that can act on it.
*/
export interface Attention {
tone: "danger" | "warning";
title: string;
detail: string;
/** The tab that can do something about it. */
goTo: TabId;
action: string;
}
function StoragePanel({ server }: { server: ServerWithKeys }) {
const partitions = server.inventory?.partitions ?? [];
return (
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Storage</h2>
<span className="font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary">
{partitions.length} partition{partitions.length !== 1 ? "s" : ""}
</span>
</div>
{partitions.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">No partitions reported. The agent sends a full inventory every 15 minutes.</p>
) : (
<div className="space-y-4 px-6 py-5">
{partitions.map((p) => {
const pct = p.total_bytes > 0 ? (p.used_bytes / p.total_bytes) * 100 : 0;
return (
<div key={p.mountpoint}>
<div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="font-mono text-sm text-text-primary">{p.mountpoint}</span>
<span className="font-mono text-xs text-text-secondary">
{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)}
{p.fstype ? ` · ${p.fstype}` : ""}
</span>
</div>
<div className="h-[3px] w-full overflow-hidden rounded-full bg-well">
<div className={clsx("h-full rounded-full", pct >= 90 ? "bg-danger" : pct >= 75 ? "bg-warning" : "bg-accent")} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
</div>
);
})}
</div>
)}
</Card>
);
}
function Fact({ term, children, mono = true }: { term: string; children: React.ReactNode; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-4 border-b border-border-soft py-2.5 last:border-b-0">
<dt className="shrink-0 text-xs text-text-secondary">{term}</dt>
<dd className={clsx("min-w-0 break-all text-right text-sm text-text-primary", mono && "font-mono text-xs")}>{children}</dd>
</div>
);
}
function MachinePanel({ server }: { server: ServerWithKeys }) {
return (
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Machine</h2>
</div>
<dl className="px-6 py-2">
<Fact term="OS" mono={false}>
{server.os_info || "unknown"}
</Fact>
{server.inventory?.kernel && <Fact term="Kernel">{server.inventory.kernel}</Fact>}
{server.inventory?.cpu.model && <Fact term="CPU">{server.inventory.cpu.model}</Fact>}
<Fact term="Agent version">{server.agent_version ? `v${server.agent_version}` : "unknown"}</Fact>
<Fact term="Last seen" mono={false}>
{server.last_seen ? formatDate(server.last_seen) : "Never"}
</Fact>
<Fact term="Registered" mono={false}>
{formatDate(server.created_at)}
</Fact>
<Fact term="Server ID">{server.server_id}</Fact>
</dl>
</Card>
);
}
function AttentionPanel({ items, onGoTo }: { items: Attention[]; onGoTo: (tab: TabId) => void }) {
return (
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<CardHeader className="mb-0">
<CardTitle className="text-lg font-semibold">Needs attention</CardTitle>
</CardHeader>
</div>
{items.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-success">Nothing outstanding on this server.</p>
) : (
<ul className="divide-y divide-border-soft">
{items.map((item) => (
<li key={item.title} className="flex flex-wrap items-center justify-between gap-3 px-6 py-3.5">
<div className="flex min-w-0 items-start gap-3">
{/* The dot is recognition, never the message — the title says
what is wrong on its own. */}
<span className={clsx("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", item.tone === "danger" ? "bg-danger" : "bg-warning")} />
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary">{item.title}</p>
<p className="font-mono text-xs text-text-secondary">{item.detail}</p>
</div>
</div>
<button type="button" onClick={() => onGoTo(item.goTo)} className="text-sm font-semibold text-accent transition-colors hover:text-accent-hover">
{item.action}
</button>
</li>
))}
</ul>
)}
</Card>
);
}
export function OverviewTab({ server, attention, onGoTo }: { server: ServerWithKeys; attention: Attention[]; onGoTo: (tab: TabId) => void }) {
return (
<div className="space-y-6">
<AttentionPanel items={attention} onGoTo={onGoTo} />
{/* Collapses at xl, not lg: the 240px sidebar leaves a 1280px laptop
about 1010px, which is not enough for a two-thirds split. */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div className="xl:col-span-2">
<StoragePanel server={server} />
</div>
<MachinePanel server={server} />
</div>
</div>
);
}
+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>
);
}
+4 -1
View File
@@ -996,11 +996,14 @@ export interface VulnAlertRuleInput {
}
export const vulnerabilities = {
list(params?: { severity?: string; state?: string; server?: string; tags?: Record<string, string> }): Promise<VulnGroup[]> {
list(params?: { severity?: string; state?: string; server?: string; hasFix?: boolean; tags?: Record<string, string> }): Promise<VulnGroup[]> {
const q = new URLSearchParams();
if (params?.severity) q.set("severity", params.severity);
if (params?.state) q.set("state", params.state);
if (params?.server) q.set("server", params.server);
// Explicitly undefined-checked: `false` is a real selection here (the
// unfixable set), so a truthiness test would silently drop it.
if (params?.hasFix !== undefined) q.set("has_fix", String(params.hasFix));
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
const qs = q.toString();
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
+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,
},
},
});
File diff suppressed because one or more lines are too long