Compare commits
35
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 | ||
|
|
6dced22499 | ||
|
|
5cee53dc5f | ||
|
|
81248bb159 | ||
|
|
6354d54de8 | ||
|
|
da6d64f95c | ||
|
|
9ba3d4a61f | ||
|
|
eee236a072 | ||
|
|
9df89e2db4 |
@@ -147,7 +147,7 @@ with `"schedule"` as the actor, so there is no second dispatch path and the run
|
||||
detail page needed no changes.
|
||||
|
||||
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
|
||||
builds on Alpine, which ships no zone database, so without it
|
||||
runs on `scratch`, which ships no zone database, so without it
|
||||
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
|
||||
back to UTC — an hour wrong for half the year, in the direction nobody notices
|
||||
until a maintenance window lands in business hours. It works on a developer
|
||||
@@ -350,10 +350,86 @@ is where risk goes to be forgotten. An unsupported distribution reports
|
||||
unknown is the same lie as a silently stale database, which is why
|
||||
`vulndb_meta.pulled_at` is on screen rather than only in a log.
|
||||
|
||||
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
|
||||
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
|
||||
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
|
||||
no such file or directory`, logged once at boot while every other subsystem
|
||||
runs normally, so the only symptom is a fleet that never reports a finding.
|
||||
|
||||
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.
|
||||
@@ -506,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);
|
||||
@@ -515,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
|
||||
@@ -572,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)
|
||||
@@ -652,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/`.
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
//
|
||||
// The two Free plans get no rows at all, and that absence is what keeps Free
|
||||
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
|
||||
|
||||
@@ -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,9 +9,11 @@ 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";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export default function InstancePage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -104,7 +106,11 @@ export default function InstancePage() {
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
value: lic.features.join(", ") || "none",
|
||||
// Labelled, not raw keys: this is
|
||||
// the customer's own licence, and
|
||||
// "vuln_scanning" is not a name
|
||||
// anyone bought.
|
||||
value: lic.features.map(featureLabel).join(", ") || "none",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -131,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));
|
||||
|
||||
@@ -6,25 +6,16 @@ import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
|
||||
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
|
||||
import { featureDesc, featureLabel } from "@/lib/features";
|
||||
|
||||
/* Tiers in the order a customer reads them, cheapest first. */
|
||||
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Human labels for feature keys. The catalogue names them by key; this is the
|
||||
* one place the customer-facing wording lives. */
|
||||
const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
};
|
||||
const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
};
|
||||
function featureLabel(key: string) {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
/* Feature wording lives in lib/features.ts, shared with the staff
|
||||
* configurator. It was duplicated here and there, and the two copies had
|
||||
* already drifted. */
|
||||
|
||||
interface Choice {
|
||||
tier: Tier;
|
||||
@@ -294,7 +285,7 @@ export function PurchaseForm() {
|
||||
{featureKeys.map((key) => {
|
||||
const st = featureStateFor(plan, rows, options.env, choice.term, key);
|
||||
return (
|
||||
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
|
||||
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
|
||||
{st === "included" ? (
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
|
||||
) : st === "absent" ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export interface PlanChoice {
|
||||
tier: Tier;
|
||||
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span>{featureLabel(key)}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Human wording for licence feature keys.
|
||||
*
|
||||
* One place, because there were two and they disagreed: the staff configurator
|
||||
* rendered every key that was not "console" as "Single sign-on", so adding a
|
||||
* third feature silently mislabelled the checkbox that grants it. A map with a
|
||||
* fallback degrades to the raw key, which is ugly but never wrong.
|
||||
*
|
||||
* Keys must match shared/license/license.go. */
|
||||
export const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
vuln_scanning: "Vulnerability scanning",
|
||||
};
|
||||
|
||||
export const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
vuln_scanning: "Package inventory matched against distribution security advisories",
|
||||
};
|
||||
|
||||
export function featureLabel(key: string): string {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
|
||||
export function featureDesc(key: string): string {
|
||||
return FEATURE_DESC[key] ?? "";
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
@@ -20,6 +23,38 @@ import (
|
||||
// are not paying for.
|
||||
var collectPackagesFlag atomic.Bool
|
||||
|
||||
// firstPoll closes once a SyncKeys response has set the flag above.
|
||||
//
|
||||
// Without it the boot-time package report loses a race it can only lose: the
|
||||
// hourly loop starts before the first poll, reads a flag that is still false by
|
||||
// construction, and skips — so a freshly installed agent reports no packages for
|
||||
// an hour and the server shows nothing to scan.
|
||||
// How long the boot package report waits for that first poll. Two poll
|
||||
// intervals plus slack: long enough to cover one failed attempt, short enough
|
||||
// that a dead control plane does not hold the OS-update report hostage.
|
||||
const firstPollWait = 90 * time.Second
|
||||
|
||||
var (
|
||||
firstPoll = make(chan struct{})
|
||||
firstPollOnce sync.Once
|
||||
)
|
||||
|
||||
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
|
||||
|
||||
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
|
||||
// bounded because this loop also reports OS updates, which do not depend on the
|
||||
// flag at all — a control plane that cannot be polled must not silence those too.
|
||||
func waitFirstPoll(ctx context.Context, limit time.Duration) {
|
||||
t := time.NewTimer(limit)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-firstPoll:
|
||||
case <-t.C:
|
||||
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
|
||||
|
||||
// reportPackages offers a hash of the installed package set and sends the full
|
||||
|
||||
@@ -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)
|
||||
@@ -101,6 +103,7 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
// goroutine. Absent on the wire decodes as false, so an older server leaves
|
||||
// collection off rather than on.
|
||||
collectPackagesFlag.Store(resp.CollectPackages)
|
||||
markFirstPoll()
|
||||
|
||||
desired := resp.PublicKeys
|
||||
|
||||
@@ -346,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) {
|
||||
@@ -409,6 +421,10 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
reportPackages(client, cfg)
|
||||
}
|
||||
|
||||
// The boot round only: after this the flag has long been set, and every
|
||||
// later tick is an hour past a poll that runs every 30s.
|
||||
waitFirstPoll(ctx, firstPollWait)
|
||||
|
||||
doCheck()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,19 @@ ARG VERSION=dev
|
||||
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
|
||||
# itself — scratch has no shell — and os.MkdirTemp fails outright without it.
|
||||
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
|
||||
|
||||
# Runtime stage
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
# vulndb unpacks the ~50MB trivy-db here. Without it the scheduler stops at
|
||||
# "temp dir: stat /tmp: no such file or directory" and no scanning happens,
|
||||
# while everything else in the process runs perfectly well.
|
||||
COPY --from=builder /staging/tmp /tmp
|
||||
COPY --from=builder /vantage-server /vantage-server
|
||||
|
||||
COPY default_steps/ /opt/default-steps/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+17
-7
@@ -3,27 +3,37 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/server
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
oras.land/oras-go/v2 v2.6.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
oras.land/oras-go/v2 v2.6.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/samber/lo v1.50.0 // indirect
|
||||
github.com/samber/oops v1.18.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.opentelemetry.io/otel v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.34.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
+24
-1
@@ -1,3 +1,5 @@
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -37,6 +39,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -63,6 +67,8 @@ github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f/go.mod h1:
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -72,10 +78,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -84,12 +95,17 @@ github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsT
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
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/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -119,8 +135,14 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.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.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
@@ -169,8 +191,9 @@ google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+14
-20
@@ -21,6 +21,7 @@ const COMPARISON: [string, string, string, string][] = [
|
||||
["Audit history", "30 days", "365 days", "Forever"],
|
||||
["Browser console", "Not Available", "Add-on", "Add-on"],
|
||||
["Single sign-on", "Not Available", "Add-on", "Add-on"],
|
||||
["Vulnerability scanning", "Not Available", "Add-on", "Add-on"],
|
||||
["Support", "Community", "Email, 24/5", "Email and phone, 24/7"],
|
||||
["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"],
|
||||
["Self-hosted term", "Annual, £0", "Annual", "Annual"],
|
||||
@@ -39,10 +40,7 @@ export default function PricingPage() {
|
||||
>
|
||||
Pick a tier, then pay per server.
|
||||
</h1>
|
||||
<p className="lede">
|
||||
You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs
|
||||
the same whether we host it or you do.
|
||||
</p>
|
||||
<p className="lede">You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs the same whether we host it or you do.</p>
|
||||
|
||||
<div className="plans">
|
||||
<div className="plan">
|
||||
@@ -79,7 +77,7 @@ export default function PricingPage() {
|
||||
<li>3 servers included, add as many as you like</li>
|
||||
<li>Unlimited monitors, secrets and channels</li>
|
||||
<li>365 days of audit history</li>
|
||||
<li>Browser console and single sign-on as add-ons</li>
|
||||
<li>Browser console, single sign-on and vulnerability scanning as add-ons</li>
|
||||
<li>Email support, 24/5</li>
|
||||
</ul>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
@@ -125,6 +123,13 @@ export default function PricingPage() {
|
||||
<p>Connect your own OIDC provider to an instance. £90 a year.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">£7 / MO</span>
|
||||
<div>
|
||||
<h3>Vulnerability Scanning</h3>
|
||||
<p>Agents will scan and report known Vulnerabilites. £70 a year.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="scroll__hint">Scroll the table sideways for all three tiers</p>
|
||||
@@ -179,39 +184,28 @@ export default function PricingPage() {
|
||||
<span className="spec__k">LIMITS</span>
|
||||
<div>
|
||||
<h3>Reaching your limit</h3>
|
||||
<p>
|
||||
Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove
|
||||
one.
|
||||
</p>
|
||||
<p>Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove one.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">CANCELLING</span>
|
||||
<div>
|
||||
<h3>What happens when you stop paying</h3>
|
||||
<p>
|
||||
You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your
|
||||
servers keep the access they have. You just can't change anything until you renew.
|
||||
</p>
|
||||
<p>You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your servers keep the access they have. You just can't change anything until you renew.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">SELF-HOSTED</span>
|
||||
<div>
|
||||
<h3>Why self-hosted is yearly only</h3>
|
||||
<p>
|
||||
When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can't switch
|
||||
one off partway through, so we sell it a year at a time.
|
||||
</p>
|
||||
<p>When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can't switch one off partway through, so we sell it a year at a time.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">EXIT</span>
|
||||
<div>
|
||||
<h3>Leaving</h3>
|
||||
<p>
|
||||
You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.
|
||||
</p>
|
||||
<p>You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -104,14 +104,7 @@ function Allowance({ label, used, limit }: { label: string; used: number; limit:
|
||||
{unlimited ? (
|
||||
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">No limit</p>
|
||||
) : (
|
||||
<div
|
||||
className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-2"
|
||||
role="meter"
|
||||
aria-valuenow={used}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={limit}
|
||||
aria-label={`${label}: ${used} of ${limit} used`}
|
||||
>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-2" role="meter" aria-valuenow={used} aria-valuemin={0} aria-valuemax={limit} aria-label={`${label}: ${used} of ${limit} used`}>
|
||||
<div className={`h-full rounded-full ${fill}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -122,12 +115,7 @@ function Allowance({ label, used, limit }: { label: string; used: number; limit:
|
||||
function Feature({ label, included }: { label: string; included: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border text-[0.6rem] font-bold ${
|
||||
included ? "border-success text-success" : "border-border text-text-tertiary"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border text-[0.6rem] font-bold ${included ? "border-success text-success" : "border-border text-text-tertiary"}`}>
|
||||
{included ? "✓" : "–"}
|
||||
</span>
|
||||
<span className="text-sm text-text-primary">{label}</span>
|
||||
@@ -183,11 +171,7 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
|
||||
<Keyed label="Instance ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="truncate font-mono text-xs text-text-primary">{license.instance_id}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyId}
|
||||
className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary"
|
||||
>
|
||||
<button type="button" onClick={copyId} className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -265,6 +249,7 @@ export default function LicensePage() {
|
||||
<div className="divide-y divide-border-soft">
|
||||
<Feature label="Browser console" included={Boolean(license.features.console)} />
|
||||
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
|
||||
<Feature label="Vulnerability Scanning" included={Boolean(license.features.vuln_scanning)} />
|
||||
</div>
|
||||
</Card>
|
||||
</Group>
|
||||
@@ -272,9 +257,7 @@ export default function LicensePage() {
|
||||
{isCloud ? (
|
||||
<Group label="Where this licence comes from">
|
||||
<Card>
|
||||
<p className="max-w-prose text-sm text-text-secondary">
|
||||
This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste.
|
||||
</p>
|
||||
<p className="max-w-prose text-sm text-text-secondary">This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste.</p>
|
||||
{hqUrl && (
|
||||
<div className="mt-5 border-t border-border-soft pt-5">
|
||||
<a href={hqUrl} target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -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 } 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
|
||||
@@ -78,92 +105,111 @@ export default function VulnerabilitiesPage() {
|
||||
const total = SEVERITY_ORDER.reduce((n, s) => n + (counts[s] ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<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-xl font-bold text-text-primary">Vulnerabilities</h1>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Vulnerabilities</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Installed packages matched against distribution security advisories.
|
||||
{total} open finding{total !== 1 ? "s" : ""} · installed packages matched against distribution security advisories
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
|
||||
Rescan fleet
|
||||
Rescan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DBFreshness summary={summary.data} />
|
||||
<div className="mb-4">
|
||||
<DBFreshness summary={summary.data} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 rounded-lg border border-border bg-surface px-5 py-4">
|
||||
{/* Severity counts double as the filter. They are always the whole
|
||||
fleet's open counts, never the filtered view's, so switching
|
||||
state cannot make the fleet look better than it is. */}
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-lg border border-border bg-surface px-4 py-3 sm:px-5">
|
||||
{SEVERITY_ORDER.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSeverity(severity === s ? "" : s)}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1 text-left transition-colors ${
|
||||
severity === s ? "bg-surface-2" : "hover:bg-surface-2"
|
||||
}`}
|
||||
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"}`}
|
||||
>
|
||||
<SeverityBadge severity={s} />
|
||||
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
|
||||
{total} open
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{STATES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setState(s)}
|
||||
className={`rounded 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"
|
||||
}`}
|
||||
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"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{groups.isLoading && <p className="text-sm text-text-secondary">Loading…</p>}
|
||||
{groups.error && <p className="text-sm text-danger">{(groups.error as Error).message}</p>}
|
||||
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
{groups.data && groups.data.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface px-5 py-8 text-center">
|
||||
<p className="text-sm text-text-secondary">No {state} findings.</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
Servers report packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own
|
||||
page rather than counted here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{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}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{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 })}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
) : 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` : ""}
|
||||
{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">
|
||||
{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 })} />}
|
||||
</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,104 +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 (
|
||||
<div className="rounded-lg border border-border bg-surface">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-surface-2"
|
||||
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 text-xs text-text-secondary">
|
||||
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
{!anyFix && <span className="whitespace-nowrap text-xs text-text-tertiary">no fix published</span>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
{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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
|
||||
<h2 className="text-lg font-semibold text-text-primary">Vulnerabilities</h2>
|
||||
<Link href="/vulnerabilities">
|
||||
<Button variant="ghost" size="sm">
|
||||
Fleet board →
|
||||
View All
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -52,18 +52,13 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
|
||||
{packages.isLoading || findings.isLoading ? (
|
||||
<p className="text-sm text-text-secondary">Loading…</p>
|
||||
) : !pkg ? (
|
||||
<p className="text-sm text-text-secondary">
|
||||
No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance's
|
||||
licence.
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance's licence.</p>
|
||||
) : pkg.status === "unsupported" ? (
|
||||
<>
|
||||
<p className="text-sm text-warning">
|
||||
{pkg.os.family} {pkg.os.version_id} has no advisory feed, so this server cannot be scanned.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
This is not the same as having no vulnerabilities — it means we cannot answer the question for this distribution.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">This is not the same as having no vulnerabilities — it means we cannot answer the question for this distribution.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -94,15 +89,11 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
|
||||
<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>
|
||||
<span className="font-mono text-xs text-text-tertiary">{f.fixed_in ? `→ ${f.fixed_in}` : "no fix published"}</span>
|
||||
<StateBadge state={f.state} />
|
||||
</li>
|
||||
))}
|
||||
{open.length > 10 && (
|
||||
<li className="pt-1 text-xs text-text-tertiary">…and {open.length - 10} more on the fleet board.</li>
|
||||
)}
|
||||
{open.length > 10 && <li className="pt-1 text-xs text-text-tertiary">…and {open.length - 10} more on the fleet board.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user