Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe4ba5999 | ||
|
|
e434beec7a | ||
|
|
fe1dfe472a | ||
|
|
0cfaf6670c | ||
|
|
4d67341ba5 | ||
|
|
1fa9160c59 | ||
|
|
d559cccd44 | ||
|
|
0684d84609 | ||
|
|
78f1bf853c | ||
|
|
e28238191d | ||
|
|
82bcc5776f | ||
|
|
1993802c38 | ||
|
|
5db49b6b0e | ||
|
|
0c15b25ecd | ||
|
|
0c21765da3 |
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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’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));
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func Collect() (OSRelease, []Package, error) {
|
||||
switch {
|
||||
case have("dpkg-query"):
|
||||
out, err := run(ctx, "dpkg-query", "-W", "-f",
|
||||
`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
|
||||
`${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
|
||||
@@ -20,12 +20,20 @@ type Package struct {
|
||||
}
|
||||
|
||||
// ParseDpkg reads tab-separated output of
|
||||
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
|
||||
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n'
|
||||
//
|
||||
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
|
||||
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
|
||||
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
|
||||
// alone finds one of the three.
|
||||
//
|
||||
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
|
||||
// every package dpkg knows about, including ones removed with their config
|
||||
// files left behind — a host that has upgraded its kernel a dozen times reports
|
||||
// a dozen old linux-modules versions that are not on disk, and the oldest of
|
||||
// them sorts first and reads as the installed version. Only "installed" is
|
||||
// installed. An empty status means dpkg did not understand the field, in which
|
||||
// case the line is kept rather than the whole inventory silently vanishing.
|
||||
func ParseDpkg(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
@@ -36,6 +44,11 @@ func ParseDpkg(out string) []Package {
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
if len(f) > 4 {
|
||||
if s := strings.TrimSpace(f[4]); s != "" && s != "installed" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
|
||||
if len(f) > 3 && f[3] != "" {
|
||||
p.SourceName = f[3]
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Button, Card, Pagination, usePagination } from "@/components/ui";
|
||||
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
|
||||
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
|
||||
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
|
||||
@@ -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({
|
||||
@@ -48,6 +64,10 @@ export default function VulnerabilitiesPage() {
|
||||
|
||||
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
|
||||
|
||||
// Each package row carries its own findings and servers underneath it, so
|
||||
// the cost of a full fleet's board is well past the row count alone.
|
||||
const paged = usePagination(packages, 25);
|
||||
|
||||
const serverName = useMemo(() => {
|
||||
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
|
||||
// Falls back to the raw id rather than an empty cell: an unnamed row is
|
||||
@@ -95,7 +115,7 @@ export default function VulnerabilitiesPage() {
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
|
||||
Rescan fleet
|
||||
Rescan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -111,11 +131,12 @@ export default function VulnerabilitiesPage() {
|
||||
{SEVERITY_ORDER.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSeverity(severity === s ? "" : s)}
|
||||
onClick={() => {
|
||||
setSeverity(severity === s ? "" : s);
|
||||
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>
|
||||
@@ -123,26 +144,39 @@ 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}
|
||||
onClick={() => setState(s)}
|
||||
onClick={() => {
|
||||
setState(s);
|
||||
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 ? (
|
||||
@@ -150,40 +184,32 @@ export default function VulnerabilitiesPage() {
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : packages.length > 0 ? (
|
||||
packages.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}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{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} />
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { Badge, Button, Card, Pagination, Table, Thead, Tbody, Tr, Th, Td, usePagination } from "@/components/ui";
|
||||
import { api, workloads } from "@/lib/api";
|
||||
|
||||
/*
|
||||
@@ -29,6 +29,11 @@ export default function WorkloadsPage() {
|
||||
return m;
|
||||
}, [servers.data]);
|
||||
|
||||
// A fleet of a few hundred servers reports tens of thousands of workloads;
|
||||
// the whole set in one table is what freezes the tab.
|
||||
const rows = useMemo(() => hits.data ?? [], [hits.data]);
|
||||
const paged = usePagination(rows, 50);
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none";
|
||||
|
||||
@@ -45,6 +50,7 @@ export default function WorkloadsPage() {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() });
|
||||
paged.reset();
|
||||
}}
|
||||
>
|
||||
<input className={inputClass} placeholder="image (exact)" value={image} onChange={(e) => setImage(e.target.value)} />
|
||||
@@ -59,9 +65,10 @@ export default function WorkloadsPage() {
|
||||
<Card padding={false}>
|
||||
{hits.isLoading ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">Loading…</p>
|
||||
) : (hits.data ?? []).length === 0 ? (
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
@@ -74,7 +81,7 @@ export default function WorkloadsPage() {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{(hits.data ?? []).map((h) => (
|
||||
{paged.slice.map((h) => (
|
||||
<Tr key={`${h.server_id}:${h.workload.kind}:${h.workload.id}`}>
|
||||
<Td>
|
||||
<Link href={`/servers/${h.server_id}`} className="text-accent hover:underline">
|
||||
@@ -92,6 +99,16 @@ export default function WorkloadsPage() {
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<Pagination
|
||||
page={paged.page}
|
||||
pageCount={paged.pageCount}
|
||||
size={paged.size}
|
||||
total={paged.total}
|
||||
onPage={paged.setPage}
|
||||
onSize={paged.setSize}
|
||||
unit="workloads"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
/*
|
||||
* Client-side pagination.
|
||||
*
|
||||
* The fleet endpoints answer with the whole result set, and a few thousand rows
|
||||
* rendered at once is what locks the tab up. Slicing in the browser is enough:
|
||||
* the payload was never the problem, the DOM node count was. If a result set
|
||||
* ever outgrows the response itself, this is the seam a server-side cursor
|
||||
* would replace.
|
||||
*/
|
||||
|
||||
export const PAGE_SIZES = [25, 50, 100, 200];
|
||||
|
||||
export function usePagination<T>(items: T[], initialSize = 50) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [size, setSize] = useState(initialSize);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(items.length / size));
|
||||
|
||||
// A filter change shortens the list under a page that no longer exists;
|
||||
// clamping here rather than in every caller keeps the empty state honest.
|
||||
useEffect(() => {
|
||||
if (page > pageCount) setPage(1);
|
||||
}, [page, pageCount]);
|
||||
|
||||
const slice = useMemo(() => {
|
||||
const start = (page - 1) * size;
|
||||
return items.slice(start, start + size);
|
||||
}, [items, page, size]);
|
||||
|
||||
return {
|
||||
slice,
|
||||
page,
|
||||
size,
|
||||
pageCount,
|
||||
total: items.length,
|
||||
setPage,
|
||||
setSize: (n: number) => {
|
||||
setSize(n);
|
||||
setPage(1);
|
||||
},
|
||||
reset: () => setPage(1),
|
||||
};
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
pageCount,
|
||||
size,
|
||||
total,
|
||||
onPage,
|
||||
onSize,
|
||||
unit = "rows",
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
size: number;
|
||||
total: number;
|
||||
onPage: (n: number) => void;
|
||||
onSize: (n: number) => void;
|
||||
unit?: string;
|
||||
}) {
|
||||
if (total === 0) return null;
|
||||
|
||||
const first = (page - 1) * size + 1;
|
||||
const last = Math.min(page * size, total);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-border px-4 py-3 text-sm text-text-secondary sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<span className="tabular-nums">
|
||||
{first}–{last} of {total} {unit}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label="Rows per page"
|
||||
value={size}
|
||||
onChange={(e) => onSize(Number(e.target.value))}
|
||||
className="rounded border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} / page
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => onPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="rounded border border-border px-2.5 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-40 disabled:hover:text-text-secondary"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPage(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
className="rounded border border-border px-2.5 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-40 disabled:hover:text-text-secondary"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,3 +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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
Reference in New Issue
Block a user