Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe4ba5999 | ||
|
|
e434beec7a | ||
|
|
fe1dfe472a | ||
|
|
0cfaf6670c | ||
|
|
4d67341ba5 | ||
|
|
1fa9160c59 | ||
|
|
d559cccd44 | ||
|
|
0684d84609 | ||
|
|
78f1bf853c | ||
|
|
e28238191d | ||
|
|
82bcc5776f | ||
|
|
1993802c38 | ||
|
|
5db49b6b0e | ||
|
|
0c15b25ecd | ||
|
|
0c21765da3 | ||
|
|
4ff8fc8d51 | ||
|
|
483053b9a2 | ||
|
|
fd4c51f3db | ||
|
|
1b351cfca4 | ||
|
|
cf9d85b3cd | ||
|
|
6a4ef5b6c6 | ||
|
|
501cf4e733 | ||
|
|
89c21d752a | ||
|
|
0e38d9d500 | ||
|
|
3511c34daa | ||
|
|
0838d1d735 | ||
|
|
d1769fc886 |
@@ -360,6 +360,76 @@ Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
|
||||
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
|
||||
scheduler off entirely.
|
||||
|
||||
### Workload registry
|
||||
|
||||
A **workload** is one Docker container or one systemd unit — one word for the
|
||||
page, the collection and the commands, rather than saying "container or
|
||||
service" in every identifier. Linux only, and **not gated by licence**: this
|
||||
reads as core fleet management, so v1 ships everywhere with no `HasFeature`
|
||||
check. If that changes the check belongs at `ReportWorkloads`, gating collection
|
||||
rather than display, exactly as sub-project A does.
|
||||
|
||||
Agents collect on a 60-second ticker and report through `ReportWorkloads` with
|
||||
the **offer-then-send** handshake the package report already uses. The offer is
|
||||
identified by an explicit `full` flag, **not by an empty workloads list**: a
|
||||
host genuinely running nothing sends an empty list as its full report, and
|
||||
inferring the offer from emptiness leaves that host answering `need_full` every
|
||||
60 seconds forever and never storing anything.
|
||||
|
||||
**The on-demand refresh returns no data.** `RefreshWorkloadsCmd` carries nothing
|
||||
back; it makes the agent report through the normal RPC and the UI refetches. A
|
||||
refresh that returned workloads inline would be a second writer for
|
||||
`server_workloads`, arriving by a different route with its own serialisation and
|
||||
its own opportunity to disagree with the periodic one. One writer, one shape.
|
||||
Opening the panel dispatches a refresh because the panel has a Restart button on
|
||||
it, and a stale row is a wrong action aimed at a container that already died.
|
||||
|
||||
Two operations do answer back, both over the bus, both with `Await` called
|
||||
**before** dispatch: control actions reuse the existing `CommandResult`, and log
|
||||
reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
|
||||
`CommandResult` onto `bus.ResultChannel` — publishing with no subscriber is a
|
||||
no-op, so this costs nothing and avoids a second result path.
|
||||
|
||||
**The protected set is computed agent-side and enforced agent-side.**
|
||||
`vantage-agent.service`, plus the container ID read from `/proc/self/cgroup`
|
||||
should the agent ever run in a container. As with the console relay hardcoding
|
||||
`127.0.0.1`, the control plane may name a target but the agent decides what it
|
||||
will do to itself; a server-side denylist alone would be bypassed by the next
|
||||
dispatch path someone adds, and the failure is unrecoverable from the UI. The
|
||||
reported `Protected` flag is the courtesy that greys the button; the agent's own
|
||||
check is the boundary. The API answers **409** when it fires — nothing failed.
|
||||
|
||||
Collection avoids parsing English: `docker ps -aq` then
|
||||
`docker inspect --format '{{json .}}'`, because `docker ps` reports health and
|
||||
uptime inside a human `Status` string that is localised and reworded between
|
||||
releases. Compose stacks come from the `com.docker.compose.project` label, never
|
||||
from YAML on disk — a compose file there may not be what is running. systemd
|
||||
uses **column** output, not `--output=json`, which needs systemd 246+.
|
||||
|
||||
`DockerOK`/`DockerError` are two fields because there are three states: not
|
||||
installed (common on this fleet, and not a fault), installed but not responding,
|
||||
and running nothing. The UI must render the first as "not in use here" rather
|
||||
than an empty list.
|
||||
|
||||
Logs are capped at **500 lines and 256KB, whichever binds first** — a line count
|
||||
alone does not bound size, and 500 lines of 4KB JSON is 2MB across the bus. The
|
||||
cap is mirrored in `services.MaxWorkloadLogLines` because `agent/` is a separate
|
||||
module with an `internal/` tree and the constant cannot be shared; change one,
|
||||
change the other. There is **no follow mode**: the browser console already gives
|
||||
a real terminal where `docker logs -f` works properly. Log reads and control
|
||||
actions are **owner|admin and audited**, unlike the read-only snapshot — a
|
||||
container's stdout is arbitrary and cannot be masked the way a workflow's can.
|
||||
|
||||
`server_workloads` is one document per server, mirroring `server_packages`, and
|
||||
is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). There
|
||||
is no history: a workload list is state, not a record.
|
||||
|
||||
**`proto/vantage/v1/vantage.proto` is documentation, not a generator input.**
|
||||
Both `pb` packages are hand-written JSON-tagged structs over a custom codec, and
|
||||
there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
|
||||
A message added to one must be added to the other and to the `.proto`, in the
|
||||
same commit.
|
||||
|
||||
### Agent self-update
|
||||
|
||||
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
|
||||
@@ -512,6 +582,7 @@ service Vantage {
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
@@ -521,7 +592,8 @@ service Vantage {
|
||||
|
||||
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
|
||||
|
||||
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
|
||||
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`, `RefreshWorkloadsCmd`, `ControlWorkloadCmd`,
|
||||
`WorkloadLogsCmd`.
|
||||
|
||||
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
|
||||
The server sends one every 20s on an otherwise idle command stream; the agent
|
||||
@@ -578,6 +650,10 @@ vulns GET /vulnerabilities · GET /vulnerabilities/summary
|
||||
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
|
||||
GET /packages/search?name=
|
||||
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
|
||||
workloads GET /workloads · GET /servers/:id/workloads
|
||||
POST /servers/:id/workloads/refresh
|
||||
POST /servers/:id/workloads/:wid/action (owner|admin)
|
||||
GET /servers/:id/workloads/:wid/logs (owner|admin)
|
||||
audit GET /audit
|
||||
agent GET /agent/latest-version
|
||||
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
|
||||
@@ -658,7 +734,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `migrations`
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `migrations`
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,19 @@ func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
|
||||
return resp.NeedFull, nil
|
||||
}
|
||||
|
||||
// ReportWorkloads sends a workload report and returns whether the server wants
|
||||
// the full list.
|
||||
func (c *Client) ReportWorkloads(req *pb.ReportWorkloadsRequest) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.ReportWorkloads(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.NeedFull, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -206,6 +206,10 @@ type ServerCommand struct {
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
Ping *PingCmd `json:"ping,omitempty"`
|
||||
|
||||
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
|
||||
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
|
||||
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"`
|
||||
}
|
||||
|
||||
// PingCmd is a server-originated liveness beat. It carries nothing and expects
|
||||
@@ -243,6 +247,8 @@ type AgentMessage struct {
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
|
||||
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
@@ -377,6 +383,7 @@ type VantageClient interface {
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
|
||||
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Workload registry messages. Hand-written like the rest of this package: the
|
||||
// .proto is the contract, this file is the Go side of it, and the two must be
|
||||
// changed together.
|
||||
|
||||
// Workload is one container or one systemd unit.
|
||||
type Workload struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Restarts int32 `json:"restarts,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
|
||||
Protected bool `json:"protected,omitempty"`
|
||||
}
|
||||
|
||||
// ReportWorkloadsRequest carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with Workloads empty, and resends with the body only if NeedFull is set.
|
||||
type ReportWorkloadsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
DockerOk bool `json:"docker_ok"`
|
||||
DockerError string `json:"docker_error,omitempty"`
|
||||
SystemdOk bool `json:"systemd_ok"`
|
||||
SystemdError string `json:"systemd_error,omitempty"`
|
||||
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
|
||||
// Full marks the second call. It is not inferred from an empty Workloads
|
||||
// slice: a host running nothing sends an empty list as its full report.
|
||||
Full bool `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
type ReportWorkloadsResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
type RefreshWorkloadsCmd struct{}
|
||||
|
||||
type ControlWorkloadCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Action string `json:"action"` // start | stop | restart
|
||||
}
|
||||
|
||||
type WorkloadLogsCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Tail int32 `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
type WorkloadLogsResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
|
||||
out := new(ReportWorkloadsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -70,6 +70,8 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
|
||||
go runInventory(ctx, cfg)
|
||||
|
||||
go runWorkloads(ctx, cfg)
|
||||
|
||||
go monitors.Run(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
@@ -347,6 +349,15 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.OpenProxy != nil {
|
||||
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
|
||||
}
|
||||
if cmd.RefreshWorkloads != nil {
|
||||
go handleRefreshWorkloads(cfg)
|
||||
}
|
||||
if cmd.ControlWorkload != nil {
|
||||
go handleControlWorkload(send, cfg, cmd.CommandId, cmd.ControlWorkload)
|
||||
}
|
||||
if cmd.WorkloadLogs != nil {
|
||||
go handleWorkloadLogs(send, cfg, cmd.CommandId, cmd.WorkloadLogs)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/workloads"
|
||||
)
|
||||
|
||||
// workloadInterval is the report cadence. Sixty seconds is affordable because
|
||||
// an unchanged list costs one small offer message, not the body.
|
||||
const workloadInterval = 60 * time.Second
|
||||
|
||||
// runWorkloads reports what this host runs, on its own ticker.
|
||||
func runWorkloads(ctx context.Context, cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
|
||||
reportWorkloads(cfg)
|
||||
|
||||
ticker := time.NewTicker(workloadInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportWorkloads offers a hash of the current workload set and sends the full
|
||||
// list only if the server does not already hold it.
|
||||
//
|
||||
// This is the ONLY writer of the server_workloads collection. RefreshWorkloadsCmd
|
||||
// calls straight into here rather than answering with data of its own.
|
||||
func reportWorkloads(cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
res := workloads.Collect(ctx)
|
||||
hash := workloads.Hash(res.Workloads)
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("workload report dial error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
base := func() *pb.ReportWorkloadsRequest {
|
||||
return &pb.ReportWorkloadsRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
DockerOk: res.DockerOK,
|
||||
DockerError: res.DockerError,
|
||||
SystemdOk: res.SystemdOK,
|
||||
SystemdError: res.SystemdError,
|
||||
}
|
||||
}
|
||||
|
||||
// The offer: hash only, no body. On an unchanged host this is the whole
|
||||
// exchange, which is the point of the handshake.
|
||||
needFull, err := client.ReportWorkloads(base())
|
||||
if err != nil {
|
||||
log.Printf("ReportWorkloads offer error: %v", err)
|
||||
return
|
||||
}
|
||||
if !needFull {
|
||||
return
|
||||
}
|
||||
|
||||
req := base()
|
||||
req.Full = true
|
||||
req.Workloads = make([]pb.Workload, len(res.Workloads))
|
||||
for i, w := range res.Workloads {
|
||||
req.Workloads[i] = pb.Workload{
|
||||
Kind: w.Kind,
|
||||
Id: w.ID,
|
||||
Name: w.Name,
|
||||
State: w.State,
|
||||
Health: w.Health,
|
||||
Image: w.Image,
|
||||
Stack: w.Stack,
|
||||
Ports: w.Ports,
|
||||
Restarts: int32(w.Restarts),
|
||||
Protected: w.Protected,
|
||||
}
|
||||
if !w.StartedAt.IsZero() {
|
||||
req.Workloads[i].StartedAt = w.StartedAt.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.ReportWorkloads(req); err != nil {
|
||||
log.Printf("ReportWorkloads error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d workload(s)", len(res.Workloads))
|
||||
}
|
||||
|
||||
// handleRefreshWorkloads makes the agent report immediately. It sends nothing
|
||||
// back beyond the stream ack: the refresh is a nudge, not a channel, so there
|
||||
// is one writer for the collection rather than two.
|
||||
func handleRefreshWorkloads(cfg *config.Config) {
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
|
||||
// handleControlWorkload starts, stops or restarts a workload and answers with
|
||||
// the ordinary CommandResult.
|
||||
//
|
||||
// The agent's own protected check inside workloads.Control is the boundary; the
|
||||
// Protected flag it reports is only there so the UI can grey the button.
|
||||
func handleControlWorkload(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.ControlWorkloadCmd) {
|
||||
err := workloads.Control(context.Background(), cmd.Kind, cmd.Id, cmd.Action)
|
||||
|
||||
res := &pb.CommandResult{CommandId: commandID, Success: err == nil}
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
log.Printf("workload %s %s failed (cmd=%s): %v", cmd.Action, cmd.Id, commandID, err)
|
||||
} else {
|
||||
res.Message = cmd.Action + " " + cmd.Id + " ok"
|
||||
}
|
||||
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Result: res,
|
||||
})
|
||||
|
||||
// Report straight away on success so the UI's refetch shows the new state
|
||||
// rather than the old one.
|
||||
if err == nil {
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func handleWorkloadLogs(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.WorkloadLogsCmd) {
|
||||
text, truncated, err := workloads.Logs(context.Background(), cmd.Kind, cmd.Id, int(cmd.Tail))
|
||||
res := &pb.WorkloadLogsResult{CommandId: commandID, Text: text, Truncated: truncated}
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
}
|
||||
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
WorkloadLogsResult: res,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrProtected is returned for a workload the agent will not act on.
|
||||
var ErrProtected = errors.New("workload is protected")
|
||||
|
||||
// AgentUnit is the systemd unit this agent runs as.
|
||||
const AgentUnit = "vantage-agent.service"
|
||||
|
||||
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
|
||||
// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit
|
||||
// with a long TimeoutStopSec blocks for exactly as long as that says. A
|
||||
// timeout must return a real error rather than an ack implying success.
|
||||
const controlTimeout = 90 * time.Second
|
||||
|
||||
// ownContainerID is read once: the container this agent runs in, if any.
|
||||
var ownContainerID = detectOwnContainer()
|
||||
|
||||
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
|
||||
|
||||
// detectOwnContainer returns this process's container ID, or "" on a host
|
||||
// install. The agent is normally a systemd service, so "" is the common case;
|
||||
// this exists so containerising it later cannot silently remove the guard.
|
||||
func detectOwnContainer() string {
|
||||
b, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if m := cgroupContainerRe.FindString(string(b)); m != "" {
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isProtected reports whether the agent refuses to act on this workload.
|
||||
//
|
||||
// The refusal lives here, in the agent, and not in the control plane. As with
|
||||
// the console relay hardcoding 127.0.0.1 agent-side: the control plane may name
|
||||
// a target, but the agent decides what it will do to itself. A server-side
|
||||
// denylist alone would be bypassed by the next dispatch path someone adds.
|
||||
func isProtected(kind, id, name string) bool {
|
||||
if kind == "unit" {
|
||||
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
|
||||
}
|
||||
if ownContainerID == "" {
|
||||
return false
|
||||
}
|
||||
// Container IDs are commonly abbreviated to 12 characters; compare on the
|
||||
// shorter of the two so a short id still matches a full one.
|
||||
return strings.HasPrefix(ownContainerID, id) || strings.HasPrefix(id, ownContainerID)
|
||||
}
|
||||
|
||||
// markProtected stamps the flag onto a collected list so the UI can render the
|
||||
// action disabled with a reason.
|
||||
func markProtected(wls []Workload) {
|
||||
for i := range wls {
|
||||
wls[i].Protected = isProtected(wls[i].Kind, wls[i].ID, wls[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Control starts, stops or restarts a workload.
|
||||
func Control(ctx context.Context, kind, id, action string) error {
|
||||
switch action {
|
||||
case "start", "stop", "restart":
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
|
||||
// Checked before anything else happens, and checked here rather than only
|
||||
// on the server. See isProtected.
|
||||
if isProtected(kind, id, strings.TrimSuffix(id, ".service")) {
|
||||
return fmt.Errorf("%w: %s", ErrProtected, id)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, controlTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", action, id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "systemctl", action, id)
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Workload is one container or one systemd unit, agent-side. It mirrors
|
||||
// models.Workload on the server.
|
||||
type Workload struct {
|
||||
Kind string
|
||||
ID string
|
||||
Name string
|
||||
State string
|
||||
Health string
|
||||
Image string
|
||||
Stack string
|
||||
Ports []string
|
||||
Restarts int
|
||||
StartedAt time.Time
|
||||
Protected bool
|
||||
}
|
||||
|
||||
const dockerTimeout = 30 * time.Second
|
||||
|
||||
// dockerInspect is the subset of `docker inspect` output we read.
|
||||
//
|
||||
// We use inspect rather than `docker ps --format '{{json .}}'` because ps
|
||||
// reports health and uptime inside a human Status string — "Up 2 hours
|
||||
// (healthy)" — and anything built on that is parsing English that is
|
||||
// localised, reworded between releases, and silently different for a paused or
|
||||
// restarting container. inspect gives typed fields instead.
|
||||
type dockerInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
State struct {
|
||||
Status string `json:"Status"`
|
||||
StartedAt string `json:"StartedAt"`
|
||||
Restarting bool `json:"Restarting"`
|
||||
Health *struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"Health"`
|
||||
} `json:"State"`
|
||||
Config struct {
|
||||
Image string `json:"Image"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
} `json:"Config"`
|
||||
RestartCount int `json:"RestartCount"`
|
||||
NetworkSettings struct {
|
||||
Ports map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
} `json:"Ports"`
|
||||
} `json:"NetworkSettings"`
|
||||
}
|
||||
|
||||
// collectDocker enumerates containers. It returns ok=false with an empty error
|
||||
// string when Docker is simply not installed — the common case on this fleet,
|
||||
// and not a fault.
|
||||
func collectDocker(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return nil, false, "" // not installed; not an error
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, dockerTimeout)
|
||||
defer cancel()
|
||||
|
||||
idsOut, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output()
|
||||
if err != nil {
|
||||
// Installed but not answering: a different problem with a different
|
||||
// fix, so it carries a message where "not installed" does not.
|
||||
return nil, false, "docker ps failed: " + errText(err)
|
||||
}
|
||||
|
||||
ids := strings.Fields(string(idsOut))
|
||||
if len(ids) == 0 {
|
||||
return []Workload{}, true, "" // Docker present, nothing running
|
||||
}
|
||||
|
||||
args := append([]string{"inspect", "--format", "{{json .}}"}, ids...)
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
||||
if err != nil {
|
||||
return nil, false, "docker inspect failed: " + errText(err)
|
||||
}
|
||||
|
||||
var wls []Workload
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var di dockerInspect
|
||||
if err := json.Unmarshal([]byte(line), &di); err != nil {
|
||||
continue
|
||||
}
|
||||
wls = append(wls, dockerToWorkload(di))
|
||||
}
|
||||
return wls, true, ""
|
||||
}
|
||||
|
||||
func dockerToWorkload(di dockerInspect) Workload {
|
||||
w := Workload{
|
||||
Kind: "container",
|
||||
ID: di.ID,
|
||||
Name: strings.TrimPrefix(di.Name, "/"),
|
||||
State: di.State.Status,
|
||||
Image: di.Config.Image,
|
||||
Restarts: di.RestartCount,
|
||||
}
|
||||
if di.State.Health != nil {
|
||||
w.Health = strings.ToLower(di.State.Health.Status)
|
||||
}
|
||||
// The compose project label is what Docker itself treats as authoritative.
|
||||
// No YAML is read from disk: a compose file there may not be what is running.
|
||||
if v := di.Config.Labels["com.docker.compose.project"]; v != "" {
|
||||
w.Stack = v
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, di.State.StartedAt); err == nil {
|
||||
w.StartedAt = t
|
||||
}
|
||||
for container, bindings := range di.NetworkSettings.Ports {
|
||||
for _, b := range bindings {
|
||||
w.Ports = append(w.Ports, b.HostIP+":"+b.HostPort+"->"+container)
|
||||
}
|
||||
}
|
||||
// Map iteration order is random; sort so a stored snapshot does not reorder
|
||||
// its own ports between two otherwise identical reports.
|
||||
sort.Strings(w.Ports)
|
||||
return w
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return strings.TrimSpace(string(ee.Stderr))
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxLogLines and MaxLogBytes are BOTH enforced, whichever binds first.
|
||||
//
|
||||
// A line count alone does not bound size: 500 lines of a container printing
|
||||
// 4KB JSON blobs is 2MB travelling over the bus. This is the same reasoning
|
||||
// that gave workflow logs a per-line cap as well as a per-run one.
|
||||
MaxLogLines = 500
|
||||
MaxLogBytes = 256 * 1024
|
||||
|
||||
logTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// Logs returns a bounded snapshot of a workload's recent output.
|
||||
//
|
||||
// There is no follow mode. The browser console already offers a real terminal
|
||||
// on the same server where `docker logs -f` works properly, with its own
|
||||
// scrollback and cancellation. A snapshot answers "why did this restart",
|
||||
// which is the question that sends people to the console in the first place.
|
||||
func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error) {
|
||||
if tail <= 0 || tail > MaxLogLines {
|
||||
tail = MaxLogLines
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, logTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
|
||||
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
|
||||
default:
|
||||
return "", false, fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
// docker logs writes container stderr to our stderr, so both streams must
|
||||
// be captured or half the output silently disappears.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", false, fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
|
||||
text, truncated := capLog(string(out))
|
||||
return text, truncated, nil
|
||||
}
|
||||
|
||||
// capLog enforces both limits, trimming from the FRONT: the most recent lines
|
||||
// are the ones worth keeping.
|
||||
func capLog(s string) (string, bool) {
|
||||
truncated := false
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > MaxLogLines {
|
||||
lines = lines[len(lines)-MaxLogLines:]
|
||||
truncated = true
|
||||
}
|
||||
s = strings.Join(lines, "\n")
|
||||
|
||||
if len(s) > MaxLogBytes {
|
||||
s = s[len(s)-MaxLogBytes:]
|
||||
// Drop the leading partial line left by a byte-wise cut.
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
truncated = true
|
||||
}
|
||||
|
||||
return s, truncated
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const systemdTimeout = 30 * time.Second
|
||||
|
||||
// excludedPrefixes drops the platform's own units. A typical host carries 300+
|
||||
// units and systemd accounts for most of them; listing all of them buries the
|
||||
// ten anyone cares about.
|
||||
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}
|
||||
|
||||
// collectSystemd enumerates services in two passes, because "running or
|
||||
// failed" and "enabled but stopped" are different questions — and an enabled
|
||||
// unit that is not running is exactly the one worth seeing.
|
||||
func collectSystemd(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, systemdTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Column output rather than --output=json: the JSON flag needs systemd
|
||||
// 246+, and this fleet includes older stable distributions. The columns
|
||||
// have been stable considerably longer than the JSON has existed.
|
||||
unitsOut, err := exec.CommandContext(ctx, "systemctl",
|
||||
"list-units", "--type=service", "--state=running,failed",
|
||||
"--no-legend", "--plain", "--no-pager").Output()
|
||||
if err != nil {
|
||||
return nil, false, "systemctl list-units failed: " + errText(err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var wls []Workload
|
||||
|
||||
for _, line := range strings.Split(string(unitsOut), "\n") {
|
||||
f := strings.Fields(line)
|
||||
// UNIT LOAD ACTIVE SUB DESCRIPTION…
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
name := f[0]
|
||||
if excluded(name) || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: name,
|
||||
Name: strings.TrimSuffix(name, ".service"),
|
||||
State: f[2], // ACTIVE: active | failed | activating | inactive
|
||||
})
|
||||
}
|
||||
|
||||
filesOut, err := exec.CommandContext(ctx, "systemctl",
|
||||
"list-unit-files", "--type=service", "--state=enabled",
|
||||
"--no-legend", "--plain", "--no-pager").Output()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(filesOut), "\n") {
|
||||
f := strings.Fields(line)
|
||||
// UNIT FILE STATE [PRESET]
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
name := f[0]
|
||||
if excluded(name) || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: name,
|
||||
Name: strings.TrimSuffix(name, ".service"),
|
||||
State: "inactive", // enabled but not currently running
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return wls, true, ""
|
||||
}
|
||||
|
||||
func excluded(name string) bool {
|
||||
for _, p := range excludedPrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Result is one collection pass.
|
||||
type Result struct {
|
||||
Workloads []Workload
|
||||
DockerOK bool
|
||||
DockerError string
|
||||
SystemdOK bool
|
||||
SystemdError string
|
||||
}
|
||||
|
||||
// Collect enumerates every workload on this host. Linux only.
|
||||
func Collect(ctx context.Context) Result {
|
||||
if runtime.GOOS != "linux" {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
var r Result
|
||||
containers, dockerOK, dockerErr := collectDocker(ctx)
|
||||
units, systemdOK, systemdErr := collectSystemd(ctx)
|
||||
|
||||
r.DockerOK, r.DockerError = dockerOK, dockerErr
|
||||
r.SystemdOK, r.SystemdError = systemdOK, systemdErr
|
||||
r.Workloads = append(append([]Workload{}, containers...), units...)
|
||||
|
||||
markProtected(r.Workloads)
|
||||
return r
|
||||
}
|
||||
|
||||
// Hash fingerprints a workload set so an unchanged set never has to be sent.
|
||||
//
|
||||
// It sorts first: `docker ps` output ordering is not stable, and an
|
||||
// ordering-sensitive hash would resend the full list every 60 seconds forever
|
||||
// — a cost visible only as traffic.
|
||||
//
|
||||
// StartedAt is deliberately excluded: it does not change while a container
|
||||
// runs, and including it would add nothing. Restarts IS included, because a
|
||||
// container cycling is exactly the change worth reporting.
|
||||
func Hash(wls []Workload) string {
|
||||
lines := make([]string, 0, len(wls))
|
||||
for _, w := range wls {
|
||||
lines = append(lines, strings.Join([]string{
|
||||
w.Kind, w.ID, w.Name, w.State, w.Health, w.Image, w.Stack,
|
||||
strconv.Itoa(w.Restarts),
|
||||
}, "\x00"))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l))
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
id: workloads
|
||||
title: Workloads
|
||||
sidebar_label: Workloads
|
||||
---
|
||||
|
||||
A **workload** is one Docker container or one systemd service. Each Linux
|
||||
server reports what it runs, and you can start, stop and restart those
|
||||
workloads — and read a snapshot of their logs — without opening a console.
|
||||
|
||||
Available on every instance. No licence feature is required.
|
||||
|
||||
## What gets reported
|
||||
|
||||
Linux servers only. Agents report every 60 seconds, and an unchanged list costs
|
||||
a single small message rather than the whole thing again.
|
||||
|
||||
- **Containers** — every container, running or not, with its image, published
|
||||
ports, health, restart count and the compose stack it belongs to.
|
||||
- **Services** — systemd units that are running or failed, plus units that are
|
||||
enabled but currently stopped. The platform's own units (`systemd-*`,
|
||||
`user@*`, `session-*`) are filtered out; a typical host has 300 of them and
|
||||
they bury the ten you care about.
|
||||
|
||||
Windows servers report no workloads at all.
|
||||
|
||||
## Docker not in use is not an error
|
||||
|
||||
Three different things look identical if you are careless, and only one of them
|
||||
is a problem:
|
||||
|
||||
| What you see | What it means |
|
||||
| ------------ | ------------- |
|
||||
| "Docker is not in use on this server" | Docker is not installed. Normal, and not a fault |
|
||||
| "Docker is installed but not responding" | The daemon is down or the socket is unreachable |
|
||||
| An empty container list | Docker is running and there are no containers |
|
||||
|
||||
## Stacks are grouped
|
||||
|
||||
Compose stacks appear first, grouped under the stack name, then loose
|
||||
containers, then services. A stack is one thing even when it is six containers,
|
||||
and a flat list turns one decision into six rows.
|
||||
|
||||
The stack name comes from Docker's own `com.docker.compose.project` label. No
|
||||
compose file is read from disk — a file on disk may not be what is running.
|
||||
|
||||
## Controlling a workload
|
||||
|
||||
Start, stop and restart are **owner or admin only**, and every action is
|
||||
written to the audit log naming you, the server and the target.
|
||||
|
||||
The agent refuses to act on itself. `vantage-agent.service` is shown with its
|
||||
buttons disabled: a server that stops its own agent goes offline, and the only
|
||||
way back is SSH or physical access — which is exactly what this page exists to
|
||||
avoid needing.
|
||||
|
||||
A stop that never finishes is not reported as success. Both `docker stop` and
|
||||
`systemctl stop` run under a 90-second limit, and a timeout comes back as a
|
||||
real error.
|
||||
|
||||
## Reading logs
|
||||
|
||||
Logs are **owner or admin only** and every read is audited. Unlike workflow
|
||||
logs, a container's output cannot be masked: it is arbitrary, and a startup
|
||||
banner or a stack trace may contain credentials nobody declared.
|
||||
|
||||
A log read returns a snapshot of at most **500 lines or 256KB**, whichever
|
||||
limit is reached first, with the most recent output kept. When either limit
|
||||
binds, the dialog says so — a truncated log must never be read as a complete
|
||||
one.
|
||||
|
||||
There is no live following. The [browser console](./browser-console.md) already
|
||||
gives you a real terminal on the same server, where `docker logs -f` works
|
||||
properly with its own scrollback.
|
||||
|
||||
## Refreshing
|
||||
|
||||
Opening a server's Workloads panel asks its agent to report immediately, so
|
||||
what is on screen is current rather than up to a minute old. That matters
|
||||
because the panel has a Restart button on it: a stale row is not just a wrong
|
||||
impression, it is a wrong action aimed at something that already died.
|
||||
|
||||
If the agent is offline the refresh fails visibly rather than queueing. A
|
||||
command whose target cannot be reached must say so.
|
||||
|
||||
## Fleet view
|
||||
|
||||
**Workloads** in the sidebar searches the whole fleet by image, stack or state
|
||||
— "which of these servers is still on the old image" — and links each result
|
||||
back to its server.
|
||||
@@ -27,6 +27,7 @@ const sidebars: SidebarsConfig = {
|
||||
"vantage/workflows",
|
||||
"vantage/monitors",
|
||||
"vantage/vulnerabilities",
|
||||
"vantage/workloads",
|
||||
"vantage/notification-channels",
|
||||
"vantage/secrets",
|
||||
"vantage/browser-console",
|
||||
|
||||
+8
-15
@@ -5,7 +5,6 @@ github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNY
|
||||
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
|
||||
github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce/go.mod h1:HXgVzOPvXhVGLJs4ZKO817idqr/xhwsTcj17CLYY74s=
|
||||
github.com/aquasecurity/go-npm-version v0.0.1/go.mod h1:hxbJZtKlO4P8sZ9nztizR6XLoE33O+BkPmuYQ4ACyz0=
|
||||
github.com/aquasecurity/go-pep440-version v0.0.1/go.mod h1:3naPe+Bp6wi3n4l5iBFCZgS0JG8vY6FT0H4NGhFJ+i4=
|
||||
@@ -19,9 +18,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
|
||||
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gocsaf/csaf/v3 v3.1.1/go.mod h1:EpUCrQg69i+Y66MphmQvVbcj333GFLjXOYHg1zoXVso=
|
||||
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
@@ -29,6 +29,7 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/josephburnett/jd/v2 v2.3.0/go.mod h1:0I5+gbo7y8diuajJjm79AF44eqTheSJy1K7DSbIUFAQ=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
@@ -36,28 +37,20 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
|
||||
github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a/go.mod h1:jZ3F25l7DbD7l7DcA8aj7eo1EZ84nbzcQHBB4lCSrI8=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0=
|
||||
github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
|
||||
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
|
||||
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
|
||||
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
|
||||
@@ -10,6 +10,7 @@ service Vantage {
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
|
||||
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
@@ -107,6 +108,7 @@ message AgentMessage {
|
||||
CommandResult result = 4;
|
||||
StepResult step_result = 5;
|
||||
StepOutputChunk step_output = 6;
|
||||
WorkloadLogsResult workload_logs_result = 7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +231,9 @@ message ServerCommand {
|
||||
CleanupWorkspaceCmd cleanup_workspace = 7;
|
||||
OpenProxyCmd open_proxy = 8;
|
||||
PingCmd ping = 9;
|
||||
RefreshWorkloadsCmd refresh_workloads = 10;
|
||||
ControlWorkloadCmd control_workload = 11;
|
||||
WorkloadLogsCmd workload_logs = 12;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,3 +324,66 @@ message ProxyServerMsg {
|
||||
ProxyClose close = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workload registry
|
||||
|
||||
// ReportWorkloads carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with workloads empty, and resends with the body only if need_full is set.
|
||||
message ReportWorkloadsRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string hash = 3;
|
||||
bool docker_ok = 4;
|
||||
string docker_error = 5;
|
||||
bool systemd_ok = 6;
|
||||
string systemd_error = 7;
|
||||
repeated Workload workloads = 8; // empty on the offer call
|
||||
// full marks the second call. It is not inferred from an empty workloads
|
||||
// list: a host running nothing sends an empty list as its full report.
|
||||
bool full = 9;
|
||||
}
|
||||
|
||||
message ReportWorkloadsResponse {
|
||||
bool need_full = 1;
|
||||
}
|
||||
|
||||
message Workload {
|
||||
string kind = 1; // "container" | "unit"
|
||||
string id = 2;
|
||||
string name = 3;
|
||||
string state = 4;
|
||||
string health = 5;
|
||||
string image = 6;
|
||||
string stack = 7;
|
||||
repeated string ports = 8;
|
||||
int32 restarts = 9;
|
||||
string started_at = 10; // RFC3339, empty when not running
|
||||
bool protected = 11;
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
message RefreshWorkloadsCmd {}
|
||||
|
||||
message ControlWorkloadCmd {
|
||||
string kind = 1;
|
||||
string id = 2;
|
||||
string action = 3; // "start" | "stop" | "restart"
|
||||
}
|
||||
|
||||
message WorkloadLogsCmd {
|
||||
string kind = 1;
|
||||
string id = 2;
|
||||
int32 tail = 3;
|
||||
}
|
||||
|
||||
message WorkloadLogsResult {
|
||||
string command_id = 1;
|
||||
string text = 2;
|
||||
bool truncated = 3;
|
||||
string error = 4;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure vuln indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureWorkloadIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure workload indexes: %v", err)
|
||||
}
|
||||
|
||||
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
|
||||
log.Printf("warning: failed to list instances for default step seeding: %v", err)
|
||||
} else {
|
||||
|
||||
@@ -132,6 +132,15 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule)
|
||||
apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule)
|
||||
apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule)
|
||||
|
||||
// Control actions and log reads are owner|admin: container output is
|
||||
// arbitrary and cannot be masked, so a member who can see the fleet
|
||||
// still cannot read its logs.
|
||||
apiGroup.GET("/workloads", listWorkloads)
|
||||
apiGroup.GET("/servers/:id/workloads", getServerWorkloads)
|
||||
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
|
||||
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
|
||||
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getServerWorkloads returns the stored snapshot.
|
||||
//
|
||||
// A server that has never reported answers an empty list rather than 404: the
|
||||
// agent may simply not have got there yet, and 404 reads as "no such server".
|
||||
func getServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
if _, err := services.GetServer(instanceID, id); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
sw, err := services.GetWorkloads(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if sw == nil {
|
||||
c.JSON(http.StatusOK, models.ServerWorkloads{
|
||||
ServerID: id,
|
||||
Workloads: []models.Workload{},
|
||||
})
|
||||
return
|
||||
}
|
||||
if sw.Workloads == nil {
|
||||
sw.Workloads = []models.Workload{}
|
||||
}
|
||||
c.JSON(http.StatusOK, sw)
|
||||
}
|
||||
|
||||
// refreshServerWorkloads nudges the agent to report now. It returns no data:
|
||||
// the client refetches the stored document once the agent has written it.
|
||||
func refreshServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
s, err := services.GetServer(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DispatchRefreshWorkloads(s.ServerID); err != nil {
|
||||
// Not queued: a command whose owner died must fail loudly, so the
|
||||
// client can show the stored snapshot as stale rather than pretend.
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
|
||||
}
|
||||
|
||||
func controlWorkload(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
wid, ok := workloadIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
switch body.Action {
|
||||
case models.WorkloadStart, models.WorkloadStop, models.WorkloadRestart:
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be start, stop or restart"})
|
||||
return
|
||||
}
|
||||
if body.Kind != models.WorkloadContainer && body.Kind != models.WorkloadUnit {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
|
||||
return
|
||||
}
|
||||
|
||||
s, err := services.GetServer(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
err = services.DispatchControlWorkload(s.ServerID, body.Kind, wid, body.Action)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrAgentNotConnected):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
case services.IsWorkloadProtected(err):
|
||||
// Nothing failed — the agent refused, which is the design. 409, not
|
||||
// 500, and the reason is carried through.
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
|
||||
c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"})
|
||||
}
|
||||
|
||||
func getWorkloadLogs(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
wid, ok := workloadIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
kind := c.DefaultQuery("kind", models.WorkloadContainer)
|
||||
if kind != models.WorkloadContainer && kind != models.WorkloadUnit {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clamped rather than refused: a client asking for more than the cap gets
|
||||
// the cap, which is what it would have got anyway.
|
||||
tail, _ := strconv.Atoi(c.Query("tail"))
|
||||
if tail <= 0 || tail > services.MaxWorkloadLogLines {
|
||||
tail = services.MaxWorkloadLogLines
|
||||
}
|
||||
|
||||
s, err := services.GetServer(instanceID, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
text, truncated, err := services.DispatchWorkloadLogs(s.ServerID, kind, wid, tail)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrAgentNotConnected) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Audited because container output is arbitrary and cannot be masked: a
|
||||
// startup banner or a stack trace may carry credentials nobody declared.
|
||||
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated})
|
||||
}
|
||||
|
||||
// listWorkloads answers the fleet-wide question, which is the reason the
|
||||
// snapshot is stored rather than fetched on demand and discarded.
|
||||
func listWorkloads(c *gin.Context) {
|
||||
hits, err := services.SearchWorkloads(auth.InstanceID(c),
|
||||
c.Query("image"), c.Query("stack"), c.Query("state"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, hits)
|
||||
}
|
||||
|
||||
// workloadIDParam decodes :wid. Unit names carry dots and '@', so the client
|
||||
// encodes it and this is where it comes back.
|
||||
func workloadIDParam(c *gin.Context) (string, bool) {
|
||||
raw := c.Param("wid")
|
||||
decoded, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workload id"})
|
||||
return "", false
|
||||
}
|
||||
decoded = strings.TrimSpace(decoded)
|
||||
if decoded == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workload id is required"})
|
||||
return "", false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
@@ -198,6 +198,10 @@ type ServerCommand struct {
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
Ping *PingCmd `json:"ping,omitempty"`
|
||||
|
||||
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
|
||||
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
|
||||
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"`
|
||||
}
|
||||
|
||||
// PingCmd is a server-originated liveness beat. It carries nothing and expects
|
||||
@@ -233,6 +237,8 @@ type AgentMessage struct {
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
|
||||
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
@@ -366,6 +372,7 @@ type VantageServer interface {
|
||||
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
|
||||
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
|
||||
ReportPackages(context.Context, *ReportPackagesRequest) (*ReportPackagesResponse, error)
|
||||
ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error)
|
||||
ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)
|
||||
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
|
||||
@@ -421,6 +428,7 @@ type VantageClient interface {
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
|
||||
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
@@ -529,6 +537,7 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
|
||||
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
|
||||
{MethodName: "ReportPackages", Handler: _Vantage_ReportPackages_Handler},
|
||||
{MethodName: "ReportWorkloads", Handler: _Vantage_ReportWorkloads_Handler},
|
||||
{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler},
|
||||
{MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler},
|
||||
{MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler},
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Workload registry messages. Hand-written like the rest of this package: the
|
||||
// .proto is the contract, this file is the Go side of it, and the two must be
|
||||
// changed together. The agent module carries the same declarations.
|
||||
|
||||
// Workload is one container or one systemd unit.
|
||||
type Workload struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Restarts int32 `json:"restarts,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
|
||||
Protected bool `json:"protected,omitempty"`
|
||||
}
|
||||
|
||||
// ReportWorkloadsRequest carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with Workloads empty, and resends with the body only if NeedFull is set.
|
||||
type ReportWorkloadsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
DockerOk bool `json:"docker_ok"`
|
||||
DockerError string `json:"docker_error,omitempty"`
|
||||
SystemdOk bool `json:"systemd_ok"`
|
||||
SystemdError string `json:"systemd_error,omitempty"`
|
||||
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
|
||||
// Full marks the second call. It is not inferred from an empty Workloads
|
||||
// slice: a host running nothing sends an empty list as its full report.
|
||||
Full bool `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
type ReportWorkloadsResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
type RefreshWorkloadsCmd struct{}
|
||||
|
||||
type ControlWorkloadCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Action string `json:"action"` // start | stop | restart
|
||||
}
|
||||
|
||||
type WorkloadLogsCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Tail int32 `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
type WorkloadLogsResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportWorkloads not implemented")
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
|
||||
out := new(ReportWorkloadsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func _Vantage_ReportWorkloads_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReportWorkloadsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).ReportWorkloads(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportWorkloads"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).ReportWorkloads(ctx, req.(*ReportWorkloadsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
@@ -163,6 +163,70 @@ func (s *vantageServer) ReportPackages(ctx context.Context, req *pb.ReportPackag
|
||||
return &pb.ReportPackagesResponse{NeedFull: false}, nil
|
||||
}
|
||||
|
||||
// ReportWorkloads stores what a server is running.
|
||||
//
|
||||
// It is not gated by licence: the workload registry reads as core fleet
|
||||
// management rather than a premium add-on. If that ever changes, the check
|
||||
// belongs here — gating collection, not display — for the same reason it does
|
||||
// in ReportPackages.
|
||||
func (s *vantageServer) ReportWorkloads(ctx context.Context, req *pb.ReportWorkloadsRequest) (*pb.ReportWorkloadsResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
// The offer call: a hash and no body. Answering NeedFull=false here is what
|
||||
// keeps an unchanged 60-second report to one small message.
|
||||
//
|
||||
// The offer is identified by Full, not by an empty Workloads slice: a host
|
||||
// genuinely running nothing sends an empty list as its FULL report, and
|
||||
// inferring the offer from emptiness would leave that host answering
|
||||
// NeedFull=true forever and never storing anything.
|
||||
if !req.Full {
|
||||
known, err := services.HasWorkloadHash(srv.InstanceID, srv.ServerID, req.Hash)
|
||||
if err != nil {
|
||||
log.Printf("workload hash lookup for %s: %v", srv.ServerID, err)
|
||||
return nil, status.Errorf(codes.Internal, "workload hash lookup failed")
|
||||
}
|
||||
return &pb.ReportWorkloadsResponse{NeedFull: !known}, nil
|
||||
}
|
||||
|
||||
wls := make([]models.Workload, len(req.Workloads))
|
||||
for i, w := range req.Workloads {
|
||||
wls[i] = models.Workload{
|
||||
Kind: w.Kind,
|
||||
ID: w.Id,
|
||||
Name: w.Name,
|
||||
State: w.State,
|
||||
Health: w.Health,
|
||||
Image: w.Image,
|
||||
Stack: w.Stack,
|
||||
Ports: w.Ports,
|
||||
Restarts: int(w.Restarts),
|
||||
Protected: w.Protected,
|
||||
}
|
||||
if w.StartedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, w.StartedAt); err == nil {
|
||||
wls[i].StartedAt = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := storeWorkloadReport(srv.InstanceID, srv.ServerID, req, wls); err != nil {
|
||||
log.Printf("store workloads for %s: %v", srv.ServerID, err)
|
||||
return nil, status.Errorf(codes.Internal, "failed to store workloads")
|
||||
}
|
||||
return &pb.ReportWorkloadsResponse{NeedFull: false}, nil
|
||||
}
|
||||
|
||||
func storeWorkloadReport(instanceID, serverID string, req *pb.ReportWorkloadsRequest, wls []models.Workload) error {
|
||||
if wls == nil {
|
||||
wls = []models.Workload{}
|
||||
}
|
||||
return services.StoreWorkloads(instanceID, serverID, req.Hash, wls,
|
||||
req.DockerOk, req.DockerError, req.SystemdOk, req.SystemdError)
|
||||
}
|
||||
|
||||
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
@@ -257,6 +321,13 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
if m.Result != nil {
|
||||
r := m.Result
|
||||
log.Printf("agent %s cmd %s: success=%v %s", srv.ServerID, r.CommandId, r.Success, r.Message)
|
||||
// Republished so a control action waiting on another pod sees
|
||||
// it. Publishing with no subscriber is a no-op, so this is safe
|
||||
// for every command result rather than only the awaited ones.
|
||||
services.WorkloadResults.DeliverCommand(r)
|
||||
}
|
||||
if m.WorkloadLogsResult != nil {
|
||||
services.WorkloadResults.Deliver(m.WorkloadLogsResult)
|
||||
}
|
||||
if m.StepResult != nil {
|
||||
services.StepResults.Deliver(m.StepResult)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Workload kinds.
|
||||
const (
|
||||
WorkloadContainer = "container"
|
||||
WorkloadUnit = "unit"
|
||||
)
|
||||
|
||||
// Control actions.
|
||||
const (
|
||||
WorkloadStart = "start"
|
||||
WorkloadStop = "stop"
|
||||
WorkloadRestart = "restart"
|
||||
)
|
||||
|
||||
// Workload is one container or one systemd unit.
|
||||
type Workload struct {
|
||||
Kind string `bson:"kind" json:"kind"` // container | unit
|
||||
ID string `bson:"id" json:"id"` // container id, or unit name
|
||||
Name string `bson:"name" json:"name"`
|
||||
|
||||
// State is deliberately NOT collapsed into a shared vocabulary across the
|
||||
// two kinds. Containers report running/exited/paused/restarting/created;
|
||||
// units report active/inactive/failed/activating. A failed unit and an
|
||||
// exited container mean different things, and flattening them loses the
|
||||
// distinction the operator needs.
|
||||
State string `bson:"state" json:"state"`
|
||||
Health string `bson:"health,omitempty" json:"health,omitempty"`
|
||||
|
||||
Image string `bson:"image,omitempty" json:"image,omitempty"`
|
||||
Stack string `bson:"stack,omitempty" json:"stack,omitempty"` // compose project label
|
||||
Ports []string `bson:"ports,omitempty" json:"ports,omitempty"`
|
||||
|
||||
Restarts int `bson:"restarts,omitempty" json:"restarts,omitempty"`
|
||||
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
|
||||
|
||||
// Protected is computed agent-side and reported so the UI can render the
|
||||
// action disabled with a reason rather than offering a button whose refusal
|
||||
// is already known. The field is the courtesy; the agent's own check is the
|
||||
// boundary.
|
||||
Protected bool `bson:"protected" json:"protected"`
|
||||
}
|
||||
|
||||
// ServerWorkloads holds one server's whole workload list in ONE document.
|
||||
type ServerWorkloads struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"-"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hash string `bson:"hash" json:"hash"`
|
||||
Workloads []Workload `bson:"workloads" json:"workloads"`
|
||||
CollectedAt time.Time `bson:"collected_at" json:"collected_at"`
|
||||
|
||||
// A host with no Docker and a host with Docker running nothing both produce
|
||||
// an empty list. One should read "not in use here", the other "nothing
|
||||
// running", and only the second deserves any alarm.
|
||||
//
|
||||
// The error strings separate a third case the booleans cannot: installed
|
||||
// with the daemon down. "Not installed" and "installed but not responding"
|
||||
// are different problems with different fixes.
|
||||
DockerOK bool `bson:"docker_ok" json:"docker_ok"`
|
||||
DockerError string `bson:"docker_error,omitempty" json:"docker_error,omitempty"`
|
||||
SystemdOK bool `bson:"systemd_ok" json:"systemd_ok"`
|
||||
SystemdError string `bson:"systemd_error,omitempty" json:"systemd_error,omitempty"`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,7 @@ var ScopedCollections = []string{
|
||||
"server_packages",
|
||||
"vuln_findings",
|
||||
"vuln_alert_rules",
|
||||
"server_workloads",
|
||||
}
|
||||
|
||||
// collectionRenames maps the two collections whose names change. Ordered so the
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// EnsureWorkloadIndexes declares the indexes for the workload registry.
|
||||
//
|
||||
// It warns rather than being fatal, matching EnsureSecretIndexes and
|
||||
// EnsureVulnIndexes: a missing index degrades these queries to a collection
|
||||
// scan, which is no reason to refuse to serve the fleet.
|
||||
func EnsureWorkloadIndexes() error {
|
||||
ctx := context.Background()
|
||||
|
||||
idx := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "server_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
// Multikey, for the fleet-wide "which servers run image X" query, which
|
||||
// is the reason the snapshot is stored rather than fetched and discarded.
|
||||
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "workloads.image", Value: 1}}},
|
||||
}
|
||||
if _, err := db.Col("server_workloads").Indexes().CreateMany(ctx, idx); err != nil {
|
||||
log.Printf("warning: server_workloads indexes: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
)
|
||||
|
||||
// Workload results travel back over the bus for the same reason commands travel
|
||||
// out over it: the pod serving the HTTP request and the pod holding the agent's
|
||||
// stream are two different processes, and a map in one cannot be read by the
|
||||
// other.
|
||||
//
|
||||
// Await MUST be called before the command is dispatched, or a fast agent
|
||||
// answers into a channel nobody is listening on yet. See stepresults.go.
|
||||
|
||||
type workloadResultRegistry struct{}
|
||||
|
||||
var WorkloadResults = &workloadResultRegistry{}
|
||||
|
||||
// Await subscribes to a command's result channel for a log snapshot.
|
||||
func (r *workloadResultRegistry) Await(commandID string) (<-chan *pb.WorkloadLogsResult, func()) {
|
||||
out := make(chan *pb.WorkloadLogsResult, 1)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
|
||||
if err != nil {
|
||||
log.Printf("workload results: subscribe for %s: %v", commandID, err)
|
||||
cancel()
|
||||
close(out)
|
||||
return out, func() {}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case b, ok := <-raw:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var res pb.WorkloadLogsResult
|
||||
if err := json.Unmarshal(b, &res); err != nil {
|
||||
log.Printf("workload results: undecodable result for %s: %v", commandID, err)
|
||||
return
|
||||
}
|
||||
out <- &res
|
||||
}
|
||||
}()
|
||||
|
||||
return out, func() {
|
||||
cancel()
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
|
||||
// AwaitCommand subscribes to a command's result channel for a plain
|
||||
// CommandResult, which is what a control action answers with.
|
||||
//
|
||||
// A control action reuses CommandResult rather than growing a message of its
|
||||
// own: start, stop and restart succeed or fail, and that is exactly what
|
||||
// CommandResult already says.
|
||||
func (r *workloadResultRegistry) AwaitCommand(commandID string) (<-chan *pb.CommandResult, func()) {
|
||||
out := make(chan *pb.CommandResult, 1)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
|
||||
if err != nil {
|
||||
log.Printf("workload results: subscribe for %s: %v", commandID, err)
|
||||
cancel()
|
||||
close(out)
|
||||
return out, func() {}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case b, ok := <-raw:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var res pb.CommandResult
|
||||
if err := json.Unmarshal(b, &res); err != nil {
|
||||
log.Printf("workload results: undecodable command result for %s: %v", commandID, err)
|
||||
return
|
||||
}
|
||||
out <- &res
|
||||
}
|
||||
}()
|
||||
|
||||
return out, func() {
|
||||
cancel()
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver publishes a log result received from an agent. Called on the pod
|
||||
// holding that agent's stream, which is not usually the pod waiting for it.
|
||||
func (r *workloadResultRegistry) Deliver(res *pb.WorkloadLogsResult) {
|
||||
if res == nil || res.CommandId == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
|
||||
defer cancel()
|
||||
if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
|
||||
log.Printf("workload results: publish for %s: %v", res.CommandId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeliverCommand republishes a CommandResult onto the bus so a waiting pod can
|
||||
// see it. Publishing with no subscriber is a no-op, so this is safe to call for
|
||||
// every CommandResult rather than only the ones somebody is waiting on.
|
||||
func (r *workloadResultRegistry) DeliverCommand(res *pb.CommandResult) {
|
||||
if res == nil || res.CommandId == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
|
||||
defer cancel()
|
||||
if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
|
||||
log.Printf("workload results: publish command result for %s: %v", res.CommandId, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// workloadResultTimeout bounds how long an API request waits for an agent to
|
||||
// answer a control action or a log read. It is well above the agent's own
|
||||
// 90-second control timeout and 60-second log timeout, so a slow-but-working
|
||||
// agent reports its real error rather than being cut off by this side.
|
||||
const workloadResultTimeout = 120 * time.Second
|
||||
|
||||
// MaxWorkloadLogLines mirrors the agent's own cap. It is declared again here
|
||||
// rather than imported: agent/ is a separate module with an internal/ tree, so
|
||||
// the two cannot share a constant. Change one, change the other — the same
|
||||
// shape of hazard as the mirrored token blocks in the web apps.
|
||||
const MaxWorkloadLogLines = 500
|
||||
|
||||
// workloadProtectedMarker is the text the agent's ErrProtected carries. The
|
||||
// refusal crosses the wire as a string, so this is how the control plane knows
|
||||
// a 409 is owed rather than a 502.
|
||||
const workloadProtectedMarker = "workload is protected"
|
||||
|
||||
// IsWorkloadProtected reports whether an agent refused because the target is
|
||||
// protected — the agent's own guard, which is the boundary. Nothing failed, so
|
||||
// the API answers 409 rather than an error status.
|
||||
func IsWorkloadProtected(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), workloadProtectedMarker)
|
||||
}
|
||||
|
||||
func HasWorkloadHash(instanceID, serverID, hash string) (bool, error) {
|
||||
err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
|
||||
"instance_id": instanceID,
|
||||
"server_id": serverID,
|
||||
"hash": hash,
|
||||
}, options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// StoreWorkloads replaces a server's workload list.
|
||||
func StoreWorkloads(instanceID, serverID, hash string, wls []models.Workload,
|
||||
dockerOK bool, dockerErr string, systemdOK bool, systemdErr string) error {
|
||||
|
||||
_, err := db.Col("server_workloads").UpdateOne(context.Background(),
|
||||
bson.M{"instance_id": instanceID, "server_id": serverID},
|
||||
bson.M{"$set": bson.M{
|
||||
"hash": hash,
|
||||
"workloads": wls,
|
||||
"collected_at": time.Now(),
|
||||
"docker_ok": dockerOK,
|
||||
"docker_error": dockerErr,
|
||||
"systemd_ok": systemdOK,
|
||||
"systemd_error": systemdErr,
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetWorkloads(instanceID, serverID string) (*models.ServerWorkloads, error) {
|
||||
var sw models.ServerWorkloads
|
||||
err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
|
||||
"instance_id": instanceID,
|
||||
"server_id": serverID,
|
||||
}).Decode(&sw)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sw, nil
|
||||
}
|
||||
|
||||
type WorkloadHit struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Workload models.Workload `json:"workload"`
|
||||
}
|
||||
|
||||
// SearchWorkloads answers "which servers run image X" — the reason the snapshot
|
||||
// is stored rather than fetched on demand and discarded.
|
||||
func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
if image != "" {
|
||||
filter["workloads.image"] = image
|
||||
}
|
||||
|
||||
cur, err := db.Col("server_workloads").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
var docs []models.ServerWorkloads
|
||||
if err := cur.All(ctx, &docs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hits := []WorkloadHit{}
|
||||
for _, d := range docs {
|
||||
for _, w := range d.Workloads {
|
||||
if image != "" && w.Image != image {
|
||||
continue
|
||||
}
|
||||
if stack != "" && w.Stack != stack {
|
||||
continue
|
||||
}
|
||||
if state != "" && w.State != state {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, WorkloadHit{ServerID: d.ServerID, Workload: w})
|
||||
}
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// DispatchRefreshWorkloads asks an agent to report immediately. It returns as
|
||||
// soon as the owning pod acks; the caller refetches the stored document.
|
||||
//
|
||||
// The refresh carries nothing back on purpose: the agent answers through the
|
||||
// normal ReportWorkloads RPC, so server_workloads has exactly one writer.
|
||||
func DispatchRefreshWorkloads(serverID string) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{
|
||||
CommandId: uuid.New().String(),
|
||||
RefreshWorkloads: &pb.RefreshWorkloadsCmd{},
|
||||
})
|
||||
}
|
||||
|
||||
// DispatchControlWorkload runs a control action and waits for the agent's
|
||||
// CommandResult.
|
||||
//
|
||||
// Await is called BEFORE dispatch. Reversing those two lines introduces a race
|
||||
// that only shows under load, on a fast agent answering into a channel nobody
|
||||
// has joined yet.
|
||||
func DispatchControlWorkload(serverID, kind, id, action string) error {
|
||||
commandID := uuid.New().String()
|
||||
|
||||
results, done := WorkloadResults.AwaitCommand(commandID)
|
||||
defer done()
|
||||
|
||||
if err := Dispatcher.dispatch(serverID, &pb.ServerCommand{
|
||||
CommandId: commandID,
|
||||
ControlWorkload: &pb.ControlWorkloadCmd{Kind: kind, Id: id, Action: action},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case res, ok := <-results:
|
||||
if !ok || res == nil {
|
||||
return fmt.Errorf("no result from agent for %s %s", action, id)
|
||||
}
|
||||
if !res.Success {
|
||||
return fmt.Errorf("%s", res.Message)
|
||||
}
|
||||
return nil
|
||||
case <-time.After(workloadResultTimeout):
|
||||
return fmt.Errorf("timed out waiting for the agent to %s %s", action, id)
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchWorkloadLogs fetches a bounded log snapshot.
|
||||
//
|
||||
// Await is called BEFORE dispatch, for the same reason as above.
|
||||
func DispatchWorkloadLogs(serverID, kind, id string, tail int) (string, bool, error) {
|
||||
commandID := uuid.New().String()
|
||||
|
||||
results, done := WorkloadResults.Await(commandID)
|
||||
defer done()
|
||||
|
||||
if err := Dispatcher.dispatch(serverID, &pb.ServerCommand{
|
||||
CommandId: commandID,
|
||||
WorkloadLogs: &pb.WorkloadLogsCmd{Kind: kind, Id: id, Tail: int32(tail)},
|
||||
}); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
select {
|
||||
case res, ok := <-results:
|
||||
if !ok || res == nil {
|
||||
return "", false, fmt.Errorf("no log result from agent for %s", id)
|
||||
}
|
||||
if res.Error != "" {
|
||||
return "", false, fmt.Errorf("%s", res.Error)
|
||||
}
|
||||
return res.Text, res.Truncated, nil
|
||||
case <-time.After(workloadResultTimeout):
|
||||
return "", false, fmt.Errorf("timed out waiting for logs for %s", id)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+217
-533
@@ -1,15 +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) {
|
||||
@@ -22,297 +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,
|
||||
@@ -323,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: () => {
|
||||
@@ -332,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: () => {
|
||||
@@ -350,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: () => {
|
||||
@@ -364,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">
|
||||
@@ -375,255 +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-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,34 +4,55 @@ 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 { FindingRow } from "@/components/vulnerabilities/FindingRow";
|
||||
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
|
||||
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
|
||||
import { groupByPackage } from "@/lib/vulnPackages";
|
||||
|
||||
/*
|
||||
* The fleet vulnerability board.
|
||||
*
|
||||
* Grouped by CVE, defaulting to open findings, with database freshness always
|
||||
* on screen. The three things this page must never do: imply freshness it does
|
||||
* not have, present an unsupported distribution as clean, or make one CVE on
|
||||
* forty servers look like forty problems.
|
||||
* Grouped by package, defaulting to open findings, with database freshness
|
||||
* always on screen. The three things this page must never do: imply freshness
|
||||
* it does not have, present an unsupported distribution as clean, or make one
|
||||
* upgrade look like several problems.
|
||||
*
|
||||
* The API groups by CVE; the rollup to packages happens here, in
|
||||
* `lib/vulnPackages`, because a finding is still per-CVE everywhere it is
|
||||
* stored, accepted or remediated.
|
||||
*/
|
||||
|
||||
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({
|
||||
@@ -41,6 +62,12 @@ export default function VulnerabilitiesPage() {
|
||||
|
||||
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
|
||||
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
|
||||
@@ -88,7 +115,7 @@ export default function VulnerabilitiesPage() {
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
|
||||
Rescan fleet
|
||||
Rescan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -104,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>
|
||||
@@ -116,67 +144,72 @@ 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 ? (
|
||||
<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>
|
||||
) : groups.data && groups.data.length > 0 ? (
|
||||
groups.data.map((g) => (
|
||||
<FindingRow
|
||||
key={g.cve_id}
|
||||
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}
|
||||
/>
|
||||
))
|
||||
) : packages.length > 0 ? (
|
||||
<>
|
||||
{paged.slice.map((g) => (
|
||||
<PackageRow key={g.package_name} group={g} serverName={serverName} canAct={isAdmin} onAccept={setAccepting} onUnaccept={(f) => unaccept.mutate(f.id)} onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)} applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined} />
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Button, Card, Pagination, Table, Thead, Tbody, Tr, Th, Td, usePagination } from "@/components/ui";
|
||||
import { api, workloads } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* The fleet view answers "which servers run image X", which is the reason the
|
||||
* snapshot is stored at all rather than fetched on demand and discarded.
|
||||
*/
|
||||
export default function WorkloadsPage() {
|
||||
const [image, setImage] = useState("");
|
||||
const [stack, setStack] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [applied, setApplied] = useState<{ image?: string; stack?: string; state?: string }>({});
|
||||
|
||||
const hits = useQuery({
|
||||
queryKey: ["workloads", "fleet", applied],
|
||||
queryFn: () => workloads.search(applied),
|
||||
});
|
||||
|
||||
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
|
||||
const hostnames = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const s of servers.data ?? []) m.set(s.server_id, s.hostname);
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Workloads</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Containers and systemd services across the fleet, as last reported by each agent.</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form
|
||||
className="grid gap-3 sm:grid-cols-4"
|
||||
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)} />
|
||||
<input className={inputClass} placeholder="stack" value={stack} onChange={(e) => setStack(e.target.value)} />
|
||||
<input className={inputClass} placeholder="state" value={state} onChange={(e) => setState(e.target.value)} />
|
||||
<Button type="submit" variant="primary">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card padding={false}>
|
||||
{hits.isLoading ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Server</Th>
|
||||
<Th>Workload</Th>
|
||||
<Th>Kind</Th>
|
||||
<Th>State</Th>
|
||||
<Th>Image</Th>
|
||||
<Th>Stack</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{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">
|
||||
{hostnames.get(h.server_id) ?? h.server_id}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td className="font-mono text-xs">{h.workload.name}</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral">{h.workload.kind}</Badge>
|
||||
</Td>
|
||||
<Td>{h.workload.state}</Td>
|
||||
<Td className="font-mono text-xs">{h.workload.image ?? "—"}</Td>
|
||||
<Td>{h.workload.stack ?? "—"}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,10 +133,23 @@ function ShieldIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkloadIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
||||
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
||||
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
|
||||
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
|
||||
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
|
||||
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
|
||||
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { VulnFinding, VulnGroup } from "@/lib/api";
|
||||
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
|
||||
|
||||
/*
|
||||
* One row per CVE, expandable to the servers it affects.
|
||||
*
|
||||
* The grouping is the point. The same CVE across forty servers is one decision
|
||||
* — patch it, or accept it and say why — and a flat list of forty findings
|
||||
* makes it look like forty decisions, which is how a board stops being read.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
group: VulnGroup;
|
||||
serverName: (serverId: string) => string;
|
||||
canAct: boolean;
|
||||
onAccept: (f: VulnFinding) => void;
|
||||
onUnaccept: (f: VulnFinding) => void;
|
||||
onApplyUpdates: (serverId: string) => void;
|
||||
applying?: string;
|
||||
}
|
||||
|
||||
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
|
||||
// the collapsed row saves opening it to find there is nothing to do.
|
||||
const anyFix = group.findings.some((f) => f.fixed_in);
|
||||
|
||||
return (
|
||||
// A row inside the page's one bordered container, not a card of its
|
||||
// own — the same stack idiom as the monitors and workflows lists.
|
||||
<div className="border-t border-border-soft first:border-t-0">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
|
||||
<SeverityBadge severity={group.severity} />
|
||||
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
|
||||
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
|
||||
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
|
||||
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
{!anyFix && (
|
||||
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
|
||||
no fix
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border-soft bg-surface-2/40">
|
||||
{group.findings.map((f) => (
|
||||
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
|
||||
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
|
||||
{serverName(f.server_id)}
|
||||
</Link>
|
||||
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{f.package_name} {f.installed_version}
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-xs text-text-tertiary">
|
||||
{f.fixed_in ? `→ ${f.fixed_in}` : "no fix published"}
|
||||
</span>
|
||||
|
||||
<StateBadge state={f.state} />
|
||||
|
||||
{f.state === "accepted" && f.accepted && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
|
||||
|
||||
{canAct && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
{/* Remediation is the existing endpoint, not a new
|
||||
mechanism: see it, patch it, one place. */}
|
||||
{f.fixed_in && f.state !== "fixed" && (
|
||||
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
|
||||
Apply updates
|
||||
</Button>
|
||||
)}
|
||||
{f.state === "accepted" ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
f.state === "open" && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
|
||||
Accept
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { VulnFinding } from "@/lib/api";
|
||||
import type { PackageGroup, PackageServer } from "@/lib/vulnPackages";
|
||||
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
|
||||
|
||||
/*
|
||||
* One row per package, expandable to the servers carrying it and the CVEs on
|
||||
* each.
|
||||
*
|
||||
* The grouping is the point, and it is the same argument as the CVE grouping it
|
||||
* replaced, one level in: an operator upgrades a package, not a CVE. Two CVEs
|
||||
* on one apache2 are one upgrade to the higher of the two fix versions, and
|
||||
* showing them as two rows with two different targets is how a fleet gets
|
||||
* patched to the lower one.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
group: PackageGroup;
|
||||
serverName: (serverId: string) => string;
|
||||
canAct: boolean;
|
||||
onAccept: (f: VulnFinding) => void;
|
||||
onUnaccept: (f: VulnFinding) => void;
|
||||
onApplyUpdates: (serverId: string) => void;
|
||||
applying?: string;
|
||||
}
|
||||
|
||||
export function PackageRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// A package with no fix anywhere cannot be patched, only accepted. Saying so
|
||||
// on the collapsed row saves opening it to find there is nothing to do.
|
||||
const anyFix = group.servers.some((s) => s.target);
|
||||
|
||||
return (
|
||||
// A row inside the page's one bordered container, not a card of its
|
||||
// own — the same stack idiom as the monitors and workflows lists.
|
||||
<div className="border-t border-border-soft first:border-t-0">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
|
||||
<SeverityBadge severity={group.severity} />
|
||||
<span className="font-mono text-sm font-medium text-text-primary">{group.package_name}</span>
|
||||
<span className="whitespace-nowrap font-mono text-[11px] text-text-tertiary">
|
||||
{group.cve_count} {group.cve_count === 1 ? "CVE" : "CVEs"}
|
||||
</span>
|
||||
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
|
||||
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
{!anyFix && (
|
||||
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
|
||||
no fix
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border-soft bg-surface-2/40">
|
||||
{group.servers.map((s) => (
|
||||
<ServerBlock
|
||||
key={`${s.package_name}:${s.server_id}`}
|
||||
row={s}
|
||||
serverName={serverName}
|
||||
canAct={canAct}
|
||||
onAccept={onAccept}
|
||||
onUnaccept={onUnaccept}
|
||||
onApplyUpdates={onApplyUpdates}
|
||||
applying={applying}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerBlock({
|
||||
row,
|
||||
serverName,
|
||||
canAct,
|
||||
onAccept,
|
||||
onUnaccept,
|
||||
onApplyUpdates,
|
||||
applying,
|
||||
}: Omit<Props, "group"> & { row: PackageServer }) {
|
||||
const patchable = row.target && row.findings.some((f) => f.state !== "fixed");
|
||||
|
||||
return (
|
||||
<div className="border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Link href={`/servers/${row.server_id}`} className="text-sm text-accent hover:underline">
|
||||
{serverName(row.server_id)}
|
||||
</Link>
|
||||
|
||||
<span className="font-mono text-xs text-text-secondary">{row.installed_version}</span>
|
||||
|
||||
{/* One target, the highest across every CVE on this package.
|
||||
A lower fix version does not remediate a higher one, so it
|
||||
is never the number offered. */}
|
||||
<span className="font-mono text-xs text-text-tertiary">{row.target ? `→ ${row.target}` : "no fix published"}</span>
|
||||
|
||||
{row.superseded && (
|
||||
<span
|
||||
className="whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary"
|
||||
title="Several CVEs name different fix versions; the highest is shown and covers the rest."
|
||||
>
|
||||
supersedes lower fixes
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canAct && patchable && (
|
||||
<div className="ml-auto">
|
||||
{/* Remediation is the existing endpoint, not a new
|
||||
mechanism: see it, patch it, one place. */}
|
||||
<Button size="sm" variant="secondary" loading={applying === row.server_id} onClick={() => onApplyUpdates(row.server_id)}>
|
||||
Apply updates
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{row.findings.map((f) => (
|
||||
<div key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 pl-1">
|
||||
<SeverityBadge severity={f.severity} />
|
||||
<span className="font-mono text-xs text-text-secondary">{f.cve_id}</span>
|
||||
{f.title && <span className="hidden truncate text-xs text-text-tertiary sm:block">{f.title}</span>}
|
||||
|
||||
{/* The per-CVE fix stays visible when it differs from
|
||||
the row's target, so the rollup can be checked
|
||||
rather than taken on trust. */}
|
||||
{f.fixed_in && f.fixed_in !== row.target && (
|
||||
<span className="font-mono text-[11px] text-text-tertiary">fixed in {f.fixed_in}</span>
|
||||
)}
|
||||
{!f.fixed_in && <span className="font-mono text-[11px] text-text-tertiary">no fix</span>}
|
||||
|
||||
<StateBadge state={f.state} />
|
||||
|
||||
{f.state === "accepted" && f.accepted && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
|
||||
|
||||
{canAct && (
|
||||
<div className="ml-auto">
|
||||
{f.state === "accepted" ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
f.state === "open" && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
|
||||
Accept
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { workloads, type WorkloadKind } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* A bounded snapshot, not a follow. The browser console already offers a real
|
||||
* terminal on the same server where `docker logs -f` works properly, with its
|
||||
* own scrollback and cancellation.
|
||||
*/
|
||||
export function LogDialog({
|
||||
serverId,
|
||||
kind,
|
||||
id,
|
||||
name,
|
||||
onClose,
|
||||
}: {
|
||||
serverId: string;
|
||||
kind: WorkloadKind;
|
||||
id: string;
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const pre = useRef<HTMLPreElement>(null);
|
||||
|
||||
const logs = useQuery({
|
||||
queryKey: ["workload-logs", serverId, kind, id],
|
||||
queryFn: () => workloads.logs(serverId, kind, id),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Newest output is the point of the snapshot, so it opens at the bottom.
|
||||
useEffect(() => {
|
||||
if (logs.data && pre.current) pre.current.scrollTop = pre.current.scrollHeight;
|
||||
}, [logs.data]);
|
||||
|
||||
return (
|
||||
<Modal open title={`Logs · ${name}`} onClose={onClose} wide>
|
||||
{logs.isLoading && <p className="text-sm text-text-secondary">Reading logs from the agent…</p>}
|
||||
|
||||
{logs.isError && <p className="text-sm text-danger">{(logs.error as Error).message}</p>}
|
||||
|
||||
{logs.data && (
|
||||
<>
|
||||
{/* Stated, not implied: a truncated log must never be read as
|
||||
a complete one. */}
|
||||
{logs.data.truncated && (
|
||||
<p className="mb-3 rounded border border-warning/50 px-3 py-2 text-xs text-warning">
|
||||
Output was capped at 500 lines or 256KB, whichever came first. Older lines are not shown.
|
||||
</p>
|
||||
)}
|
||||
<pre ref={pre} className="max-h-[55dvh] overflow-auto rounded border border-border bg-well p-3 font-mono text-xs text-text-primary">
|
||||
{logs.data.text || "(no output)"}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { workloads, type Workload, type WorkloadAction, type WorkloadKind } from "@/lib/api";
|
||||
import { WorkloadRow } from "./WorkloadRow";
|
||||
import { LogDialog } from "./LogDialog";
|
||||
|
||||
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`;
|
||||
return `${Math.round(secs / 3600)}h ago`;
|
||||
}
|
||||
|
||||
/** Compose stacks first, grouped under the stack name; then loose containers;
|
||||
* then units. Not cosmetic: a stack is one thing to an operator even when it
|
||||
* is six containers, and a flat list turns one decision into six rows. */
|
||||
function group(list: Workload[]) {
|
||||
const stacks = new Map<string, Workload[]>();
|
||||
const loose: Workload[] = [];
|
||||
const units: Workload[] = [];
|
||||
|
||||
for (const w of list) {
|
||||
if (w.kind === "unit") units.push(w);
|
||||
else if (w.stack) stacks.set(w.stack, [...(stacks.get(w.stack) ?? []), w]);
|
||||
else loose.push(w);
|
||||
}
|
||||
|
||||
const byName = (a: Workload, b: Workload) => a.name.localeCompare(b.name);
|
||||
return {
|
||||
stacks: [...stacks.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([name, items]) => [name, items.sort(byName)] as const),
|
||||
loose: loose.sort(byName),
|
||||
units: units.sort(byName),
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkloadList({ serverId, canControl }: { serverId: string; canControl: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [logTarget, setLogTarget] = useState<Workload | null>(null);
|
||||
|
||||
const snapshot = useQuery({
|
||||
queryKey: ["workloads", serverId],
|
||||
queryFn: () => workloads.forServer(serverId),
|
||||
});
|
||||
|
||||
const refresh = useMutation({
|
||||
mutationFn: () => workloads.refresh(serverId),
|
||||
// The refresh returns no data — the agent reports through the normal
|
||||
// path, so the only correct move is to refetch the stored document.
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const control = useMutation({
|
||||
mutationFn: ({ w, action }: { w: Workload; action: WorkloadAction }) => workloads.control(serverId, w.kind as WorkloadKind, w.id, action),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
qc.invalidateQueries({ queryKey: ["workloads", serverId] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
// Opening the panel asks for a fresh list: this page carries a Restart
|
||||
// button, and a stale row is a wrong action aimed at something already dead.
|
||||
useEffect(() => {
|
||||
refresh.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId]);
|
||||
|
||||
const data = snapshot.data;
|
||||
const grouped = useMemo(() => group(data?.workloads ?? []), [data]);
|
||||
|
||||
const row = (w: Workload) => (
|
||||
<WorkloadRow
|
||||
key={`${w.kind}:${w.id}`}
|
||||
workload={w}
|
||||
canControl={canControl}
|
||||
busy={control.isPending}
|
||||
onAction={(action) => control.mutate({ w, action })}
|
||||
onLogs={() => setLogTarget(w)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card padding={false}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Workloads</h2>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">Collected {relativeAge(data?.collected_at)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" loading={refresh.isPending} onClick={() => refresh.mutate()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
{snapshot.isLoading ? (
|
||||
<p className="text-sm text-text-secondary">Loading…</p>
|
||||
) : !data ? (
|
||||
<p className="text-sm text-text-secondary">Nothing reported yet. Agents report every 60 seconds, on Linux only.</p>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
{/* Docker absent is the common case on a fleet built
|
||||
around SSH keys, and is not a fault. Installed but
|
||||
not responding is a different problem, so it reads
|
||||
differently. */}
|
||||
{data.docker_error ? (
|
||||
<p className="text-warning">Docker is installed but not responding: {data.docker_error}</p>
|
||||
) : !data.docker_ok ? (
|
||||
<p className="text-text-secondary">Docker is not in use on this server.</p>
|
||||
) : null}
|
||||
|
||||
{data.systemd_error ? (
|
||||
<p className="text-warning">systemd could not be read: {data.systemd_error}</p>
|
||||
) : !data.systemd_ok ? (
|
||||
<p className="text-text-secondary">systemd is not in use on this server.</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{grouped.stacks.map(([stack, items]) => (
|
||||
<div key={stack}>
|
||||
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">
|
||||
stack · {stack}
|
||||
</div>
|
||||
{items.map(row)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{grouped.loose.length > 0 && (
|
||||
<div>
|
||||
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">containers</div>
|
||||
{grouped.loose.map(row)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.units.length > 0 && (
|
||||
<div>
|
||||
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">services</div>
|
||||
{grouped.units.map(row)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logTarget && (
|
||||
<LogDialog
|
||||
serverId={serverId}
|
||||
kind={logTarget.kind as WorkloadKind}
|
||||
id={logTarget.id}
|
||||
name={logTarget.name}
|
||||
onClose={() => setLogTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button } from "@/components/ui";
|
||||
import type { Workload, WorkloadAction } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* Container and unit states are kept in their own vocabularies on purpose — a
|
||||
* failed unit and an exited container mean different things. Colour never
|
||||
* carries the state on its own: Badge already pairs a dot with the word.
|
||||
*/
|
||||
function stateVariant(w: Workload): "success" | "warning" | "danger" | "neutral" {
|
||||
if (w.kind === "container") {
|
||||
switch (w.state) {
|
||||
case "running":
|
||||
return w.health === "unhealthy" ? "danger" : "success";
|
||||
case "restarting":
|
||||
case "paused":
|
||||
case "created":
|
||||
return "warning";
|
||||
case "dead":
|
||||
case "exited":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
switch (w.state) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "activating":
|
||||
case "reloading":
|
||||
return "warning";
|
||||
case "failed":
|
||||
return "danger";
|
||||
case "inactive":
|
||||
return "neutral";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIONS: WorkloadAction[] = ["start", "stop", "restart"];
|
||||
|
||||
export function WorkloadRow({
|
||||
workload,
|
||||
canControl,
|
||||
busy,
|
||||
onAction,
|
||||
onLogs,
|
||||
}: {
|
||||
workload: Workload;
|
||||
canControl: boolean;
|
||||
busy: boolean;
|
||||
onAction: (action: WorkloadAction) => void;
|
||||
onLogs: () => void;
|
||||
}) {
|
||||
const w = workload;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-b border-border px-6 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-mono text-sm text-text-primary">{w.name}</span>
|
||||
<Badge variant={stateVariant(w)}>{w.state}</Badge>
|
||||
{w.health && <Badge variant={w.health === "healthy" ? "success" : "warning"}>{w.health}</Badge>}
|
||||
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-text-secondary">
|
||||
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
|
||||
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{canControl &&
|
||||
ACTIONS.map((a) => (
|
||||
<Button
|
||||
key={a}
|
||||
size="sm"
|
||||
variant={a === "stop" ? "danger" : "secondary"}
|
||||
/* Protected rows show the action disabled with the
|
||||
reason rather than offering a button whose refusal
|
||||
the agent has already told us about. */
|
||||
disabled={w.protected || busy}
|
||||
title={w.protected ? "This workload runs the Vantage agent and cannot be controlled from here" : undefined}
|
||||
onClick={() => onAction(a)}
|
||||
>
|
||||
{a}
|
||||
</Button>
|
||||
))}
|
||||
{canControl && (
|
||||
<Button size="sm" variant="ghost" onClick={onLogs}>
|
||||
Logs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+77
-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}` : ""}`);
|
||||
@@ -1054,6 +1057,79 @@ export const vulnerabilities = {
|
||||
},
|
||||
};
|
||||
|
||||
export type WorkloadKind = "container" | "unit";
|
||||
export type WorkloadAction = "start" | "stop" | "restart";
|
||||
|
||||
/** One container or one systemd unit.
|
||||
*
|
||||
* `state` is deliberately not a shared vocabulary across the two kinds:
|
||||
* containers report running/exited/paused/restarting/created, units report
|
||||
* active/inactive/failed/activating. A failed unit and an exited container
|
||||
* mean different things. */
|
||||
export interface Workload {
|
||||
kind: WorkloadKind;
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
health?: string;
|
||||
image?: string;
|
||||
stack?: string;
|
||||
ports?: string[];
|
||||
restarts?: number;
|
||||
started_at?: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export interface ServerWorkloads {
|
||||
server_id: string;
|
||||
hash?: string;
|
||||
workloads: Workload[];
|
||||
collected_at?: string;
|
||||
/** false with no error means "Docker not in use here", which is not a
|
||||
* fault. With an error it means installed but not responding. */
|
||||
docker_ok: boolean;
|
||||
docker_error?: string;
|
||||
systemd_ok: boolean;
|
||||
systemd_error?: string;
|
||||
}
|
||||
|
||||
export interface WorkloadHit {
|
||||
server_id: string;
|
||||
workload: Workload;
|
||||
}
|
||||
|
||||
export const workloads = {
|
||||
forServer(serverId: string): Promise<ServerWorkloads> {
|
||||
return request<ServerWorkloads>(`/servers/${serverId}/workloads`);
|
||||
},
|
||||
|
||||
refresh(serverId: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" });
|
||||
},
|
||||
|
||||
control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(
|
||||
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`,
|
||||
{ method: "POST", body: JSON.stringify({ kind, action }) },
|
||||
);
|
||||
},
|
||||
|
||||
logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> {
|
||||
return request<{ text: string; truncated: boolean }>(
|
||||
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`,
|
||||
);
|
||||
},
|
||||
|
||||
search(params?: { image?: string; stack?: string; state?: string }): Promise<WorkloadHit[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.image) q.set("image", params.image);
|
||||
if (params?.stack) q.set("stack", params.stack);
|
||||
if (params?.state) q.set("state", params.state);
|
||||
const qs = q.toString();
|
||||
return request<WorkloadHit[]>(`/workloads${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
};
|
||||
|
||||
// `request` already prefixes /api, so these paths do not repeat it.
|
||||
export const licence = {
|
||||
get(): Promise<LicenseInfo> {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user