Compare commits

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