Compare commits

...
21 Commits
Author SHA1 Message Date
mrhid6 d559cccd44 feat: Updated server page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 45s
2026-08-07 16:21:17 +01:00
mrhid6 0684d84609 fix: Fixed vuln score
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m40s
2026-08-07 15:33:50 +01:00
mrhid6 78f1bf853c fix: More fixes to vuln matching
Chart Release / chart (push) Successful in 31s
Server Deploy / deploy (push) Successful in 1m42s
2026-08-07 13:29:15 +01:00
mrhid6 e28238191d feat: Added vuln filter
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m29s
2026-08-07 11:58:42 +01:00
mrhid6 82bcc5776f fix: Fixed vuln scanning
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 4m7s
2026-08-07 11:13:58 +01:00
mrhid6 1993802c38 feat: Updated rescan button text 2026-08-07 10:52:16 +01:00
mrhid6 5db49b6b0e feat: Vuln debug logs
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m47s
2026-08-07 10:50:08 +01:00
mrhid6 0c15b25ecd fix: Fixed agent package version
Server Deploy / deploy (push) Successful in 14s
Chart Release / chart (push) Successful in 26s
Agent Release / build (push) Successful in 11m32s
Agent Release / msi (push) Successful in 1m9s
2026-08-07 10:21:00 +01:00
mrhid6 0c21765da3 feat: Added pagination
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m39s
2026-08-07 09:59:50 +01:00
mrhid6 4ff8fc8d51 docs: document the workload registry
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 8m45s
Agent Release / build (push) Successful in 10m54s
Agent Release / msi (push) Successful in 2m31s
2026-08-07 09:09:18 +01:00
mrhid6 483053b9a2 feat: workload registry UI 2026-08-07 09:06:36 +01:00
mrhid6 fd4c51f3db feat: workload registry REST API 2026-08-07 09:01:46 +01:00
mrhid6 1b351cfca4 feat: agent reports workloads and handles workload commands 2026-08-07 08:58:46 +01:00
mrhid6 cf9d85b3cd feat: store workload reports and route log results 2026-08-07 08:56:26 +01:00
mrhid6 6a4ef5b6c6 feat: workload registry proto messages 2026-08-07 08:53:33 +01:00
mrhid6 501cf4e733 feat: agent reads bounded workload logs 2026-08-07 08:49:42 +01:00
mrhid6 89c21d752a feat: agent control actions with self-protection 2026-08-07 08:48:27 +01:00
mrhid6 0e38d9d500 feat: agent enumerates systemd services 2026-08-07 08:46:59 +01:00
mrhid6 3511c34daa feat: agent enumerates docker containers 2026-08-07 08:46:03 +01:00
mrhid6 0838d1d735 feat: models and indexes for the workload registry 2026-08-07 08:45:00 +01:00
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
58 changed files with 4292 additions and 731 deletions
+78 -2
View File
@@ -360,6 +360,76 @@ Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
scheduler off entirely.
### Workload registry
A **workload** is one Docker container or one systemd unit — one word for the
page, the collection and the commands, rather than saying "container or
service" in every identifier. Linux only, and **not gated by licence**: this
reads as core fleet management, so v1 ships everywhere with no `HasFeature`
check. If that changes the check belongs at `ReportWorkloads`, gating collection
rather than display, exactly as sub-project A does.
Agents collect on a 60-second ticker and report through `ReportWorkloads` with
the **offer-then-send** handshake the package report already uses. The offer is
identified by an explicit `full` flag, **not by an empty workloads list**: a
host genuinely running nothing sends an empty list as its full report, and
inferring the offer from emptiness leaves that host answering `need_full` every
60 seconds forever and never storing anything.
**The on-demand refresh returns no data.** `RefreshWorkloadsCmd` carries nothing
back; it makes the agent report through the normal RPC and the UI refetches. A
refresh that returned workloads inline would be a second writer for
`server_workloads`, arriving by a different route with its own serialisation and
its own opportunity to disagree with the periodic one. One writer, one shape.
Opening the panel dispatches a refresh because the panel has a Restart button on
it, and a stale row is a wrong action aimed at a container that already died.
Two operations do answer back, both over the bus, both with `Await` called
**before** dispatch: control actions reuse the existing `CommandResult`, and log
reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
`CommandResult` onto `bus.ResultChannel` — publishing with no subscriber is a
no-op, so this costs nothing and avoids a second result path.
**The protected set is computed agent-side and enforced agent-side.**
`vantage-agent.service`, plus the container ID read from `/proc/self/cgroup`
should the agent ever run in a container. As with the console relay hardcoding
`127.0.0.1`, the control plane may name a target but the agent decides what it
will do to itself; a server-side denylist alone would be bypassed by the next
dispatch path someone adds, and the failure is unrecoverable from the UI. The
reported `Protected` flag is the courtesy that greys the button; the agent's own
check is the boundary. The API answers **409** when it fires — nothing failed.
Collection avoids parsing English: `docker ps -aq` then
`docker inspect --format '{{json .}}'`, because `docker ps` reports health and
uptime inside a human `Status` string that is localised and reworded between
releases. Compose stacks come from the `com.docker.compose.project` label, never
from YAML on disk — a compose file there may not be what is running. systemd
uses **column** output, not `--output=json`, which needs systemd 246+.
`DockerOK`/`DockerError` are two fields because there are three states: not
installed (common on this fleet, and not a fault), installed but not responding,
and running nothing. The UI must render the first as "not in use here" rather
than an empty list.
Logs are capped at **500 lines and 256KB, whichever binds first** — a line count
alone does not bound size, and 500 lines of 4KB JSON is 2MB across the bus. The
cap is mirrored in `services.MaxWorkloadLogLines` because `agent/` is a separate
module with an `internal/` tree and the constant cannot be shared; change one,
change the other. There is **no follow mode**: the browser console already gives
a real terminal where `docker logs -f` works properly. Log reads and control
actions are **owner|admin and audited**, unlike the read-only snapshot — a
container's stdout is arbitrary and cannot be masked the way a workflow's can.
`server_workloads` is one document per server, mirroring `server_packages`, and
is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). There
is no history: a workload list is state, not a record.
**`proto/vantage/v1/vantage.proto` is documentation, not a generator input.**
Both `pb` packages are hand-written JSON-tagged structs over a custom codec, and
there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
A message added to one must be added to the other and to the `.proto`, in the
same commit.
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
@@ -512,6 +582,7 @@ service Vantage {
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
@@ -521,7 +592,8 @@ service Vantage {
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`, `RefreshWorkloadsCmd`, `ControlWorkloadCmd`,
`WorkloadLogsCmd`.
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
The server sends one every 20s on an otherwise idle command stream; the agent
@@ -578,6 +650,10 @@ vulns GET /vulnerabilities · GET /vulnerabilities/summary
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
GET /packages/search?name=
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
workloads GET /workloads · GET /servers/:id/workloads
POST /servers/:id/workloads/refresh
POST /servers/:id/workloads/:wid/action (owner|admin)
GET /servers/:id/workloads/:wid/logs (owner|admin)
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
@@ -658,7 +734,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
+13
View File
@@ -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()
+7
View File
@@ -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)
+80
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+14 -1
View File
@@ -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]
+11
View File
@@ -70,6 +70,8 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
go runInventory(ctx, cfg)
go runWorkloads(ctx, cfg)
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
@@ -347,6 +349,15 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
if cmd.OpenProxy != nil {
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
}
if cmd.RefreshWorkloads != nil {
go handleRefreshWorkloads(cfg)
}
if cmd.ControlWorkload != nil {
go handleControlWorkload(send, cfg, cmd.CommandId, cmd.ControlWorkload)
}
if cmd.WorkloadLogs != nil {
go handleWorkloadLogs(send, cfg, cmd.CommandId, cmd.WorkloadLogs)
}
if cmd.RunStep != nil {
go func(rc *pb.RunStepCmd, cid string) {
emit := func(seq uint64, data []byte) {
+161
View File
@@ -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,
})
}
+105
View File
@@ -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
}
+141
View File
@@ -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()
}
+83
View File
@@ -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
}
+94
View File
@@ -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
}
+64
View File
@@ -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))
}
+90
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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=
+68
View File
@@ -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;
}
+4
View File
@@ -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 {
+9
View File
@@ -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)
}
}
+17
View File
@@ -32,6 +32,7 @@ func listVulnerabilities(c *gin.Context) {
State: c.DefaultQuery("state", models.FindingOpen),
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
HasFix: hasFixFromQuery(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -82,6 +83,22 @@ func groupByCVE(findings []models.VulnFinding) []vulnGroup {
return out
}
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
// or malformed value, is no filter — a filter nobody asked for must never hide
// findings, and the wrong direction here hides the unfixable ones.
func hasFixFromQuery(c *gin.Context) *bool {
switch c.Query("has_fix") {
case "true":
v := true
return &v
case "false":
v := false
return &v
default:
return nil
}
}
// tagsFromQuery reads repeated tag=key:value parameters.
func tagsFromQuery(c *gin.Context) map[string]string {
out := map[string]string{}
+196
View File
@@ -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
}
+9
View File
@@ -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},
+101
View File
@@ -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)
}
+71
View File
@@ -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)
+70
View File
@@ -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"`
}
+56 -9
View File
@@ -52,6 +52,9 @@ func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now ti
Installed: r.Installed,
FixedIn: r.FixedIn,
Severity: r.Severity,
CVSSScore: r.CVSSScore,
Title: r.Title,
References: r.References,
State: models.FindingOpen,
FirstSeen: now,
LastSeen: now,
@@ -112,6 +115,10 @@ type FindingFilter struct {
State string
ServerID string
Tags map[string]string
// HasFix nil is no filter. true is "a vendor fix exists, this is
// patchable"; false is the unfixable set — remove the package, disable the
// service, or accept it, but do not wait for an update.
HasFix *bool
}
// ListInstanceFindings returns findings across the whole fleet.
@@ -130,6 +137,17 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
filter["server_id"] = f.ServerID
}
// fixed_in is omitempty, so a finding with no vendor fix carries no such
// field at all rather than an empty string. Both forms must be matched, or
// the unfixable set reads as empty on any document written before this.
if f.HasFix != nil {
if *f.HasFix {
filter["fixed_in"] = bson.M{"$nin": bson.A{"", nil}}
} else {
filter["fixed_in"] = bson.M{"$in": bson.A{"", nil}}
}
}
// The tag selector resolves through ResolveTargets, the single answer to
// which servers a selector touches. A second matcher here could disagree
// with what a workflow means by env:prod.
@@ -207,8 +225,11 @@ func MarkInstanceForRescan(instanceID string) (int64, error) {
bson.M{"$set": bson.M{"scan_pending": true}},
)
if err != nil {
log.Printf("vulnsched: mark rescan for instance %s: %v", instanceID, err)
return 0, err
}
log.Printf("vulnsched: rescan requested for instance %s, %d of %d server(s) flagged",
instanceID, res.ModifiedCount, res.MatchedCount)
return res.ModifiedCount, nil
}
@@ -300,19 +321,40 @@ func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.Vu
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
col := db.Col("vuln_findings")
// One BulkWrite per batch, not one UpdateOne per finding. A freshly scanned
// Ubuntu host opens tens of thousands of findings, and at one round trip each
// that is minutes of sequential latency during which the tick holds the
// leader and every other server waits its turn. Unordered, because the
// upserts are independent and one duplicate-key race must not abandon the
// rest of the batch.
const bulkBatch = 1000
ops := make([]mongo.WriteModel, 0, bulkBatch)
flush := func() error {
if len(ops) == 0 {
return nil
}
_, err := col.BulkWrite(ctx, ops, options.BulkWrite().SetOrdered(false))
ops = ops[:0]
return err
}
for _, f := range d.Upserts {
_, err := col.UpdateOne(ctx,
bson.M{
ops = append(ops, mongo.NewUpdateOneModel().
SetFilter(bson.M{
"instance_id": instanceID,
"server_id": serverID,
"cve_id": f.CVEID,
"package_name": f.PackageName,
},
bson.M{
}).
SetUpdate(bson.M{
"$set": bson.M{
"installed_version": f.Installed,
"fixed_in": f.FixedIn,
"severity": f.Severity,
"cvss_score": f.CVSSScore,
"title": f.Title,
"references": f.References,
"state": models.FindingOpen,
"last_seen": now,
},
@@ -326,13 +368,18 @@ func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d Findin
"first_seen": f.FirstSeen,
},
"$unset": bson.M{"fixed_at": "", "accepted": ""},
},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}).
SetUpsert(true))
if len(ops) >= bulkBatch {
if err := flush(); err != nil {
return err
}
}
}
if err := flush(); err != nil {
return err
}
if len(d.FixedIDs) > 0 {
if _, err := col.UpdateMany(ctx,
@@ -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
}
+128
View File
@@ -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)
}
}
+206
View File
@@ -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)
}
}
+9
View File
@@ -1,6 +1,7 @@
package vulndb
import (
"log"
"strings"
trivydb "github.com/aquasecurity/trivy-db/pkg/db"
@@ -14,6 +15,11 @@ type Advisory struct {
// state, not an absence of data, and callers must treat it as vulnerable.
FixedVersion string
Severity string
// Status is filled by trivy-db ONLY when FixedVersion is empty — when there
// is a fix, "fixed" is the obvious state and the field is left zero. It is
// what separates "the vendor confirms this package is affected and has not
// fixed it" from "nobody has looked yet".
Status string
}
// VulnInfo is the CVE's own metadata, shared across every server it affects.
@@ -33,8 +39,10 @@ type Store struct {
// it appends "trivy.db" itself.
func Open(dir string) (*Store, error) {
if err := trivydb.Init(dir); err != nil {
log.Printf("vulndb: open %s: %v", dir, err)
return nil, err
}
log.Printf("vulndb: opened database in %s", dir)
return &Store{cfg: trivydb.Config{}}, nil
}
@@ -55,6 +63,7 @@ func (s *Store) Advisories(bucket, srcName string) ([]Advisory, error) {
// Vulnerability.Severity which is a string. They are genuinely
// different types in trivy-db, not an inconsistency here.
Severity: severityFromLevel(a.Severity),
Status: a.Status.String(),
})
}
return out, nil
+64
View File
@@ -0,0 +1,64 @@
package vulndb
// VulnSource is the CVE-metadata lookup the enricher needs. *Store satisfies it.
type VulnSource interface {
Vulnerability(cveID string) (VulnInfo, error)
}
// MetaCache enriches match results with the CVE's own metadata.
//
// This is not an optimisation, it is where severity comes from. Debian, Ubuntu
// and Alpine advisories carry no severity of their own — trivy-db leaves
// Advisory.Severity zero for those buckets and keeps the rating in the
// vulnerability bucket's VendorSeverity map instead. Taking the advisory's
// value alone reported an entire fleet as "unknown".
//
// One cache per tick, shared across servers: a CVE affects every host running
// the package, and the bolt read is the same read every time.
type MetaCache struct {
src VulnSource
seen map[string]VulnInfo
}
func NewMetaCache(src VulnSource) *MetaCache {
return &MetaCache{src: src, seen: make(map[string]VulnInfo)}
}
// Enrich fills severity, title, score and references in place.
//
// The advisory's severity is kept when the vulnerability bucket has nothing
// better to say — RHEL does publish it per advisory — so this can only raise
// the quality of the answer, never lower it.
func (m *MetaCache) Enrich(results []Result) {
if m == nil {
return
}
for i := range results {
info, ok := m.lookup(results[i].CVEID)
if !ok {
continue
}
if info.Severity != "" && info.Severity != "unknown" {
results[i].Severity = info.Severity
}
results[i].Title = info.Title
results[i].CVSSScore = info.CVSSScore
results[i].References = info.References
}
}
func (m *MetaCache) lookup(cveID string) (VulnInfo, bool) {
if info, ok := m.seen[cveID]; ok {
return info, true
}
info, err := m.src.Vulnerability(cveID)
if err != nil {
// A CVE with an advisory but no vulnerability document is a real state
// in trivy-db, not a fault. Cache the miss so it is asked once.
Debugf("no vulnerability record for %s: %v", cveID, err)
m.seen[cveID] = VulnInfo{}
return VulnInfo{}, false
}
m.seen[cveID] = info
return info, true
}
+126 -6
View File
@@ -14,12 +14,19 @@ type AdvisorySource interface {
}
// Result is one vulnerable package on one server, before it becomes a finding.
//
// Severity, Title, CVSSScore and References are only as good as the advisory
// until MetaCache.Enrich has run over them — for the Debian-family buckets the
// advisory carries no severity at all, so an unenriched Result reads "unknown".
type Result struct {
CVEID string
PackageName string // the BINARY package, which is what is installed
Installed string
FixedIn string
Severity string
Title string
CVSSScore float64
References []string
}
// Match returns every advisory that the installed packages do not satisfy.
@@ -32,11 +39,22 @@ type Result struct {
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
bucket, err := Bucket(os.Family, os.VersionID)
if err != nil {
log.Printf("vulndb: no bucket for family=%q version=%q: %v", os.Family, os.VersionID, err)
return nil, err
}
log.Printf("vulndb: matching %d packages against bucket %q", len(pkgs), bucket)
var advisoryCount, skipped, unactionable, noFix int
// Advisories are keyed on the SOURCE package, and several hundred binary
// packages on a host resolve to the same few hundred sources — linux-modules,
// linux-image and linux-headers all ask about "linux", whose advisory list is
// thousands long. Without this the same bolt read is repeated once per binary
// package, which is most of what made a single Ubuntu host take minutes.
cache := make(map[string][]Advisory, len(pkgs))
var out []Result
for _, p := range pkgs {
for _, p := range newestPerSource(os.Family, pkgs) {
// Debian and Ubuntu advisories are keyed on the source package: one
// advisory against "openssl" covers libssl3, openssl and libssl-dev.
srcName := p.SourceName
@@ -44,15 +62,30 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
srcName = p.Name
}
advs, err := src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
advs, cached := cache[srcName]
if !cached {
var err error
advs, err = src.Advisories(bucket, srcName)
if err != nil {
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
}
cache[srcName] = advs
}
advisoryCount += len(advs)
if len(advs) > 0 {
Debugf("%s (src %s, installed %s): %d advisories", p.Name, srcName, p.Version, len(advs))
}
for _, a := range advs {
// No published fix. Vulnerable, and the finding most in need of
// acceptance, since there is nothing to patch.
// No published fix. Whether that is a finding depends entirely on the
// status the vendor attached to it — see actionable().
if a.FixedVersion == "" {
if !actionable(a.Status) {
unactionable++
Debugf("%s (src %s): %s skipped, status %q", p.Name, srcName, a.CVEID, a.Status)
continue
}
noFix++
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
Installed: p.Version, Severity: a.Severity,
@@ -67,8 +100,10 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
// on the host. Log it — a silent skip is a silent false
// negative, which is the direction that hurts.
log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
skipped++
continue
}
Debugf("%s %s vs fixed %s (%s): vulnerable=%t", p.Name, p.Version, a.FixedVersion, a.CVEID, older)
if older {
out = append(out, Result{
CVEID: a.CVEID, PackageName: p.Name,
@@ -77,5 +112,90 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
}
}
}
log.Printf("vulndb: bucket %q done: %d packages, %d sources, %d advisories considered, "+
"%d results (%d with no vendor fix), %d skipped as not-yet-triaged, %d unparseable comparisons",
bucket, len(pkgs), len(cache), advisoryCount, len(out), noFix, unactionable, skipped)
return out, nil
}
// newestPerSource collapses the installed set to one binary package per source
// package: the one carrying the highest version.
//
// Advisories are keyed on the source, so every binary package of a source asks
// the same question. Normally they all carry the same version and the answer is
// the same, so collapsing is free. The kernel is the exception that makes it
// necessary: Ubuntu encodes the ABI in the binary name, so an upgrade INSTALLS
// linux-headers-6.8.0-137 beside linux-headers-6.8.0-124 rather than replacing
// it, and the old one lingers until an autoremove. Matched per binary package,
// a fully patched host reports every superseded ABI package as vulnerable —
// which is the noise this exists to stop — and reports it twice over, once for
// linux-headers-6.8.0-124 and again for its -generic sibling.
//
// The version, not the name, decides. There is no kernel special case here: a
// source's newest installed version is what the fix landed as, whatever the
// source is.
//
// A comparison that cannot be made keeps the incumbent rather than guessing;
// the loser is dropped either way, and dropping the parseable one would be the
// false-negative direction.
func newestPerSource(family string, pkgs []models.InstalledPackage) []models.InstalledPackage {
best := make(map[string]models.InstalledPackage, len(pkgs))
order := make([]string, 0, len(pkgs))
for _, p := range pkgs {
src := p.SourceName
if src == "" {
src = p.Name
}
cur, seen := best[src]
if !seen {
best[src] = p
order = append(order, src)
continue
}
older, err := LessThan(family, cur.Version, p.Version)
if err != nil {
log.Printf("vulndb: newest for source %s: compare %s vs %s: %v",
src, cur.Version, p.Version, err)
continue
}
if older {
Debugf("source %s: %s %s supersedes %s %s",
src, p.Name, p.Version, cur.Name, cur.Version)
best[src] = p
}
}
out := make([]models.InstalledPackage, 0, len(order))
for _, src := range order {
out = append(out, best[src])
}
return out
}
// actionable decides whether an advisory with no fixed version is a finding.
//
// trivy-db fills Status only when FixedVersion is empty, and Ubuntu publishes a
// status for every CVE against every source package it ships — the vast
// majority being "under_investigation" (the tracker's needs-triage), meaning
// nobody has yet established that the package is affected at all. Reporting
// those produced ~24,000 findings for a single 797-package host, which is not a
// security report, it is a wall. A "not_affected" is the vendor stating the
// opposite of a finding, so it is never one.
//
// What survives is what the vendor has confirmed: affected, will_not_fix,
// fix_deferred, end_of_life. Those are exactly the findings the CLAUDE.md rule
// is about — an empty fixed_in that means "no fix exists", the one most in need
// of acceptance rather than patching.
// Only the two statuses that positively say "this is not a finding" are
// dropped. "unknown" is kept: a feed that sets no status at all must not become
// a silent false negative, and it is not what generates the noise — Ubuntu
// states under_investigation explicitly.
func actionable(status string) bool {
switch status {
case "not_affected", "under_investigation":
return false
default:
return true
}
}
+29 -1
View File
@@ -7,9 +7,11 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
@@ -49,6 +51,24 @@ func Disabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULNDB_DISABLED"), "true")
}
// DebugEnabled turns on per-package and per-advisory tracing.
//
// It is a switch rather than always-on because a single scan asks the store one
// question per installed package — ~2000 lines per server, per tick — which
// would bury every other subsystem's logs on a fleet of any size. The lifecycle
// logs (pull, tick, per-server totals) are unconditional; only the inner loop
// is gated.
func DebugEnabled() bool {
return strings.EqualFold(os.Getenv("VANTAGE_VULN_DEBUG"), "true")
}
// Debugf logs only when VANTAGE_VULN_DEBUG=true.
func Debugf(format string, args ...any) {
if DebugEnabled() {
log.Printf("vulndb[debug]: "+format, args...)
}
}
// dbMetadata is the subset of trivy-db's metadata.json we read.
type dbMetadata struct {
Version int `json:"Version"`
@@ -62,6 +82,8 @@ type dbMetadata struct {
// one that Open would happily accept and scan against.
func Pull(ctx context.Context, dir string) (int, error) {
ref := Ref()
started := time.Now()
log.Printf("vulndb: pull starting ref=%s dir=%s", ref, dir)
parsed, err := registry.ParseReference(ref)
if err != nil {
@@ -92,6 +114,8 @@ func Pull(ctx context.Context, dir string) (int, error) {
if len(man.Layers) == 0 {
return 0, fmt.Errorf("artifact %s has no layers", ref)
}
log.Printf("vulndb: manifest resolved layers=%d digest=%s size=%dB",
len(man.Layers), man.Layers[0].Digest, man.Layers[0].Size)
// Streamed rather than buffered: the layer is ~50MB and there is no reason
// to hold it in memory on the way to disk.
@@ -110,6 +134,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
if err := extractTarGz(rc, staging); err != nil {
return 0, fmt.Errorf("extract layer: %w", err)
}
log.Printf("vulndb: layer extracted into %s after %s", staging, time.Since(started).Round(time.Millisecond))
metaBytes, err := os.ReadFile(filepath.Join(staging, metaFileName))
if err != nil {
@@ -123,9 +148,11 @@ func Pull(ctx context.Context, dir string) (int, error) {
return 0, fmt.Errorf("trivy-db schema %d is not supported (want %d)", meta.Version, SupportedSchema)
}
if _, err := os.Stat(filepath.Join(staging, dbFileName)); err != nil {
fi, err := os.Stat(filepath.Join(staging, dbFileName))
if err != nil {
return 0, fmt.Errorf("artifact has no %s: %w", dbFileName, err)
}
log.Printf("vulndb: %s is %dB, schema %d accepted", dbFileName, fi.Size(), meta.Version)
// Both files present and the schema accepted, so it is safe to replace.
for _, name := range []string{dbFileName, metaFileName} {
@@ -139,6 +166,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
}
}
log.Printf("vulndb: pull complete ref=%s schema=%d in %s", ref, meta.Version, time.Since(started).Round(time.Millisecond))
return meta.Version, nil
}
+44 -3
View File
@@ -51,11 +51,17 @@ func Start(ctx context.Context, deps Deps) {
dir, err := os.MkdirTemp("", "vantage-vulndb-")
if err != nil {
log.Printf("vulnsched: temp dir: %v", err)
// The classic form of this is "stat /tmp: no such file or directory" on
// the scratch runtime image. It is logged once at boot while everything
// else runs normally, so the only other symptom is a fleet that never
// reports a finding.
log.Printf("vulnsched: temp dir: %v (scan loop NOT started)", err)
return
}
s := &scheduler{deps: deps, dir: dir}
log.Printf("vulnsched: started, ref=%s tick=%s dir=%s debug=%t",
vulndb.Ref(), tickInterval, dir, vulndb.DebugEnabled())
go func() {
defer os.RemoveAll(dir)
@@ -66,6 +72,7 @@ func Start(ctx context.Context, deps Deps) {
for {
select {
case <-ctx.Done():
log.Println("vulnsched: leadership lost or shutting down, scan loop stopping")
return
case <-ticker.C:
s.tick(ctx)
@@ -75,16 +82,22 @@ func Start(ctx context.Context, deps Deps) {
}
func (s *scheduler) tick(ctx context.Context) {
started := time.Now()
vulndb.Debugf("vulnsched tick starting (store loaded=%t, db version=%d, pulled %s ago)",
s.store != nil, s.version, time.Since(s.pulled).Round(time.Second))
if err := s.ensureDB(ctx); err != nil {
// Keep the last good database and carry on scanning against it. A
// network blip must never clear findings or read as "all fixed".
log.Printf("vulnsched: database unavailable: %v", err)
s.recordDBError(ctx, err)
if s.store == nil {
log.Println("vulnsched: no database loaded at all, nothing can be scanned this tick")
return
}
}
s.scanPending(ctx)
vulndb.Debugf("vulnsched tick finished in %s", time.Since(started).Round(time.Millisecond))
}
// ensureDB pulls a fresh database when the local copy is stale, and marks the
@@ -93,8 +106,11 @@ func (s *scheduler) tick(ctx context.Context) {
// next agent report.
func (s *scheduler) ensureDB(ctx context.Context) error {
if s.store != nil && time.Since(s.pulled) < dbMaxAge {
vulndb.Debugf("database is %s old, under the %s limit; not pulling",
time.Since(s.pulled).Round(time.Second), dbMaxAge)
return nil
}
log.Printf("vulnsched: pulling database (age %s, max %s)", time.Since(s.pulled).Round(time.Second), dbMaxAge)
version, err := vulndb.Pull(ctx, s.dir)
if err != nil {
@@ -110,6 +126,7 @@ func (s *scheduler) ensureDB(ctx context.Context) error {
s.pulled = time.Now()
changed := version != s.version
log.Printf("vulnsched: database ready, schema %d (previous %d, changed=%t)", version, s.version, changed)
s.version = version
_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
@@ -152,19 +169,29 @@ func (s *scheduler) scanPending(ctx context.Context) {
return
}
if len(pending) == 0 {
vulndb.Debugf("no servers pending scan")
return
}
log.Printf("vulnsched: %d server(s) pending scan", len(pending))
// Newly opened findings are collected across the whole tick and sent as one
// digest per instance. A database refresh can open several hundred findings
// at once; one message per finding would rate-limit the webhook or get the
// channel muted, and either way the alerts stop being read.
newly := map[string][]models.VulnFinding{}
// One metadata cache for the whole tick: a CVE affects every host running
// the package, and the lookup is the same read each time.
meta := vulndb.NewMetaCache(s.store)
for _, sp := range pending {
if ctx.Err() != nil {
// Leadership lost. scan_pending is still set, so the next leader
// picks these up — which is why it lives on the document.
return
}
opened := s.scanOne(ctx, sp)
opened := s.scanOne(ctx, sp, meta)
newly[sp.InstanceID] = append(newly[sp.InstanceID], opened...)
}
@@ -180,8 +207,10 @@ func (s *scheduler) scanPending(ctx context.Context) {
)
}
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []models.VulnFinding {
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages, meta *vulndb.MetaCache) []models.VulnFinding {
now := time.Now()
log.Printf("vulnsched: scanning server %s (instance %s, os %s %s, %d packages)",
sp.ServerID, sp.InstanceID, sp.OS.Family, sp.OS.VersionID, len(sp.Packages))
results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
if err != nil {
@@ -193,11 +222,19 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
if !errors.Is(err, vulndb.ErrUnsupportedFamily) {
log.Printf("vulnsched: scan %s: %v", sp.ServerID, err)
status = sp.Status
} else {
log.Printf("vulnsched: server %s marked unsupported: no feed for %s %s",
sp.ServerID, sp.OS.Family, sp.OS.VersionID)
}
s.clearPending(ctx, sp.ID, status, now)
return nil
}
// Severity for the Debian-family buckets lives on the CVE, not the
// advisory. Without this every finding is stored as "unknown", which also
// silences every alert rule with a minimum severity.
meta.Enrich(results)
existing, err := services.ListFindings(ctx, sp.InstanceID, sp.ServerID)
if err != nil {
log.Printf("vulnsched: list findings %s: %v", sp.ServerID, err)
@@ -210,6 +247,10 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
return nil
}
log.Printf("vulnsched: server %s scanned: %d matches, %d existing, %d upserts, %d newly opened, %d reopened, %d fixed",
sp.ServerID, len(results), len(existing), len(diff.Upserts),
len(diff.NewlyOpened), len(diff.ReopenIDs), len(diff.FixedIDs))
s.clearPending(ctx, sp.ID, models.ScanStatusOK, now)
for i := range diff.NewlyOpened {
+217 -533
View File
@@ -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>
</>
);
}
+81 -48
View File
@@ -4,34 +4,55 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button, Card } from "@/components/ui";
import { Button, Card, Pagination, usePagination } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
import { groupByPackage } from "@/lib/vulnPackages";
/*
* The fleet vulnerability board.
*
* Grouped by CVE, defaulting to open findings, with database freshness always
* on screen. The three things this page must never do: imply freshness it does
* not have, present an unsupported distribution as clean, or make one CVE on
* forty servers look like forty problems.
* Grouped by package, defaulting to open findings, with database freshness
* always on screen. The three things this page must never do: imply freshness
* it does not have, present an unsupported distribution as clean, or make one
* upgrade look like several problems.
*
* The API groups by CVE; the rollup to packages happens here, in
* `lib/vulnPackages`, because a finding is still per-CVE everywhere it is
* stored, accepted or remediated.
*/
const STATES: FindingState[] = ["open", "accepted", "fixed"];
/*
* The fix filter. "Unfixable" is not a synonym for "ignorable": those findings
* are the ones whose action is to remove the package, disable the service or
* move off an end-of-life release, and they are invisible in a list sorted for
* patching. Splitting them is what lets the patchable list be worked top to
* bottom without them quietly disappearing.
*/
const FIX_FILTERS: { key: string; label: string; hasFix: boolean | undefined }[] = [
{ key: "all", label: "All", hasFix: undefined },
{ key: "fixable", label: "Fix available", hasFix: true },
{ key: "nofix", label: "No fix", hasFix: false },
];
export default function VulnerabilitiesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const [state, setState] = useState<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
const [fixFilter, setFixFilter] = useState("all");
const [accepting, setAccepting] = useState<VulnFinding | null>(null);
const hasFix = FIX_FILTERS.find((f) => f.key === fixFilter)?.hasFix;
const groups = useQuery({
queryKey: ["vulnerabilities", state, severity],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined }),
queryKey: ["vulnerabilities", state, severity, fixFilter],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined, hasFix }),
});
const summary = useQuery({
@@ -41,6 +62,12 @@ export default function VulnerabilitiesPage() {
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
// Each package row carries its own findings and servers underneath it, so
// the cost of a full fleet's board is well past the row count alone.
const paged = usePagination(packages, 25);
const serverName = useMemo(() => {
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
// Falls back to the raw id rather than an empty cell: an unnamed row is
@@ -88,7 +115,7 @@ export default function VulnerabilitiesPage() {
</div>
{isAdmin && (
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
Rescan fleet
Rescan
</Button>
)}
</div>
@@ -104,11 +131,12 @@ export default function VulnerabilitiesPage() {
{SEVERITY_ORDER.map((s) => (
<button
key={s}
onClick={() => setSeverity(severity === s ? "" : s)}
onClick={() => {
setSeverity(severity === s ? "" : s);
paged.reset();
}}
aria-pressed={severity === s}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${
severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"
}`}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"}`}
>
<SeverityBadge severity={s} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
@@ -116,67 +144,72 @@ export default function VulnerabilitiesPage() {
))}
</div>
<div className="mb-4 flex gap-2">
<div className="mb-4 flex flex-wrap items-center gap-2">
{STATES.map((s) => (
<button
key={s}
onClick={() => setState(s)}
onClick={() => {
setState(s);
paged.reset();
}}
aria-pressed={state === s}
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"}`}
>
{s}
</button>
))}
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
{FIX_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => {
setFixFilter(f.key);
paged.reset();
}}
aria-pressed={fixFilter === f.key}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${fixFilter === f.key ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"}`}
>
{f.label}
</button>
))}
</div>
{groups.error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(groups.error as Error).message}
</div>
)}
{groups.error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(groups.error as Error).message}</div>}
<Card padding={false}>
{groups.isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : groups.data && groups.data.length > 0 ? (
groups.data.map((g) => (
<FindingRow
key={g.cve_id}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
))
) : packages.length > 0 ? (
<>
{paged.slice.map((g) => (
<PackageRow key={g.package_name} group={g} serverName={serverName} canAct={isAdmin} onAccept={setAccepting} onUnaccept={(f) => unaccept.mutate(f.id)} onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)} applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined} />
))}
<Pagination page={paged.page} pageCount={paged.pageCount} size={paged.size} total={paged.total} onPage={paged.setPage} onSize={paged.setSize} unit="packages" />
</>
) : (
<div className="px-6 py-14 text-center">
<p className="text-[15px] font-semibold text-text-primary">
No {state} findings{severity ? ` at ${severity} severity` : ""}.
No {state} findings{severity ? ` at ${severity} severity` : ""}
{hasFix === true ? " with a fix available" : hasFix === false ? " without a vendor fix" : ""}.
</p>
{/* Named explicitly, because "no findings" under a filter
the reader has forgotten setting reads as a clean
fleet the one claim this page must never make by
accident. */}
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">
Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported
on its own page rather than counted as clean here.
{hasFix !== undefined
? "This is a filtered view. Switch to All to see every finding in this state."
: "Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own page rather than counted as clean here."}
</p>
</div>
)}
</Card>
{accepting && (
<AcceptDialog
finding={accepting}
serverName={serverName(accepting.server_id)}
pending={accept.isPending}
onClose={() => setAccepting(null)}
onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })}
/>
)}
{accepting && <AcceptDialog finding={accepting} serverName={serverName(accepting.server_id)} pending={accept.isPending} onClose={() => setAccepting(null)} onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })} />}
</div>
);
}
+116
View File
@@ -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>
);
}
+13
View File
@@ -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 /> },
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { GenerateKeyOptions } from "@/lib/api";
import { Button } from "@/components/ui";
const KEY_SIZES: Record<string, number[]> = {
rsa: [2048, 3072, 4096],
ecdsa: [256, 384, 521],
};
const DEFAULT_SIZE: Record<string, number> = {
rsa: 4096,
ecdsa: 256,
};
export function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
const [label, setLabel] = useState("");
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
const [keySize, setKeySize] = useState<number>(4096);
const [passphrase, setPassphrase] = useState("");
const [comment, setComment] = useState("");
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
setKeyType(t);
if (t !== "ed25519") {
setKeySize(DEFAULT_SIZE[t]);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
label: label || "generated",
key_type: keyType,
key_size: keyType !== "ed25519" ? keySize : undefined,
passphrase: passphrase || undefined,
comment: comment || undefined,
});
}
const sizes = KEY_SIZES[keyType];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
<button onClick={onClose} className="rounded-md p-1 text-text-secondary transition-colors hover:text-text-primary">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. server-deploy-key"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => handleKeyTypeChange(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t}
</button>
))}
</div>
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>}
</div>
{sizes && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
<select
value={keySize}
onChange={(e) => setKeySize(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="e.g. user@hostname"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="Optional passphrase"
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
Generate Key
</Button>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import { Button } from "@/components/ui";
/*
* Every action this page can take, behind one control.
*
* The header used to carry up to six buttons a Connect per console protocol,
* OS updates, Generate key, Remove and which of them appeared depended on the
* server, so the row an operator reached for moved between machines. One button
* in one place is worth more than a shortcut that is sometimes there.
*
* An action the licence or the host does not allow is rendered disabled with a
* reason rather than hidden: a customer cannot buy what they cannot see, and a
* control that vanishes reads as a bug.
*/
export interface ServerAction {
/** Grouping heading. Items sharing one carry it once, on the first. */
group?: string;
label: string;
icon: React.ReactNode;
onSelect?: () => void;
href?: string;
disabled?: boolean;
/** Why it is disabled, or what it will do. Shown as the native tooltip. */
title?: string;
danger?: boolean;
/** Draws a rule above this item. */
separated?: boolean;
}
export function ServerActionsMenu({ actions }: { actions: ServerAction[] }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const router = useRouter();
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
menuRef.current?.querySelector<HTMLElement>("[role=menuitem]:not([aria-disabled=true])")?.focus();
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function onMenuKeyDown(e: React.KeyboardEvent) {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>("[role=menuitem]:not([aria-disabled=true])") ?? [])];
const i = items.indexOf(document.activeElement as HTMLElement);
items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
}
function run(action: ServerAction) {
if (action.disabled) return;
setOpen(false);
buttonRef.current?.focus();
if (action.href) router.push(action.href);
action.onSelect?.();
}
return (
<div ref={wrapRef} className="relative">
<Button ref={buttonRef} variant="primary" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
Actions
<svg className={clsx("h-4 w-4 transition-transform", open && "rotate-180")} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
</Button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label="Server actions"
onKeyDown={onMenuKeyDown}
className="absolute right-0 z-50 mt-1.5 w-60 rounded border border-border bg-surface p-1 shadow-panel"
>
{actions.map((action, i) => (
<div key={action.label}>
{action.separated && i > 0 && <div className="my-1 h-px bg-border-soft" />}
{action.group && <p className="px-2 pb-1 pt-1.5 font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-tertiary">{action.group}</p>}
<button
type="button"
role="menuitem"
aria-disabled={action.disabled}
disabled={action.disabled}
title={action.title}
onClick={() => run(action)}
className={clsx(
"flex w-full items-center gap-2.5 rounded px-2 py-2 text-left text-sm font-medium transition-colors",
action.disabled
? "cursor-not-allowed text-text-tertiary"
: action.danger
? "text-danger hover:bg-danger/10"
: "text-text-primary hover:bg-surface-2 [&>svg]:hover:text-accent",
!action.disabled && !action.danger && "[&>svg]:text-text-tertiary",
action.danger && "[&>svg]:text-danger",
action.disabled && "[&>svg]:text-text-tertiary",
)}
>
{action.icon}
{action.label}
</button>
</div>
))}
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useRef } from "react";
import { clsx } from "clsx";
/*
* The tab bar under the faceplate.
*
* Below md it is a select instead. Five tabs do not fit a phone, and the two
* usual answers are both worse: wrapping to a second row changes the height of
* the sticky header as the selection moves, and a horizontally scrolling strip
* hides tabs off the right edge with nothing saying they are there. A select
* shows every section and its count in one list, and it is the platform's own
* picker, so it needs no scroll affordance of ours.
*
* Counts live on the labels because that is the only way an operator learns
* there is something wrong on a tab they are not looking at. A count with a
* tone is still labelled by its tab name, so tone is never the whole message
* and in the select, where tone cannot survive, the count still does.
*/
export type TabId = "overview" | "workloads" | "security" | "access" | "maintenance";
export interface TabSpec {
id: TabId;
label: string;
count?: number;
tone?: "neutral" | "warning" | "danger";
}
export function ServerTabs({ tabs, active, onSelect }: { tabs: TabSpec[]; active: TabId; onSelect: (id: TabId) => void }) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
function onKeyDown(e: React.KeyboardEvent) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = tabs.findIndex((t) => t.id === active);
const next = tabs[(i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length];
onSelect(next.id);
refs.current[next.id]?.focus();
}
const current = tabs.find((t) => t.id === active);
return (
<>
{/* Phone: the whole set in one picker, sitting on the same row as the
section it names so the header keeps its height. */}
<div className="pb-3 md:hidden">
<label htmlFor="server-tab-select" className="sr-only">
Server section
</label>
<div className="relative">
<select
id="server-tab-select"
value={active}
onChange={(e) => onSelect(e.target.value as TabId)}
className="w-full appearance-none rounded border border-border bg-surface-2 py-2 pl-3 pr-9 text-sm font-semibold text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
{tab.label}
{tab.count !== undefined && tab.count > 0 ? ` (${tab.count})` : ""}
</option>
))}
</select>
<svg className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
</div>
{current?.count !== undefined && current.count > 0 && current.tone && current.tone !== "neutral" && (
<p className={clsx("mt-1.5 text-xs", current.tone === "danger" ? "text-danger" : "text-warning")}>
{current.count} item{current.count !== 1 ? "s" : ""} in {current.label.toLowerCase()}
</p>
)}
</div>
<div role="tablist" aria-label="Server sections" onKeyDown={onKeyDown} className="-mb-px hidden gap-1 md:flex">
{tabs.map((tab) => {
const isActive = tab.id === active;
return (
<button
key={tab.id}
ref={(el) => {
refs.current[tab.id] = el;
}}
type="button"
role="tab"
id={`server-tab-${tab.id}`}
aria-selected={isActive}
aria-controls={`server-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(tab.id)}
className={clsx(
"flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-3 py-2.5 text-sm transition-colors",
isActive ? "border-accent font-semibold text-text-primary" : "border-transparent font-medium text-text-secondary hover:text-text-primary",
)}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span
className={clsx(
"rounded-full border px-1.5 py-px font-mono text-[0.62rem] tabular-nums",
tab.tone === "danger"
? "border-danger/40 bg-danger/10 text-danger"
: tab.tone === "warning"
? "border-warning/40 bg-warning/10 text-warning"
: "border-border bg-surface-2 text-text-secondary",
)}
>
{tab.count}
</span>
)}
</button>
);
})}
</div>
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { clsx } from "clsx";
import { Inventory, Server } from "@/lib/api";
import { formatBytes, relativeAge } from "./format";
/*
* The four numbers an operator opens a server for, kept above the tabs so they
* are true on every tab rather than living inside one of them. This is the only
* part of the page that does not move when the tab changes.
*
* A meter is a hairline, not a bar: four of them across the top would otherwise
* out-shout the hostname, and the number beside each is the value being read
* the meter only says how close to full it is.
*/
function Meter({ pct }: { pct: number }) {
const clamped = Math.max(0, Math.min(100, pct));
return (
<div className="mt-2 h-[3px] w-full overflow-hidden rounded-full bg-well">
<div
className={clsx("h-full rounded-full transition-[width] duration-500", clamped >= 90 ? "bg-danger" : clamped >= 75 ? "bg-warning" : "bg-accent")}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
function Vital({ label, value, pct, sub }: { label: string; value: string; pct?: number; sub?: string }) {
return (
<div className="min-w-0 bg-surface px-4 py-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-secondary">{label}</span>
<span className="font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</span>
</div>
{pct !== undefined ? <Meter pct={pct} /> : <div className="mt-2 h-[3px] w-full rounded-full bg-well" />}
{sub && <p className="mt-1.5 truncate text-xs text-text-tertiary">{sub}</p>}
</div>
);
}
/** The partition an operator means by "the disk": the root filesystem, or the
* fullest one if there is no root a Windows agent reports no `/`. */
function primaryPartition(inv: Inventory) {
const parts = inv.partitions ?? [];
if (parts.length === 0) return undefined;
return parts.find((p) => p.mountpoint === "/") ?? parts.reduce((worst, p) => (p.used_bytes / (p.total_bytes || 1) > worst.used_bytes / (worst.total_bytes || 1) ? p : worst));
}
export function VitalsRail({ server, agentUpToDate }: { server: Server; agentUpToDate?: boolean }) {
const inv = server.inventory;
const disk = inv ? primaryPartition(inv) : undefined;
const memPct = inv && inv.memory.total_bytes > 0 ? (inv.memory.used_bytes / inv.memory.total_bytes) * 100 : 0;
const diskPct = disk && disk.total_bytes > 0 ? (disk.used_bytes / disk.total_bytes) * 100 : 0;
const agentSub = server.agent_version ? `agent v${server.agent_version}${agentUpToDate === undefined ? "" : agentUpToDate ? " · up to date" : " · update available"}` : "agent version unknown";
return (
// One hairline grid rather than four cards: these are readings off one
// machine, and four bordered panels would read as four subjects.
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded border border-border-soft bg-border-soft lg:grid-cols-4">
{inv ? (
<>
<Vital
label="CPU"
value={`${inv.cpu.usage_pct.toFixed(0)}%`}
pct={inv.cpu.usage_pct}
sub={[inv.cpu.cores ? `${inv.cpu.cores} cores` : null, inv.cpu.load1 !== undefined ? `load ${inv.cpu.load1.toFixed(2)}` : null].filter(Boolean).join(" · ") || inv.cpu.model}
/>
<Vital
label="Memory"
value={`${formatBytes(inv.memory.used_bytes)} / ${formatBytes(inv.memory.total_bytes)}`}
pct={memPct}
sub={inv.swap_total_bytes > 0 ? `swap ${formatBytes(inv.swap_used_bytes)} / ${formatBytes(inv.swap_total_bytes)}` : "no swap"}
/>
<Vital
label={disk ? `Disk ${disk.mountpoint}` : "Disk"}
value={disk ? `${diskPct.toFixed(0)}%` : "—"}
pct={disk ? diskPct : undefined}
sub={disk ? `${formatBytes(disk.used_bytes)} / ${formatBytes(disk.total_bytes)}${disk.fstype ? ` · ${disk.fstype}` : ""}` : "no partitions reported"}
/>
</>
) : (
<>
<Vital label="CPU" value="—" sub="no metrics reported" />
<Vital label="Memory" value="—" sub="no metrics reported" />
<Vital label="Disk" value="—" sub="no metrics reported" />
</>
)}
<Vital label="Last seen" value={relativeAge(server.last_seen)} pct={server.status === "active" ? 100 : 0} sub={agentSub} />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
/** Formatting shared by the server detail panels. One copy, because the rail
* and the storage panel must round the same bytes the same way. */
export function formatBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
}
export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
export function relativeAge(iso?: string): string {
if (!iso) return "never";
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${Math.round(secs)}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
if (secs < 86_400) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86_400)}d ago`;
}
+72
View File
@@ -0,0 +1,72 @@
/** The line icons the server actions menu uses. Heroicons outline, 1.5 stroke,
* the same set and weight the sidebar draws. */
const props = { className: "h-4 w-4 shrink-0 transition-colors", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5 } as const;
export function ConsoleIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
/>
</svg>
);
}
export function KeyIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
);
}
export function ArrowUpCircleIcon() {
return (
<svg {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75l-3 3m3-3l3 3m-3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
export function ShieldIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.998 2.25a.75.75 0 01.298.062l7.5 3.214a.75.75 0 01.454.69v5.034c0 4.63-2.94 8.75-7.5 10.25a.75.75 0 01-.5 0c-4.56-1.5-7.5-5.62-7.5-10.25V6.216a.75.75 0 01.454-.69l7.5-3.214a.75.75 0 01.294-.062z"
/>
</svg>
);
}
export function TrashIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166M18.16 19.673A2.25 2.25 0 0115.916 21.75H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-11 .397c.34-.059.68-.114 1.022-.165M15.75 5.393v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
}
export function RefreshIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992V4.356m-4.992 4.992l3.181-3.03a8.25 8.25 0 00-13.803 3.03M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.03a8.25 8.25 0 0013.803-3.03"
/>
</svg>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { formatDate } from "../format";
export function AccessTab({ server, onGenerateKey }: { server: ServerWithKeys; onGenerateKey: () => void }) {
const assignments = (server.keys ?? []).filter((a) => a.key);
const active = assignments.filter((a) => !a.revoked_at).length;
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="flex items-center gap-2 text-lg font-semibold text-text-primary">
Installed SSH keys
<span className="rounded-full bg-surface-2 px-2 py-0.5 font-mono text-[0.68rem] tabular-nums text-text-secondary">{active} active</span>
</h2>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onGenerateKey}>
Generate key
</Button>
<Link href="/keys">
<Button variant="ghost" size="sm">
Manage keys
</Button>
</Link>
</div>
</div>
{assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-text-secondary">No keys assigned to this server.</p>
<Link href="/keys">
<Button variant="secondary" size="sm" className="mt-3">
Assign a key
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Label</Th>
<Th>Fingerprint</Th>
<Th>Source</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{assignments.map((assignment) => (
<Tr key={assignment.key_id}>
<Td label="Label">
<span className="font-medium">{assignment.key.label}</span>
</Td>
<Td label="Fingerprint">
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
</Td>
<Td label="Source">
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
</Td>
<Td label="Assigned">
<span className="text-xs text-text-secondary">{formatDate(assignment.assigned_at)}</span>
</Td>
<Td>
<Link href={`/keys/${assignment.key_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { api, ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
/*
* Everything that changes what is installed on the machine: its OS packages,
* its agent, and its existence.
*
* The OS update list is a panel here rather than the modal it used to be. A
* modal made the list a detour off a header button; the work of patching a
* server is the reason this tab exists, so the list is the tab.
*/
export function MaintenanceTab({
server,
latestVersion,
onApplyUpdates,
isApplying,
applySuccess,
onUpdateAgent,
isUpdatingAgent,
updateAgentSuccess,
onDelete,
isDeleting,
}: {
server: ServerWithKeys;
latestVersion?: string;
onApplyUpdates: () => void;
isApplying: boolean;
applySuccess: boolean;
onUpdateAgent: () => void;
isUpdatingAgent: boolean;
updateAgentSuccess: boolean;
onDelete: () => void;
isDeleting: boolean;
}) {
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const updates = server.available_updates ?? [];
const command = api.getUpdateCommand(server.os_info);
const isWindows = server.os_info?.toLowerCase().includes("windows");
const agentCurrent = !!latestVersion && !!server.agent_version && server.agent_version === latestVersion;
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">OS updates</h2>
{updates.length > 0 ? <Badge variant="warning">{updates.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
</div>
{updates.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">No pending package updates. The agent checks hourly.</p>
) : (
<>
{/* Capped so a host with 400 pending packages does not make the
Apply button a scroll away. The count is on the badge. */}
<div className="max-h-80 overflow-y-auto">
<Table>
<Thead>
<Tr>
<Th>Package</Th>
<Th>Current</Th>
<Th>Available</Th>
</Tr>
</Thead>
<Tbody>
{updates.map((u) => (
<Tr key={u.name}>
<Td label="Package">
<span className="font-mono text-sm font-medium">{u.name}</span>
</Td>
<Td label="Current">
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
</Td>
<Td label="Available">
<span className="font-mono text-xs text-success">{u.new_version}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border px-6 py-4">
<Button variant="primary" loading={isApplying} onClick={onApplyUpdates} disabled={server.status !== "active"} title={server.status !== "active" ? "Agent must be online to apply updates" : undefined}>
{applySuccess ? "Sent!" : "Apply updates"}
</Button>
<p className="text-xs text-text-tertiary">Upgrade runs in the background and may take several minutes.</p>
</div>
</>
)}
</Card>
<div className="space-y-6">
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Agent</h2>
{latestVersion && server.agent_version && <Badge variant={agentCurrent ? "success" : "warning"}>{agentCurrent ? "up to date" : "update available"}</Badge>}
</div>
<div className="space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-text-secondary">Installed: </span>
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
</div>
<div>
<span className="text-text-secondary">Latest: </span>
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion}` : "n/a"}</span>
</div>
</div>
<div className="relative overflow-x-auto rounded border border-border bg-well px-4 py-2.5 font-mono text-sm">
<span className="text-accent">{isWindows ? "PS>" : "$"}</span> <span className="text-text-primary">{command}</span>
<button
onClick={async () => {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="absolute right-2 top-1.5 rounded border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
<Button
variant="primary"
loading={isUpdatingAgent}
onClick={onUpdateAgent}
disabled={server.status !== "active"}
title={server.status !== "active" ? "Agent must be online to update" : undefined}
>
{updateAgentSuccess ? "Update sent!" : "Update agent"}
</Button>
</div>
</Card>
<Card padding={false} className="border-danger/30">
<div className="border-b border-danger/30 px-6 py-4">
<h2 className="text-lg font-semibold text-danger">Remove server</h2>
</div>
<div className="space-y-4 px-6 py-5">
<p className="text-sm text-text-secondary">
Deletes this server and its history from Vantage. The agent stays installed on the machine and keeps trying to connect until you uninstall it there.
</p>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Remove server
</Button>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-danger">Remove {server.hostname}?</span>
<Button variant="danger" loading={isDeleting} onClick={onDelete}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
</div>
</Card>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { clsx } from "clsx";
import { ServerWithKeys } from "@/lib/api";
import { Card, CardHeader, CardTitle } from "@/components/ui";
import { formatBytes, formatDate } from "../format";
import type { TabId } from "../ServerTabs";
/*
* Overview answers one question: is anything wrong with this machine, and where
* do I go about it. The detail lives on the other tabs everything here either
* states a fact about the host or points at the tab that can act on it.
*/
export interface Attention {
tone: "danger" | "warning";
title: string;
detail: string;
/** The tab that can do something about it. */
goTo: TabId;
action: string;
}
function StoragePanel({ server }: { server: ServerWithKeys }) {
const partitions = server.inventory?.partitions ?? [];
return (
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Storage</h2>
<span className="font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary">
{partitions.length} partition{partitions.length !== 1 ? "s" : ""}
</span>
</div>
{partitions.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">No partitions reported. The agent sends a full inventory every 15 minutes.</p>
) : (
<div className="space-y-4 px-6 py-5">
{partitions.map((p) => {
const pct = p.total_bytes > 0 ? (p.used_bytes / p.total_bytes) * 100 : 0;
return (
<div key={p.mountpoint}>
<div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="font-mono text-sm text-text-primary">{p.mountpoint}</span>
<span className="font-mono text-xs text-text-secondary">
{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)}
{p.fstype ? ` · ${p.fstype}` : ""}
</span>
</div>
<div className="h-[3px] w-full overflow-hidden rounded-full bg-well">
<div className={clsx("h-full rounded-full", pct >= 90 ? "bg-danger" : pct >= 75 ? "bg-warning" : "bg-accent")} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
</div>
);
})}
</div>
)}
</Card>
);
}
function Fact({ term, children, mono = true }: { term: string; children: React.ReactNode; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-4 border-b border-border-soft py-2.5 last:border-b-0">
<dt className="shrink-0 text-xs text-text-secondary">{term}</dt>
<dd className={clsx("min-w-0 break-all text-right text-sm text-text-primary", mono && "font-mono text-xs")}>{children}</dd>
</div>
);
}
function MachinePanel({ server }: { server: ServerWithKeys }) {
return (
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Machine</h2>
</div>
<dl className="px-6 py-2">
<Fact term="OS" mono={false}>
{server.os_info || "unknown"}
</Fact>
{server.inventory?.kernel && <Fact term="Kernel">{server.inventory.kernel}</Fact>}
{server.inventory?.cpu.model && <Fact term="CPU">{server.inventory.cpu.model}</Fact>}
<Fact term="Agent version">{server.agent_version ? `v${server.agent_version}` : "unknown"}</Fact>
<Fact term="Last seen" mono={false}>
{server.last_seen ? formatDate(server.last_seen) : "Never"}
</Fact>
<Fact term="Registered" mono={false}>
{formatDate(server.created_at)}
</Fact>
<Fact term="Server ID">{server.server_id}</Fact>
</dl>
</Card>
);
}
function AttentionPanel({ items, onGoTo }: { items: Attention[]; onGoTo: (tab: TabId) => void }) {
return (
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<CardHeader className="mb-0">
<CardTitle className="text-lg font-semibold">Needs attention</CardTitle>
</CardHeader>
</div>
{items.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-success">Nothing outstanding on this server.</p>
) : (
<ul className="divide-y divide-border-soft">
{items.map((item) => (
<li key={item.title} className="flex flex-wrap items-center justify-between gap-3 px-6 py-3.5">
<div className="flex min-w-0 items-start gap-3">
{/* The dot is recognition, never the message the title says
what is wrong on its own. */}
<span className={clsx("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", item.tone === "danger" ? "bg-danger" : "bg-warning")} />
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary">{item.title}</p>
<p className="font-mono text-xs text-text-secondary">{item.detail}</p>
</div>
</div>
<button type="button" onClick={() => onGoTo(item.goTo)} className="text-sm font-semibold text-accent transition-colors hover:text-accent-hover">
{item.action}
</button>
</li>
))}
</ul>
)}
</Card>
);
}
export function OverviewTab({ server, attention, onGoTo }: { server: ServerWithKeys; attention: Attention[]; onGoTo: (tab: TabId) => void }) {
return (
<div className="space-y-6">
<AttentionPanel items={attention} onGoTo={onGoTo} />
{/* Collapses at xl, not lg: the 240px sidebar leaves a 1280px laptop
about 1010px, which is not enough for a two-thirds split. */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div className="xl:col-span-2">
<StoragePanel server={server} />
</div>
<MachinePanel server={server} />
</div>
</div>
);
}
+111
View File
@@ -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>
);
}
+1
View File
@@ -3,3 +3,4 @@ export { Badge } from "./Badge";
export { Card, CardHeader, CardTitle } from "./Card";
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
export { Modal } from "./Modal";
export { Pagination, usePagination, PAGE_SIZES } from "./Pagination";
@@ -1,110 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding, VulnGroup } from "@/lib/api";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per CVE, expandable to the servers it affects.
*
* The grouping is the point. The same CVE across forty servers is one decision
* patch it, or accept it and say why and a flat list of forty findings
* makes it look like forty decisions, which is how a board stops being read.
*/
interface Props {
group: VulnGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
// the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.findings.some((f) => f.fixed_in);
return (
// A row inside the page's one bordered container, not a card of its
// own — the same stack idiom as the monitors and workflows lists.
<div className="border-t border-border-soft first:border-t-0">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && (
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
no fix
</span>
)}
</button>
{open && (
<div className="border-t border-border-soft bg-surface-2/40">
{group.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
{serverName(f.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto flex gap-2">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
{f.fixed_in && f.state !== "fixed" && (
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
Apply updates
</Button>
)}
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding } from "@/lib/api";
import type { PackageGroup, PackageServer } from "@/lib/vulnPackages";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per package, expandable to the servers carrying it and the CVEs on
* each.
*
* The grouping is the point, and it is the same argument as the CVE grouping it
* replaced, one level in: an operator upgrades a package, not a CVE. Two CVEs
* on one apache2 are one upgrade to the higher of the two fix versions, and
* showing them as two rows with two different targets is how a fleet gets
* patched to the lower one.
*/
interface Props {
group: PackageGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function PackageRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A package with no fix anywhere cannot be patched, only accepted. Saying so
// on the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.servers.some((s) => s.target);
return (
// A row inside the page's one bordered container, not a card of its
// own — the same stack idiom as the monitors and workflows lists.
<div className="border-t border-border-soft first:border-t-0">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.package_name}</span>
<span className="whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.cve_count} {group.cve_count === 1 ? "CVE" : "CVEs"}
</span>
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && (
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
no fix
</span>
)}
</button>
{open && (
<div className="border-t border-border-soft bg-surface-2/40">
{group.servers.map((s) => (
<ServerBlock
key={`${s.package_name}:${s.server_id}`}
row={s}
serverName={serverName}
canAct={canAct}
onAccept={onAccept}
onUnaccept={onUnaccept}
onApplyUpdates={onApplyUpdates}
applying={applying}
/>
))}
</div>
)}
</div>
);
}
function ServerBlock({
row,
serverName,
canAct,
onAccept,
onUnaccept,
onApplyUpdates,
applying,
}: Omit<Props, "group"> & { row: PackageServer }) {
const patchable = row.target && row.findings.some((f) => f.state !== "fixed");
return (
<div className="border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Link href={`/servers/${row.server_id}`} className="text-sm text-accent hover:underline">
{serverName(row.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">{row.installed_version}</span>
{/* One target, the highest across every CVE on this package.
A lower fix version does not remediate a higher one, so it
is never the number offered. */}
<span className="font-mono text-xs text-text-tertiary">{row.target ? `${row.target}` : "no fix published"}</span>
{row.superseded && (
<span
className="whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary"
title="Several CVEs name different fix versions; the highest is shown and covers the rest."
>
supersedes lower fixes
</span>
)}
{canAct && patchable && (
<div className="ml-auto">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
<Button size="sm" variant="secondary" loading={applying === row.server_id} onClick={() => onApplyUpdates(row.server_id)}>
Apply updates
</Button>
</div>
)}
</div>
<div className="mt-2 space-y-1.5">
{row.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 pl-1">
<SeverityBadge severity={f.severity} />
<span className="font-mono text-xs text-text-secondary">{f.cve_id}</span>
{f.title && <span className="hidden truncate text-xs text-text-tertiary sm:block">{f.title}</span>}
{/* The per-CVE fix stays visible when it differs from
the row's target, so the rollup can be checked
rather than taken on trust. */}
{f.fixed_in && f.fixed_in !== row.target && (
<span className="font-mono text-[11px] text-text-tertiary">fixed in {f.fixed_in}</span>
)}
{!f.fixed_in && <span className="font-mono text-[11px] text-text-tertiary">no fix</span>}
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto">
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
+61
View File
@@ -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>
);
}
+165
View File
@@ -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>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { Badge, Button } from "@/components/ui";
import type { Workload, WorkloadAction } from "@/lib/api";
/*
* Container and unit states are kept in their own vocabularies on purpose a
* failed unit and an exited container mean different things. Colour never
* carries the state on its own: Badge already pairs a dot with the word.
*/
function stateVariant(w: Workload): "success" | "warning" | "danger" | "neutral" {
if (w.kind === "container") {
switch (w.state) {
case "running":
return w.health === "unhealthy" ? "danger" : "success";
case "restarting":
case "paused":
case "created":
return "warning";
case "dead":
case "exited":
return "danger";
default:
return "neutral";
}
}
switch (w.state) {
case "active":
return "success";
case "activating":
case "reloading":
return "warning";
case "failed":
return "danger";
case "inactive":
return "neutral";
default:
return "neutral";
}
}
const ACTIONS: WorkloadAction[] = ["start", "stop", "restart"];
export function WorkloadRow({
workload,
canControl,
busy,
onAction,
onLogs,
}: {
workload: Workload;
canControl: boolean;
busy: boolean;
onAction: (action: WorkloadAction) => void;
onLogs: () => void;
}) {
const w = workload;
return (
<div className="flex flex-col gap-3 border-b border-border px-6 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-sm text-text-primary">{w.name}</span>
<Badge variant={stateVariant(w)}>{w.state}</Badge>
{w.health && <Badge variant={w.health === "healthy" ? "success" : "warning"}>{w.health}</Badge>}
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
</div>
<p className="mt-1 truncate text-xs text-text-secondary">
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{canControl &&
ACTIONS.map((a) => (
<Button
key={a}
size="sm"
variant={a === "stop" ? "danger" : "secondary"}
/* Protected rows show the action disabled with the
reason rather than offering a button whose refusal
the agent has already told us about. */
disabled={w.protected || busy}
title={w.protected ? "This workload runs the Vantage agent and cannot be controlled from here" : undefined}
onClick={() => onAction(a)}
>
{a}
</Button>
))}
{canControl && (
<Button size="sm" variant="ghost" onClick={onLogs}>
Logs
</Button>
)}
</div>
</div>
);
}
+77 -1
View File
@@ -996,11 +996,14 @@ export interface VulnAlertRuleInput {
}
export const vulnerabilities = {
list(params?: { severity?: string; state?: string; server?: string; tags?: Record<string, string> }): Promise<VulnGroup[]> {
list(params?: { severity?: string; state?: string; server?: string; hasFix?: boolean; tags?: Record<string, string> }): Promise<VulnGroup[]> {
const q = new URLSearchParams();
if (params?.severity) q.set("severity", params.severity);
if (params?.state) q.set("state", params.state);
if (params?.server) q.set("server", params.server);
// Explicitly undefined-checked: `false` is a real selection here (the
// unfixable set), so a truthiness test would silently drop it.
if (params?.hasFix !== undefined) q.set("has_fix", String(params.hasFix));
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
const qs = q.toString();
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
@@ -1054,6 +1057,79 @@ export const vulnerabilities = {
},
};
export type WorkloadKind = "container" | "unit";
export type WorkloadAction = "start" | "stop" | "restart";
/** One container or one systemd unit.
*
* `state` is deliberately not a shared vocabulary across the two kinds:
* containers report running/exited/paused/restarting/created, units report
* active/inactive/failed/activating. A failed unit and an exited container
* mean different things. */
export interface Workload {
kind: WorkloadKind;
id: string;
name: string;
state: string;
health?: string;
image?: string;
stack?: string;
ports?: string[];
restarts?: number;
started_at?: string;
protected: boolean;
}
export interface ServerWorkloads {
server_id: string;
hash?: string;
workloads: Workload[];
collected_at?: string;
/** false with no error means "Docker not in use here", which is not a
* fault. With an error it means installed but not responding. */
docker_ok: boolean;
docker_error?: string;
systemd_ok: boolean;
systemd_error?: string;
}
export interface WorkloadHit {
server_id: string;
workload: Workload;
}
export const workloads = {
forServer(serverId: string): Promise<ServerWorkloads> {
return request<ServerWorkloads>(`/servers/${serverId}/workloads`);
},
refresh(serverId: string): Promise<{ message: string }> {
return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" });
},
control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> {
return request<{ message: string }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`,
{ method: "POST", body: JSON.stringify({ kind, action }) },
);
},
logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> {
return request<{ text: string; truncated: boolean }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`,
);
},
search(params?: { image?: string; stack?: string; state?: string }): Promise<WorkloadHit[]> {
const q = new URLSearchParams();
if (params?.image) q.set("image", params.image);
if (params?.stack) q.set("stack", params.stack);
if (params?.state) q.set("state", params.state);
const qs = q.toString();
return request<WorkloadHit[]>(`/workloads${qs ? `?${qs}` : ""}`);
},
};
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {
Binary file not shown.
File diff suppressed because one or more lines are too long