Compare commits
7
Commits
agent/v1.3.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d559cccd44 | ||
|
|
0684d84609 | ||
|
|
78f1bf853c | ||
|
|
e28238191d | ||
|
|
82bcc5776f | ||
|
|
1993802c38 | ||
|
|
5db49b6b0e |
@@ -32,6 +32,7 @@ func listVulnerabilities(c *gin.Context) {
|
||||
State: c.DefaultQuery("state", models.FindingOpen),
|
||||
ServerID: c.Query("server"),
|
||||
Tags: tagsFromQuery(c),
|
||||
HasFix: hasFixFromQuery(c),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -82,6 +83,22 @@ func groupByCVE(findings []models.VulnFinding) []vulnGroup {
|
||||
return out
|
||||
}
|
||||
|
||||
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
|
||||
// or malformed value, is no filter — a filter nobody asked for must never hide
|
||||
// findings, and the wrong direction here hides the unfixable ones.
|
||||
func hasFixFromQuery(c *gin.Context) *bool {
|
||||
switch c.Query("has_fix") {
|
||||
case "true":
|
||||
v := true
|
||||
return &v
|
||||
case "false":
|
||||
v := false
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tagsFromQuery reads repeated tag=key:value parameters.
|
||||
func tagsFromQuery(c *gin.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
|
||||
@@ -52,6 +52,9 @@ func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now ti
|
||||
Installed: r.Installed,
|
||||
FixedIn: r.FixedIn,
|
||||
Severity: r.Severity,
|
||||
CVSSScore: r.CVSSScore,
|
||||
Title: r.Title,
|
||||
References: r.References,
|
||||
State: models.FindingOpen,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
@@ -112,6 +115,10 @@ type FindingFilter struct {
|
||||
State string
|
||||
ServerID string
|
||||
Tags map[string]string
|
||||
// HasFix nil is no filter. true is "a vendor fix exists, this is
|
||||
// patchable"; false is the unfixable set — remove the package, disable the
|
||||
// service, or accept it, but do not wait for an update.
|
||||
HasFix *bool
|
||||
}
|
||||
|
||||
// ListInstanceFindings returns findings across the whole fleet.
|
||||
@@ -130,6 +137,17 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
|
||||
filter["server_id"] = f.ServerID
|
||||
}
|
||||
|
||||
// fixed_in is omitempty, so a finding with no vendor fix carries no such
|
||||
// field at all rather than an empty string. Both forms must be matched, or
|
||||
// the unfixable set reads as empty on any document written before this.
|
||||
if f.HasFix != nil {
|
||||
if *f.HasFix {
|
||||
filter["fixed_in"] = bson.M{"$nin": bson.A{"", nil}}
|
||||
} else {
|
||||
filter["fixed_in"] = bson.M{"$in": bson.A{"", nil}}
|
||||
}
|
||||
}
|
||||
|
||||
// The tag selector resolves through ResolveTargets, the single answer to
|
||||
// which servers a selector touches. A second matcher here could disagree
|
||||
// with what a workflow means by env:prod.
|
||||
@@ -207,8 +225,11 @@ func MarkInstanceForRescan(instanceID string) (int64, error) {
|
||||
bson.M{"$set": bson.M{"scan_pending": true}},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("vulnsched: mark rescan for instance %s: %v", instanceID, err)
|
||||
return 0, err
|
||||
}
|
||||
log.Printf("vulnsched: rescan requested for instance %s, %d of %d server(s) flagged",
|
||||
instanceID, res.ModifiedCount, res.MatchedCount)
|
||||
return res.ModifiedCount, nil
|
||||
}
|
||||
|
||||
@@ -300,19 +321,40 @@ func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.Vu
|
||||
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
|
||||
col := db.Col("vuln_findings")
|
||||
|
||||
// One BulkWrite per batch, not one UpdateOne per finding. A freshly scanned
|
||||
// Ubuntu host opens tens of thousands of findings, and at one round trip each
|
||||
// that is minutes of sequential latency during which the tick holds the
|
||||
// leader and every other server waits its turn. Unordered, because the
|
||||
// upserts are independent and one duplicate-key race must not abandon the
|
||||
// rest of the batch.
|
||||
const bulkBatch = 1000
|
||||
ops := make([]mongo.WriteModel, 0, bulkBatch)
|
||||
|
||||
flush := func() error {
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := col.BulkWrite(ctx, ops, options.BulkWrite().SetOrdered(false))
|
||||
ops = ops[:0]
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range d.Upserts {
|
||||
_, err := col.UpdateOne(ctx,
|
||||
bson.M{
|
||||
ops = append(ops, mongo.NewUpdateOneModel().
|
||||
SetFilter(bson.M{
|
||||
"instance_id": instanceID,
|
||||
"server_id": serverID,
|
||||
"cve_id": f.CVEID,
|
||||
"package_name": f.PackageName,
|
||||
},
|
||||
bson.M{
|
||||
}).
|
||||
SetUpdate(bson.M{
|
||||
"$set": bson.M{
|
||||
"installed_version": f.Installed,
|
||||
"fixed_in": f.FixedIn,
|
||||
"severity": f.Severity,
|
||||
"cvss_score": f.CVSSScore,
|
||||
"title": f.Title,
|
||||
"references": f.References,
|
||||
"state": models.FindingOpen,
|
||||
"last_seen": now,
|
||||
},
|
||||
@@ -326,13 +368,18 @@ func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d Findin
|
||||
"first_seen": f.FirstSeen,
|
||||
},
|
||||
"$unset": bson.M{"fixed_at": "", "accepted": ""},
|
||||
},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}).
|
||||
SetUpsert(true))
|
||||
|
||||
if len(ops) >= bulkBatch {
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(d.FixedIDs) > 0 {
|
||||
if _, err := col.UpdateMany(ctx,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package vulndb
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
trivydb "github.com/aquasecurity/trivy-db/pkg/db"
|
||||
@@ -14,6 +15,11 @@ type Advisory struct {
|
||||
// state, not an absence of data, and callers must treat it as vulnerable.
|
||||
FixedVersion string
|
||||
Severity string
|
||||
// Status is filled by trivy-db ONLY when FixedVersion is empty — when there
|
||||
// is a fix, "fixed" is the obvious state and the field is left zero. It is
|
||||
// what separates "the vendor confirms this package is affected and has not
|
||||
// fixed it" from "nobody has looked yet".
|
||||
Status string
|
||||
}
|
||||
|
||||
// VulnInfo is the CVE's own metadata, shared across every server it affects.
|
||||
@@ -33,8 +39,10 @@ type Store struct {
|
||||
// it appends "trivy.db" itself.
|
||||
func Open(dir string) (*Store, error) {
|
||||
if err := trivydb.Init(dir); err != nil {
|
||||
log.Printf("vulndb: open %s: %v", dir, err)
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("vulndb: opened database in %s", dir)
|
||||
return &Store{cfg: trivydb.Config{}}, nil
|
||||
}
|
||||
|
||||
@@ -55,6 +63,7 @@ func (s *Store) Advisories(bucket, srcName string) ([]Advisory, error) {
|
||||
// Vulnerability.Severity which is a string. They are genuinely
|
||||
// different types in trivy-db, not an inconsistency here.
|
||||
Severity: severityFromLevel(a.Severity),
|
||||
Status: a.Status.String(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package vulndb
|
||||
|
||||
// VulnSource is the CVE-metadata lookup the enricher needs. *Store satisfies it.
|
||||
type VulnSource interface {
|
||||
Vulnerability(cveID string) (VulnInfo, error)
|
||||
}
|
||||
|
||||
// MetaCache enriches match results with the CVE's own metadata.
|
||||
//
|
||||
// This is not an optimisation, it is where severity comes from. Debian, Ubuntu
|
||||
// and Alpine advisories carry no severity of their own — trivy-db leaves
|
||||
// Advisory.Severity zero for those buckets and keeps the rating in the
|
||||
// vulnerability bucket's VendorSeverity map instead. Taking the advisory's
|
||||
// value alone reported an entire fleet as "unknown".
|
||||
//
|
||||
// One cache per tick, shared across servers: a CVE affects every host running
|
||||
// the package, and the bolt read is the same read every time.
|
||||
type MetaCache struct {
|
||||
src VulnSource
|
||||
seen map[string]VulnInfo
|
||||
}
|
||||
|
||||
func NewMetaCache(src VulnSource) *MetaCache {
|
||||
return &MetaCache{src: src, seen: make(map[string]VulnInfo)}
|
||||
}
|
||||
|
||||
// Enrich fills severity, title, score and references in place.
|
||||
//
|
||||
// The advisory's severity is kept when the vulnerability bucket has nothing
|
||||
// better to say — RHEL does publish it per advisory — so this can only raise
|
||||
// the quality of the answer, never lower it.
|
||||
func (m *MetaCache) Enrich(results []Result) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
for i := range results {
|
||||
info, ok := m.lookup(results[i].CVEID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.Severity != "" && info.Severity != "unknown" {
|
||||
results[i].Severity = info.Severity
|
||||
}
|
||||
results[i].Title = info.Title
|
||||
results[i].CVSSScore = info.CVSSScore
|
||||
results[i].References = info.References
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MetaCache) lookup(cveID string) (VulnInfo, bool) {
|
||||
if info, ok := m.seen[cveID]; ok {
|
||||
return info, true
|
||||
}
|
||||
info, err := m.src.Vulnerability(cveID)
|
||||
if err != nil {
|
||||
// A CVE with an advisory but no vulnerability document is a real state
|
||||
// in trivy-db, not a fault. Cache the miss so it is asked once.
|
||||
Debugf("no vulnerability record for %s: %v", cveID, err)
|
||||
m.seen[cveID] = VulnInfo{}
|
||||
return VulnInfo{}, false
|
||||
}
|
||||
m.seen[cveID] = info
|
||||
return info, true
|
||||
}
|
||||
@@ -14,12 +14,19 @@ type AdvisorySource interface {
|
||||
}
|
||||
|
||||
// Result is one vulnerable package on one server, before it becomes a finding.
|
||||
//
|
||||
// Severity, Title, CVSSScore and References are only as good as the advisory
|
||||
// until MetaCache.Enrich has run over them — for the Debian-family buckets the
|
||||
// advisory carries no severity at all, so an unenriched Result reads "unknown".
|
||||
type Result struct {
|
||||
CVEID string
|
||||
PackageName string // the BINARY package, which is what is installed
|
||||
Installed string
|
||||
FixedIn string
|
||||
Severity string
|
||||
Title string
|
||||
CVSSScore float64
|
||||
References []string
|
||||
}
|
||||
|
||||
// Match returns every advisory that the installed packages do not satisfy.
|
||||
@@ -32,11 +39,22 @@ type Result struct {
|
||||
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
|
||||
bucket, err := Bucket(os.Family, os.VersionID)
|
||||
if err != nil {
|
||||
log.Printf("vulndb: no bucket for family=%q version=%q: %v", os.Family, os.VersionID, err)
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("vulndb: matching %d packages against bucket %q", len(pkgs), bucket)
|
||||
|
||||
var advisoryCount, skipped, unactionable, noFix int
|
||||
|
||||
// Advisories are keyed on the SOURCE package, and several hundred binary
|
||||
// packages on a host resolve to the same few hundred sources — linux-modules,
|
||||
// linux-image and linux-headers all ask about "linux", whose advisory list is
|
||||
// thousands long. Without this the same bolt read is repeated once per binary
|
||||
// package, which is most of what made a single Ubuntu host take minutes.
|
||||
cache := make(map[string][]Advisory, len(pkgs))
|
||||
|
||||
var out []Result
|
||||
for _, p := range pkgs {
|
||||
for _, p := range newestPerSource(os.Family, pkgs) {
|
||||
// Debian and Ubuntu advisories are keyed on the source package: one
|
||||
// advisory against "openssl" covers libssl3, openssl and libssl-dev.
|
||||
srcName := p.SourceName
|
||||
@@ -44,15 +62,30 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
|
||||
srcName = p.Name
|
||||
}
|
||||
|
||||
advs, err := src.Advisories(bucket, srcName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
|
||||
advs, cached := cache[srcName]
|
||||
if !cached {
|
||||
var err error
|
||||
advs, err = src.Advisories(bucket, srcName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
|
||||
}
|
||||
cache[srcName] = advs
|
||||
}
|
||||
advisoryCount += len(advs)
|
||||
if len(advs) > 0 {
|
||||
Debugf("%s (src %s, installed %s): %d advisories", p.Name, srcName, p.Version, len(advs))
|
||||
}
|
||||
|
||||
for _, a := range advs {
|
||||
// No published fix. Vulnerable, and the finding most in need of
|
||||
// acceptance, since there is nothing to patch.
|
||||
// No published fix. Whether that is a finding depends entirely on the
|
||||
// status the vendor attached to it — see actionable().
|
||||
if a.FixedVersion == "" {
|
||||
if !actionable(a.Status) {
|
||||
unactionable++
|
||||
Debugf("%s (src %s): %s skipped, status %q", p.Name, srcName, a.CVEID, a.Status)
|
||||
continue
|
||||
}
|
||||
noFix++
|
||||
out = append(out, Result{
|
||||
CVEID: a.CVEID, PackageName: p.Name,
|
||||
Installed: p.Version, Severity: a.Severity,
|
||||
@@ -67,8 +100,10 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
|
||||
// on the host. Log it — a silent skip is a silent false
|
||||
// negative, which is the direction that hurts.
|
||||
log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
Debugf("%s %s vs fixed %s (%s): vulnerable=%t", p.Name, p.Version, a.FixedVersion, a.CVEID, older)
|
||||
if older {
|
||||
out = append(out, Result{
|
||||
CVEID: a.CVEID, PackageName: p.Name,
|
||||
@@ -77,5 +112,90 @@ func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPacka
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("vulndb: bucket %q done: %d packages, %d sources, %d advisories considered, "+
|
||||
"%d results (%d with no vendor fix), %d skipped as not-yet-triaged, %d unparseable comparisons",
|
||||
bucket, len(pkgs), len(cache), advisoryCount, len(out), noFix, unactionable, skipped)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newestPerSource collapses the installed set to one binary package per source
|
||||
// package: the one carrying the highest version.
|
||||
//
|
||||
// Advisories are keyed on the source, so every binary package of a source asks
|
||||
// the same question. Normally they all carry the same version and the answer is
|
||||
// the same, so collapsing is free. The kernel is the exception that makes it
|
||||
// necessary: Ubuntu encodes the ABI in the binary name, so an upgrade INSTALLS
|
||||
// linux-headers-6.8.0-137 beside linux-headers-6.8.0-124 rather than replacing
|
||||
// it, and the old one lingers until an autoremove. Matched per binary package,
|
||||
// a fully patched host reports every superseded ABI package as vulnerable —
|
||||
// which is the noise this exists to stop — and reports it twice over, once for
|
||||
// linux-headers-6.8.0-124 and again for its -generic sibling.
|
||||
//
|
||||
// The version, not the name, decides. There is no kernel special case here: a
|
||||
// source's newest installed version is what the fix landed as, whatever the
|
||||
// source is.
|
||||
//
|
||||
// A comparison that cannot be made keeps the incumbent rather than guessing;
|
||||
// the loser is dropped either way, and dropping the parseable one would be the
|
||||
// false-negative direction.
|
||||
func newestPerSource(family string, pkgs []models.InstalledPackage) []models.InstalledPackage {
|
||||
best := make(map[string]models.InstalledPackage, len(pkgs))
|
||||
order := make([]string, 0, len(pkgs))
|
||||
|
||||
for _, p := range pkgs {
|
||||
src := p.SourceName
|
||||
if src == "" {
|
||||
src = p.Name
|
||||
}
|
||||
cur, seen := best[src]
|
||||
if !seen {
|
||||
best[src] = p
|
||||
order = append(order, src)
|
||||
continue
|
||||
}
|
||||
older, err := LessThan(family, cur.Version, p.Version)
|
||||
if err != nil {
|
||||
log.Printf("vulndb: newest for source %s: compare %s vs %s: %v",
|
||||
src, cur.Version, p.Version, err)
|
||||
continue
|
||||
}
|
||||
if older {
|
||||
Debugf("source %s: %s %s supersedes %s %s",
|
||||
src, p.Name, p.Version, cur.Name, cur.Version)
|
||||
best[src] = p
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]models.InstalledPackage, 0, len(order))
|
||||
for _, src := range order {
|
||||
out = append(out, best[src])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actionable decides whether an advisory with no fixed version is a finding.
|
||||
//
|
||||
// trivy-db fills Status only when FixedVersion is empty, and Ubuntu publishes a
|
||||
// status for every CVE against every source package it ships — the vast
|
||||
// majority being "under_investigation" (the tracker's needs-triage), meaning
|
||||
// nobody has yet established that the package is affected at all. Reporting
|
||||
// those produced ~24,000 findings for a single 797-package host, which is not a
|
||||
// security report, it is a wall. A "not_affected" is the vendor stating the
|
||||
// opposite of a finding, so it is never one.
|
||||
//
|
||||
// What survives is what the vendor has confirmed: affected, will_not_fix,
|
||||
// fix_deferred, end_of_life. Those are exactly the findings the CLAUDE.md rule
|
||||
// is about — an empty fixed_in that means "no fix exists", the one most in need
|
||||
// of acceptance rather than patching.
|
||||
// Only the two statuses that positively say "this is not a finding" are
|
||||
// dropped. "unknown" is kept: a feed that sets no status at all must not become
|
||||
// a silent false negative, and it is not what generates the noise — Ubuntu
|
||||
// states under_investigation explicitly.
|
||||
func actionable(status string) bool {
|
||||
switch status {
|
||||
case "not_affected", "under_investigation":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2"
|
||||
@@ -49,6 +51,24 @@ func Disabled() bool {
|
||||
return strings.EqualFold(os.Getenv("VANTAGE_VULNDB_DISABLED"), "true")
|
||||
}
|
||||
|
||||
// DebugEnabled turns on per-package and per-advisory tracing.
|
||||
//
|
||||
// It is a switch rather than always-on because a single scan asks the store one
|
||||
// question per installed package — ~2000 lines per server, per tick — which
|
||||
// would bury every other subsystem's logs on a fleet of any size. The lifecycle
|
||||
// logs (pull, tick, per-server totals) are unconditional; only the inner loop
|
||||
// is gated.
|
||||
func DebugEnabled() bool {
|
||||
return strings.EqualFold(os.Getenv("VANTAGE_VULN_DEBUG"), "true")
|
||||
}
|
||||
|
||||
// Debugf logs only when VANTAGE_VULN_DEBUG=true.
|
||||
func Debugf(format string, args ...any) {
|
||||
if DebugEnabled() {
|
||||
log.Printf("vulndb[debug]: "+format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// dbMetadata is the subset of trivy-db's metadata.json we read.
|
||||
type dbMetadata struct {
|
||||
Version int `json:"Version"`
|
||||
@@ -62,6 +82,8 @@ type dbMetadata struct {
|
||||
// one that Open would happily accept and scan against.
|
||||
func Pull(ctx context.Context, dir string) (int, error) {
|
||||
ref := Ref()
|
||||
started := time.Now()
|
||||
log.Printf("vulndb: pull starting ref=%s dir=%s", ref, dir)
|
||||
|
||||
parsed, err := registry.ParseReference(ref)
|
||||
if err != nil {
|
||||
@@ -92,6 +114,8 @@ func Pull(ctx context.Context, dir string) (int, error) {
|
||||
if len(man.Layers) == 0 {
|
||||
return 0, fmt.Errorf("artifact %s has no layers", ref)
|
||||
}
|
||||
log.Printf("vulndb: manifest resolved layers=%d digest=%s size=%dB",
|
||||
len(man.Layers), man.Layers[0].Digest, man.Layers[0].Size)
|
||||
|
||||
// Streamed rather than buffered: the layer is ~50MB and there is no reason
|
||||
// to hold it in memory on the way to disk.
|
||||
@@ -110,6 +134,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
|
||||
if err := extractTarGz(rc, staging); err != nil {
|
||||
return 0, fmt.Errorf("extract layer: %w", err)
|
||||
}
|
||||
log.Printf("vulndb: layer extracted into %s after %s", staging, time.Since(started).Round(time.Millisecond))
|
||||
|
||||
metaBytes, err := os.ReadFile(filepath.Join(staging, metaFileName))
|
||||
if err != nil {
|
||||
@@ -123,9 +148,11 @@ func Pull(ctx context.Context, dir string) (int, error) {
|
||||
return 0, fmt.Errorf("trivy-db schema %d is not supported (want %d)", meta.Version, SupportedSchema)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(staging, dbFileName)); err != nil {
|
||||
fi, err := os.Stat(filepath.Join(staging, dbFileName))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("artifact has no %s: %w", dbFileName, err)
|
||||
}
|
||||
log.Printf("vulndb: %s is %dB, schema %d accepted", dbFileName, fi.Size(), meta.Version)
|
||||
|
||||
// Both files present and the schema accepted, so it is safe to replace.
|
||||
for _, name := range []string{dbFileName, metaFileName} {
|
||||
@@ -139,6 +166,7 @@ func Pull(ctx context.Context, dir string) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("vulndb: pull complete ref=%s schema=%d in %s", ref, meta.Version, time.Since(started).Round(time.Millisecond))
|
||||
return meta.Version, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,17 @@ func Start(ctx context.Context, deps Deps) {
|
||||
|
||||
dir, err := os.MkdirTemp("", "vantage-vulndb-")
|
||||
if err != nil {
|
||||
log.Printf("vulnsched: temp dir: %v", err)
|
||||
// The classic form of this is "stat /tmp: no such file or directory" on
|
||||
// the scratch runtime image. It is logged once at boot while everything
|
||||
// else runs normally, so the only other symptom is a fleet that never
|
||||
// reports a finding.
|
||||
log.Printf("vulnsched: temp dir: %v (scan loop NOT started)", err)
|
||||
return
|
||||
}
|
||||
|
||||
s := &scheduler{deps: deps, dir: dir}
|
||||
log.Printf("vulnsched: started, ref=%s tick=%s dir=%s debug=%t",
|
||||
vulndb.Ref(), tickInterval, dir, vulndb.DebugEnabled())
|
||||
|
||||
go func() {
|
||||
defer os.RemoveAll(dir)
|
||||
@@ -66,6 +72,7 @@ func Start(ctx context.Context, deps Deps) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("vulnsched: leadership lost or shutting down, scan loop stopping")
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.tick(ctx)
|
||||
@@ -75,16 +82,22 @@ func Start(ctx context.Context, deps Deps) {
|
||||
}
|
||||
|
||||
func (s *scheduler) tick(ctx context.Context) {
|
||||
started := time.Now()
|
||||
vulndb.Debugf("vulnsched tick starting (store loaded=%t, db version=%d, pulled %s ago)",
|
||||
s.store != nil, s.version, time.Since(s.pulled).Round(time.Second))
|
||||
|
||||
if err := s.ensureDB(ctx); err != nil {
|
||||
// Keep the last good database and carry on scanning against it. A
|
||||
// network blip must never clear findings or read as "all fixed".
|
||||
log.Printf("vulnsched: database unavailable: %v", err)
|
||||
s.recordDBError(ctx, err)
|
||||
if s.store == nil {
|
||||
log.Println("vulnsched: no database loaded at all, nothing can be scanned this tick")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.scanPending(ctx)
|
||||
vulndb.Debugf("vulnsched tick finished in %s", time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// ensureDB pulls a fresh database when the local copy is stale, and marks the
|
||||
@@ -93,8 +106,11 @@ func (s *scheduler) tick(ctx context.Context) {
|
||||
// next agent report.
|
||||
func (s *scheduler) ensureDB(ctx context.Context) error {
|
||||
if s.store != nil && time.Since(s.pulled) < dbMaxAge {
|
||||
vulndb.Debugf("database is %s old, under the %s limit; not pulling",
|
||||
time.Since(s.pulled).Round(time.Second), dbMaxAge)
|
||||
return nil
|
||||
}
|
||||
log.Printf("vulnsched: pulling database (age %s, max %s)", time.Since(s.pulled).Round(time.Second), dbMaxAge)
|
||||
|
||||
version, err := vulndb.Pull(ctx, s.dir)
|
||||
if err != nil {
|
||||
@@ -110,6 +126,7 @@ func (s *scheduler) ensureDB(ctx context.Context) error {
|
||||
s.pulled = time.Now()
|
||||
|
||||
changed := version != s.version
|
||||
log.Printf("vulnsched: database ready, schema %d (previous %d, changed=%t)", version, s.version, changed)
|
||||
s.version = version
|
||||
|
||||
_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
|
||||
@@ -152,19 +169,29 @@ func (s *scheduler) scanPending(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
vulndb.Debugf("no servers pending scan")
|
||||
return
|
||||
}
|
||||
log.Printf("vulnsched: %d server(s) pending scan", len(pending))
|
||||
|
||||
// Newly opened findings are collected across the whole tick and sent as one
|
||||
// digest per instance. A database refresh can open several hundred findings
|
||||
// at once; one message per finding would rate-limit the webhook or get the
|
||||
// channel muted, and either way the alerts stop being read.
|
||||
newly := map[string][]models.VulnFinding{}
|
||||
|
||||
// One metadata cache for the whole tick: a CVE affects every host running
|
||||
// the package, and the lookup is the same read each time.
|
||||
meta := vulndb.NewMetaCache(s.store)
|
||||
|
||||
for _, sp := range pending {
|
||||
if ctx.Err() != nil {
|
||||
// Leadership lost. scan_pending is still set, so the next leader
|
||||
// picks these up — which is why it lives on the document.
|
||||
return
|
||||
}
|
||||
opened := s.scanOne(ctx, sp)
|
||||
opened := s.scanOne(ctx, sp, meta)
|
||||
newly[sp.InstanceID] = append(newly[sp.InstanceID], opened...)
|
||||
}
|
||||
|
||||
@@ -180,8 +207,10 @@ func (s *scheduler) scanPending(ctx context.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []models.VulnFinding {
|
||||
func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages, meta *vulndb.MetaCache) []models.VulnFinding {
|
||||
now := time.Now()
|
||||
log.Printf("vulnsched: scanning server %s (instance %s, os %s %s, %d packages)",
|
||||
sp.ServerID, sp.InstanceID, sp.OS.Family, sp.OS.VersionID, len(sp.Packages))
|
||||
|
||||
results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
|
||||
if err != nil {
|
||||
@@ -193,11 +222,19 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
|
||||
if !errors.Is(err, vulndb.ErrUnsupportedFamily) {
|
||||
log.Printf("vulnsched: scan %s: %v", sp.ServerID, err)
|
||||
status = sp.Status
|
||||
} else {
|
||||
log.Printf("vulnsched: server %s marked unsupported: no feed for %s %s",
|
||||
sp.ServerID, sp.OS.Family, sp.OS.VersionID)
|
||||
}
|
||||
s.clearPending(ctx, sp.ID, status, now)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Severity for the Debian-family buckets lives on the CVE, not the
|
||||
// advisory. Without this every finding is stored as "unknown", which also
|
||||
// silences every alert rule with a minimum severity.
|
||||
meta.Enrich(results)
|
||||
|
||||
existing, err := services.ListFindings(ctx, sp.InstanceID, sp.ServerID)
|
||||
if err != nil {
|
||||
log.Printf("vulnsched: list findings %s: %v", sp.ServerID, err)
|
||||
@@ -210,6 +247,10 @@ func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []mod
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("vulnsched: server %s scanned: %d matches, %d existing, %d upserts, %d newly opened, %d reopened, %d fixed",
|
||||
sp.ServerID, len(results), len(existing), len(diff.Upserts),
|
||||
len(diff.NewlyOpened), len(diff.ReopenIDs), len(diff.FixedIDs))
|
||||
|
||||
s.clearPending(ctx, sp.ID, models.ScanStatusOK, now)
|
||||
|
||||
for i := range diff.NewlyOpened {
|
||||
|
||||
+212
-537
@@ -1,17 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate, Inventory } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { api, GenerateKeyOptions, ServerStatus, vulnerabilities, workloads as workloadsApi } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui";
|
||||
import { useLicense } from "@/lib/useLicense";
|
||||
import { TagChips } from "@/components/servers/TagChips";
|
||||
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
|
||||
import { WorkloadList } from "@/components/workloads/WorkloadList";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { GenerateKeyModal } from "@/components/servers/GenerateKeyModal";
|
||||
import { VitalsRail } from "@/components/servers/VitalsRail";
|
||||
import { ServerTabs, type TabId, type TabSpec } from "@/components/servers/ServerTabs";
|
||||
import { ServerActionsMenu, type ServerAction } from "@/components/servers/ServerActionsMenu";
|
||||
import { ArrowUpCircleIcon, ConsoleIcon, KeyIcon, RefreshIcon, ShieldIcon, TrashIcon } from "@/components/servers/icons";
|
||||
import { OverviewTab, type Attention } from "@/components/servers/tabs/OverviewTab";
|
||||
import { AccessTab } from "@/components/servers/tabs/AccessTab";
|
||||
import { MaintenanceTab } from "@/components/servers/tabs/MaintenanceTab";
|
||||
|
||||
/*
|
||||
* One server, as a faceplate over five tabs.
|
||||
*
|
||||
* The page used to stack every panel it had — agent updater, inventory,
|
||||
* details, vulnerabilities, workloads, keys — so the answer to "is this machine
|
||||
* healthy" was several screens below the answer to "which agent build is on
|
||||
* it". Identity, status and the four live readings now stay pinned; everything
|
||||
* else is a tab, and the tab labels carry counts so a problem on a tab nobody
|
||||
* is looking at still announces itself.
|
||||
*/
|
||||
|
||||
const TAB_IDS: TabId[] = ["overview", "workloads", "security", "access", "maintenance"];
|
||||
|
||||
function statusVariant(status: ServerStatus) {
|
||||
switch (status) {
|
||||
@@ -24,300 +44,39 @@ function statusVariant(status: ServerStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024));
|
||||
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function UsageBar({ used, total }: { used: number; total: number }) {
|
||||
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryPanel({ inv }: { inv: Inventory }) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-text-secondary">CPU</span>
|
||||
<span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<UsageBar used={inv.cpu.usage_pct} total={100} />
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-text-secondary">Memory</span>
|
||||
<span className="text-text-primary">
|
||||
{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
|
||||
<div className="mb-1 mt-3 flex justify-between text-sm">
|
||||
<span className="text-text-secondary">Swap</span>
|
||||
<span className="text-text-primary">
|
||||
{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
|
||||
</div>
|
||||
</div>
|
||||
{inv.partitions && inv.partitions.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
|
||||
<div className="space-y-3">
|
||||
{inv.partitions.map((p) => (
|
||||
<div key={p.mountpoint}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="font-mono text-text-primary">{p.mountpoint}</span>
|
||||
<span className="text-text-secondary">
|
||||
{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={p.used_bytes} total={p.total_bytes} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const KEY_SIZES: Record<string, number[]> = {
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: Record<string, number> = {
|
||||
rsa: 4096,
|
||||
ecdsa: 256,
|
||||
};
|
||||
|
||||
function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [keySize, setKeySize] = useState<number>(4096);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||
setKeyType(t);
|
||||
if (t !== "ed25519") {
|
||||
setKeySize(DEFAULT_SIZE[t]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label: label || "generated",
|
||||
key_type: keyType,
|
||||
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const sizes = KEY_SIZES[keyType];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. server-deploy-key"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => handleKeyTypeChange(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
|
||||
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
|
||||
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||
<select
|
||||
value={keySize}
|
||||
onChange={(e) => setKeySize(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="e.g. user@hostname"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Optional passphrase"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||
Generate Key
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatesModal({ updates, onClose, onApply, isApplying, applySuccess }: { updates: PackageUpdate[]; onClose: () => void; onApply: () => void; isApplying: boolean; applySuccess: boolean }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-2xl rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Available OS Updates</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">
|
||||
{updates.length} package{updates.length !== 1 ? "s" : ""} available
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Package</Th>
|
||||
<Th>Current</Th>
|
||||
<Th>Available</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{updates.map((u) => (
|
||||
<Tr key={u.name}>
|
||||
<Td label="Package">
|
||||
<span className="font-medium font-mono text-sm">{u.name}</span>
|
||||
</Td>
|
||||
<Td label="Current">
|
||||
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
|
||||
</Td>
|
||||
<Td label="Available">
|
||||
<span className="font-mono text-xs text-success">{u.new_version}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApply}>
|
||||
{applySuccess ? "Sent!" : "Apply Updates"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<p className="ml-auto text-xs text-text-tertiary">Upgrade runs in the background. This may take several minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
|
||||
const [applySuccess, setApplySuccess] = useState(false);
|
||||
const panelsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { hasFeature } = useLicense();
|
||||
// Control actions and log reads are owner|admin server-side; the UI matches
|
||||
// so a member is not offered buttons the API will refuse.
|
||||
const { isAdmin } = useAuth();
|
||||
const consoleAllowed = hasFeature("console");
|
||||
|
||||
const tabParam = searchParams.get("tab") as TabId | null;
|
||||
const activeTab: TabId = tabParam && TAB_IDS.includes(tabParam) ? tabParam : "overview";
|
||||
|
||||
/** The tab lives in the URL so an alert, a bookmark or a browser Back can
|
||||
* name one. replace, not push — five tabs of history between two pages is
|
||||
* a Back button that does not go back. */
|
||||
function selectTab(tab: TabId) {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
if (tab === "overview") next.delete("tab");
|
||||
else next.set("tab", tab);
|
||||
const query = next.toString();
|
||||
router.replace(query ? `?${query}` : `/servers/${serverId}`, { scroll: false });
|
||||
panelsRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}
|
||||
|
||||
const {
|
||||
data: server,
|
||||
isLoading,
|
||||
@@ -328,6 +87,24 @@ export default function ServerDetailPage() {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// Both share their tab component's query key, so the count on the label and
|
||||
// the list inside the tab are one fetch, not two.
|
||||
const { data: findings } = useQuery({
|
||||
queryKey: ["vulnerabilities", "server", serverId],
|
||||
queryFn: () => vulnerabilities.forServer(serverId),
|
||||
});
|
||||
|
||||
const { data: workloadSnapshot } = useQuery({
|
||||
queryKey: ["workloads", serverId],
|
||||
queryFn: () => workloadsApi.forServer(serverId),
|
||||
});
|
||||
|
||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
|
||||
onSuccess: () => {
|
||||
@@ -337,12 +114,6 @@ export default function ServerDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () => api.updateAgent(serverId),
|
||||
onSuccess: () => {
|
||||
@@ -355,12 +126,15 @@ export default function ServerDetailPage() {
|
||||
mutationFn: () => api.applyUpdates(serverId),
|
||||
onSuccess: () => {
|
||||
setApplySuccess(true);
|
||||
setTimeout(() => {
|
||||
setApplySuccess(false);
|
||||
setShowUpdatesModal(false);
|
||||
}, 2000);
|
||||
setTimeout(() => setApplySuccess(false), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: refreshWorkloads } = useMutation({
|
||||
mutationFn: () => workloadsApi.refresh(serverId),
|
||||
onSuccess: () => setTimeout(() => queryClient.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500),
|
||||
});
|
||||
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
@@ -369,6 +143,62 @@ export default function ServerDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const openFindings = useMemo(() => (findings ?? []).filter((f) => f.state === "open"), [findings]);
|
||||
const seriousFindings = openFindings.filter((f) => f.severity === "critical" || f.severity === "high").length;
|
||||
const updateCount = server?.available_updates?.length ?? 0;
|
||||
const workloadCount = workloadSnapshot?.workloads?.length ?? 0;
|
||||
const activeKeys = (server?.keys ?? []).filter((a) => a.key && !a.revoked_at).length;
|
||||
const agentOutOfDate = !!latestVersion && !!server?.agent_version && server.agent_version !== latestVersion.version;
|
||||
|
||||
const attention: Attention[] = useMemo(() => {
|
||||
if (!server) return [];
|
||||
const items: Attention[] = [];
|
||||
|
||||
for (const p of server.inventory?.partitions ?? []) {
|
||||
const pct = p.total_bytes > 0 ? (p.used_bytes / p.total_bytes) * 100 : 0;
|
||||
if (pct >= 90) {
|
||||
items.push({
|
||||
tone: "danger",
|
||||
title: `${p.mountpoint} is ${pct.toFixed(0)}% full`,
|
||||
detail: `${((p.total_bytes - p.used_bytes) / 1024 ** 3).toFixed(1)} GB free`,
|
||||
goTo: "overview",
|
||||
action: "View storage",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (seriousFindings > 0) {
|
||||
items.push({
|
||||
tone: "danger",
|
||||
title: `${seriousFindings} critical or high severity finding${seriousFindings !== 1 ? "s" : ""}`,
|
||||
detail: openFindings
|
||||
.slice(0, 3)
|
||||
.map((f) => f.package_name)
|
||||
.join(", "),
|
||||
goTo: "security",
|
||||
action: "Review",
|
||||
});
|
||||
}
|
||||
if (updateCount > 0) {
|
||||
items.push({
|
||||
tone: "warning",
|
||||
title: `${updateCount} OS update${updateCount !== 1 ? "s" : ""} pending`,
|
||||
detail: server.updates_checked_at ? `checked ${new Date(server.updates_checked_at).toLocaleString()}` : "never checked",
|
||||
goTo: "maintenance",
|
||||
action: "Apply",
|
||||
});
|
||||
}
|
||||
if (agentOutOfDate) {
|
||||
items.push({
|
||||
tone: "warning",
|
||||
title: `Agent is behind v${latestVersion!.version}`,
|
||||
detail: `running v${server.agent_version}`,
|
||||
goTo: "maintenance",
|
||||
action: "Update",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [server, seriousFindings, openFindings, updateCount, agentOutOfDate, latestVersion]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -380,259 +210,104 @@ export default function ServerDetailPage() {
|
||||
if (error || !server) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
|
||||
<div className="rounded border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const protocols = server.console_protocols ?? [];
|
||||
|
||||
const actions: ServerAction[] = [
|
||||
...(protocols.length > 0
|
||||
? protocols.map((p, i) => ({
|
||||
group: i === 0 ? "Console" : undefined,
|
||||
label: `Connect ${p.toUpperCase()}`,
|
||||
icon: <ConsoleIcon />,
|
||||
href: `/servers/${serverId}/console?protocol=${p}`,
|
||||
// Offered disabled rather than hidden when the licence does not
|
||||
// include the console: a customer cannot buy what they cannot see.
|
||||
disabled: !consoleAllowed,
|
||||
title: consoleAllowed ? undefined : "Upgrade to use the browser console",
|
||||
}))
|
||||
: [{ group: "Console", label: "No console protocol", icon: <ConsoleIcon />, disabled: true, title: "This host reports no console protocol" }]),
|
||||
{ group: "Manage", label: "Generate SSH key", icon: <KeyIcon />, onSelect: () => setShowGenerateModal(true), separated: true },
|
||||
{ label: "Refresh workloads", icon: <RefreshIcon />, onSelect: () => refreshWorkloads() },
|
||||
{
|
||||
label: "Update agent",
|
||||
icon: <ArrowUpCircleIcon />,
|
||||
onSelect: () => triggerUpdate(),
|
||||
disabled: server.status !== "active" || isUpdating,
|
||||
title: server.status !== "active" ? "Agent must be online to update" : undefined,
|
||||
},
|
||||
...(updateCount > 0 ? [{ label: `Apply ${updateCount} OS update${updateCount !== 1 ? "s" : ""}`, icon: <ShieldIcon />, onSelect: () => selectTab("maintenance") }] : []),
|
||||
{ label: "Remove server", icon: <TrashIcon />, onSelect: () => selectTab("maintenance"), danger: true, separated: true },
|
||||
];
|
||||
|
||||
const tabs: TabSpec[] = [
|
||||
{ id: "overview", label: "Overview", count: attention.length, tone: attention.some((a) => a.tone === "danger") ? "danger" : "warning" },
|
||||
{ id: "workloads", label: "Workloads", count: workloadCount },
|
||||
{ id: "security", label: "Security", count: openFindings.length, tone: seriousFindings > 0 ? "danger" : "neutral" },
|
||||
{ id: "access", label: "Access", count: activeKeys },
|
||||
{ id: "maintenance", label: "Maintenance", count: updateCount, tone: "warning" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<>
|
||||
{showGenerateModal && <GenerateKeyModal onClose={() => setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />}
|
||||
{showUpdatesModal && server.available_updates && (
|
||||
<UpdatesModal updates={server.available_updates} onClose={() => setShowUpdatesModal(false)} onApply={() => applyUpdates()} isApplying={isApplying} applySuccess={applySuccess} />
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
||||
← Servers
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
||||
<div className="mt-2">
|
||||
<TagChips serverId={server.server_id} tags={server.tags} editable />
|
||||
{/*
|
||||
* The faceplate sticks under whichever chrome is above it: the mobile
|
||||
* top bar below lg, nothing above it. z-20 keeps it under that bar
|
||||
* (z-40) and under the nav drawer (z-50). The scroll container is
|
||||
* AppShell's column, not the window, which is what sticky anchors to.
|
||||
*/}
|
||||
<div className="sticky top-14 z-20 border-b border-border bg-background/90 px-4 pt-4 backdrop-blur sm:px-6 lg:top-0 lg:px-8">
|
||||
<Link href="/servers" className="text-sm text-text-secondary transition-colors hover:text-text-primary">
|
||||
← Servers
|
||||
</Link>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
<span className="font-mono text-sm text-text-secondary">{server.ip_address}</span>
|
||||
<div className="ml-auto">
|
||||
<ServerActionsMenu actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Rendered disabled rather than hidden when the licence does not
|
||||
include the console: a customer cannot buy what they cannot see,
|
||||
and a feature that vanishes reads as a bug. */}
|
||||
{server.console_protocols?.map((p) => (
|
||||
<Link
|
||||
key={p}
|
||||
href={consoleAllowed ? `/servers/${serverId}/console?protocol=${p}` : "#"}
|
||||
aria-disabled={!consoleAllowed}
|
||||
title={consoleAllowed ? undefined : "Upgrade to use the browser console"}
|
||||
onClick={(e) => {
|
||||
if (!consoleAllowed) e.preventDefault();
|
||||
}}
|
||||
className={consoleAllowed ? undefined : "pointer-events-none opacity-50"}
|
||||
>
|
||||
<Button variant="secondary" disabled={!consoleAllowed}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
|
||||
/>
|
||||
</svg>
|
||||
Connect {p.toUpperCase()}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
{server.available_updates && server.available_updates.length > 0 && (
|
||||
<Button variant="secondary" onClick={() => setShowUpdatesModal(true)} className="border-warning/50 text-warning hover:border-warning hover:bg-warning/10">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
{server.available_updates.length} OS Update{server.available_updates.length !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setShowGenerateModal(true)}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove Server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteServer()}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<TagChips serverId={server.server_id} tags={server.tags} editable />
|
||||
</div>
|
||||
|
||||
<VitalsRail server={server} agentUpToDate={latestVersion && server.agent_version ? !agentOutOfDate : undefined} />
|
||||
|
||||
<div className="mt-3">
|
||||
<ServerTabs tabs={tabs} active={activeTab} onSelect={selectTab} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={panelsRef} className="p-4 sm:p-6 lg:p-8">
|
||||
<div role="tabpanel" id={`server-panel-${activeTab}`} aria-labelledby={`server-tab-${activeTab}`}>
|
||||
{activeTab === "overview" && <OverviewTab server={server} attention={attention} onGoTo={selectTab} />}
|
||||
{activeTab === "workloads" && <WorkloadList serverId={server.server_id} canControl={isAdmin} />}
|
||||
{activeTab === "security" && <ServerVulnerabilities serverId={server.server_id} />}
|
||||
{activeTab === "access" && <AccessTab server={server} onGenerateKey={() => setShowGenerateModal(true)} />}
|
||||
{activeTab === "maintenance" && (
|
||||
<MaintenanceTab
|
||||
server={server}
|
||||
latestVersion={latestVersion?.version}
|
||||
onApplyUpdates={() => applyUpdates()}
|
||||
isApplying={isApplying}
|
||||
applySuccess={applySuccess}
|
||||
onUpdateAgent={() => triggerUpdate()}
|
||||
isUpdatingAgent={isUpdating}
|
||||
updateAgentSuccess={updateSuccess}
|
||||
onDelete={() => deleteServer()}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update Agent</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion.version}` : "n/a"}</span>
|
||||
</div>
|
||||
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && <Badge variant="warning">update available</Badge>}
|
||||
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && <Badge variant="success">up to date</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
onClick={() => triggerUpdate()}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{updateSuccess ? "Update Sent!" : "Update Agent"}
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-0 overflow-x-auto rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span>{" "}
|
||||
<span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand(server.os_info));
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{server.inventory && (
|
||||
<div className="mb-6">
|
||||
<InventoryPanel inv={server.inventory} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Server ID</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Agent Version</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Registered</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<ServerVulnerabilities serverId={server.server_id} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<WorkloadList serverId={server.server_id} canControl={isAdmin} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Installed SSH Keys
|
||||
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">{server.keys?.filter((k) => !k.revoked_at).length ?? 0} active</span>
|
||||
</h2>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">
|
||||
Manage Keys →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!server.keys || server.keys.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{server.keys
|
||||
.filter((a) => a.key)
|
||||
.map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td label="Label">
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td label="Fingerprint">
|
||||
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
|
||||
</Td>
|
||||
<Td label="Source">
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
|
||||
</Td>
|
||||
<Td label="Status">
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
|
||||
</Td>
|
||||
<Td label="Assigned">
|
||||
<span className="text-text-secondary text-xs">{formatDate(assignment.assigned_at)}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GenerateKeyOptions } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
const KEY_SIZES: Record<string, number[]> = {
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: Record<string, number> = {
|
||||
rsa: 4096,
|
||||
ecdsa: 256,
|
||||
};
|
||||
|
||||
export function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [keySize, setKeySize] = useState<number>(4096);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||
setKeyType(t);
|
||||
if (t !== "ed25519") {
|
||||
setKeySize(DEFAULT_SIZE[t]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label: label || "generated",
|
||||
key_type: keyType,
|
||||
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const sizes = KEY_SIZES[keyType];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-text-secondary transition-colors hover:text-text-primary">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. server-deploy-key"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => handleKeyTypeChange(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
|
||||
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
|
||||
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||
<select
|
||||
value={keySize}
|
||||
onChange={(e) => setKeySize(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="e.g. user@hostname"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Optional passphrase"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||
Generate Key
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/*
|
||||
* Every action this page can take, behind one control.
|
||||
*
|
||||
* The header used to carry up to six buttons — a Connect per console protocol,
|
||||
* OS updates, Generate key, Remove — and which of them appeared depended on the
|
||||
* server, so the row an operator reached for moved between machines. One button
|
||||
* in one place is worth more than a shortcut that is sometimes there.
|
||||
*
|
||||
* An action the licence or the host does not allow is rendered disabled with a
|
||||
* reason rather than hidden: a customer cannot buy what they cannot see, and a
|
||||
* control that vanishes reads as a bug.
|
||||
*/
|
||||
|
||||
export interface ServerAction {
|
||||
/** Grouping heading. Items sharing one carry it once, on the first. */
|
||||
group?: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
/** Why it is disabled, or what it will do. Shown as the native tooltip. */
|
||||
title?: string;
|
||||
danger?: boolean;
|
||||
/** Draws a rule above this item. */
|
||||
separated?: boolean;
|
||||
}
|
||||
|
||||
export function ServerActionsMenu({ actions }: { actions: ServerAction[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
menuRef.current?.querySelector<HTMLElement>("[role=menuitem]:not([aria-disabled=true])")?.focus();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function onMenuKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>("[role=menuitem]:not([aria-disabled=true])") ?? [])];
|
||||
const i = items.indexOf(document.activeElement as HTMLElement);
|
||||
items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
|
||||
}
|
||||
|
||||
function run(action: ServerAction) {
|
||||
if (action.disabled) return;
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
if (action.href) router.push(action.href);
|
||||
action.onSelect?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative">
|
||||
<Button ref={buttonRef} variant="primary" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||
Actions
|
||||
<svg className={clsx("h-4 w-4 transition-transform", open && "rotate-180")} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label="Server actions"
|
||||
onKeyDown={onMenuKeyDown}
|
||||
className="absolute right-0 z-50 mt-1.5 w-60 rounded border border-border bg-surface p-1 shadow-panel"
|
||||
>
|
||||
{actions.map((action, i) => (
|
||||
<div key={action.label}>
|
||||
{action.separated && i > 0 && <div className="my-1 h-px bg-border-soft" />}
|
||||
{action.group && <p className="px-2 pb-1 pt-1.5 font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-tertiary">{action.group}</p>}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-disabled={action.disabled}
|
||||
disabled={action.disabled}
|
||||
title={action.title}
|
||||
onClick={() => run(action)}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2.5 rounded px-2 py-2 text-left text-sm font-medium transition-colors",
|
||||
action.disabled
|
||||
? "cursor-not-allowed text-text-tertiary"
|
||||
: action.danger
|
||||
? "text-danger hover:bg-danger/10"
|
||||
: "text-text-primary hover:bg-surface-2 [&>svg]:hover:text-accent",
|
||||
!action.disabled && !action.danger && "[&>svg]:text-text-tertiary",
|
||||
action.danger && "[&>svg]:text-danger",
|
||||
action.disabled && "[&>svg]:text-text-tertiary",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/*
|
||||
* The tab bar under the faceplate.
|
||||
*
|
||||
* Below md it is a select instead. Five tabs do not fit a phone, and the two
|
||||
* usual answers are both worse: wrapping to a second row changes the height of
|
||||
* the sticky header as the selection moves, and a horizontally scrolling strip
|
||||
* hides tabs off the right edge with nothing saying they are there. A select
|
||||
* shows every section and its count in one list, and it is the platform's own
|
||||
* picker, so it needs no scroll affordance of ours.
|
||||
*
|
||||
* Counts live on the labels because that is the only way an operator learns
|
||||
* there is something wrong on a tab they are not looking at. A count with a
|
||||
* tone is still labelled by its tab name, so tone is never the whole message —
|
||||
* and in the select, where tone cannot survive, the count still does.
|
||||
*/
|
||||
|
||||
export type TabId = "overview" | "workloads" | "security" | "access" | "maintenance";
|
||||
|
||||
export interface TabSpec {
|
||||
id: TabId;
|
||||
label: string;
|
||||
count?: number;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
}
|
||||
|
||||
export function ServerTabs({ tabs, active, onSelect }: { tabs: TabSpec[]; active: TabId; onSelect: (id: TabId) => void }) {
|
||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||
e.preventDefault();
|
||||
const i = tabs.findIndex((t) => t.id === active);
|
||||
const next = tabs[(i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length];
|
||||
onSelect(next.id);
|
||||
refs.current[next.id]?.focus();
|
||||
}
|
||||
|
||||
const current = tabs.find((t) => t.id === active);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Phone: the whole set in one picker, sitting on the same row as the
|
||||
section it names so the header keeps its height. */}
|
||||
<div className="pb-3 md:hidden">
|
||||
<label htmlFor="server-tab-select" className="sr-only">
|
||||
Server section
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="server-tab-select"
|
||||
value={active}
|
||||
onChange={(e) => onSelect(e.target.value as TabId)}
|
||||
className="w-full appearance-none rounded border border-border bg-surface-2 py-2 pl-3 pr-9 text-sm font-semibold text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 ? ` (${tab.count})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
{current?.count !== undefined && current.count > 0 && current.tone && current.tone !== "neutral" && (
|
||||
<p className={clsx("mt-1.5 text-xs", current.tone === "danger" ? "text-danger" : "text-warning")}>
|
||||
{current.count} item{current.count !== 1 ? "s" : ""} in {current.label.toLowerCase()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div role="tablist" aria-label="Server sections" onKeyDown={onKeyDown} className="-mb-px hidden gap-1 md:flex">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === active;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
refs.current[tab.id] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`server-tab-${tab.id}`}
|
||||
aria-selected={isActive}
|
||||
aria-controls={`server-panel-${tab.id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
className={clsx(
|
||||
"flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-3 py-2.5 text-sm transition-colors",
|
||||
isActive ? "border-accent font-semibold text-text-primary" : "border-transparent font-medium text-text-secondary hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full border px-1.5 py-px font-mono text-[0.62rem] tabular-nums",
|
||||
tab.tone === "danger"
|
||||
? "border-danger/40 bg-danger/10 text-danger"
|
||||
: tab.tone === "warning"
|
||||
? "border-warning/40 bg-warning/10 text-warning"
|
||||
: "border-border bg-surface-2 text-text-secondary",
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Inventory, Server } from "@/lib/api";
|
||||
import { formatBytes, relativeAge } from "./format";
|
||||
|
||||
/*
|
||||
* The four numbers an operator opens a server for, kept above the tabs so they
|
||||
* are true on every tab rather than living inside one of them. This is the only
|
||||
* part of the page that does not move when the tab changes.
|
||||
*
|
||||
* A meter is a hairline, not a bar: four of them across the top would otherwise
|
||||
* out-shout the hostname, and the number beside each is the value being read —
|
||||
* the meter only says how close to full it is.
|
||||
*/
|
||||
|
||||
function Meter({ pct }: { pct: number }) {
|
||||
const clamped = Math.max(0, Math.min(100, pct));
|
||||
return (
|
||||
<div className="mt-2 h-[3px] w-full overflow-hidden rounded-full bg-well">
|
||||
<div
|
||||
className={clsx("h-full rounded-full transition-[width] duration-500", clamped >= 90 ? "bg-danger" : clamped >= 75 ? "bg-warning" : "bg-accent")}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Vital({ label, value, pct, sub }: { label: string; value: string; pct?: number; sub?: string }) {
|
||||
return (
|
||||
<div className="min-w-0 bg-surface px-4 py-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-secondary">{label}</span>
|
||||
<span className="font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</span>
|
||||
</div>
|
||||
{pct !== undefined ? <Meter pct={pct} /> : <div className="mt-2 h-[3px] w-full rounded-full bg-well" />}
|
||||
{sub && <p className="mt-1.5 truncate text-xs text-text-tertiary">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The partition an operator means by "the disk": the root filesystem, or the
|
||||
* fullest one if there is no root — a Windows agent reports no `/`. */
|
||||
function primaryPartition(inv: Inventory) {
|
||||
const parts = inv.partitions ?? [];
|
||||
if (parts.length === 0) return undefined;
|
||||
return parts.find((p) => p.mountpoint === "/") ?? parts.reduce((worst, p) => (p.used_bytes / (p.total_bytes || 1) > worst.used_bytes / (worst.total_bytes || 1) ? p : worst));
|
||||
}
|
||||
|
||||
export function VitalsRail({ server, agentUpToDate }: { server: Server; agentUpToDate?: boolean }) {
|
||||
const inv = server.inventory;
|
||||
const disk = inv ? primaryPartition(inv) : undefined;
|
||||
const memPct = inv && inv.memory.total_bytes > 0 ? (inv.memory.used_bytes / inv.memory.total_bytes) * 100 : 0;
|
||||
const diskPct = disk && disk.total_bytes > 0 ? (disk.used_bytes / disk.total_bytes) * 100 : 0;
|
||||
|
||||
const agentSub = server.agent_version ? `agent v${server.agent_version}${agentUpToDate === undefined ? "" : agentUpToDate ? " · up to date" : " · update available"}` : "agent version unknown";
|
||||
|
||||
return (
|
||||
// One hairline grid rather than four cards: these are readings off one
|
||||
// machine, and four bordered panels would read as four subjects.
|
||||
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded border border-border-soft bg-border-soft lg:grid-cols-4">
|
||||
{inv ? (
|
||||
<>
|
||||
<Vital
|
||||
label="CPU"
|
||||
value={`${inv.cpu.usage_pct.toFixed(0)}%`}
|
||||
pct={inv.cpu.usage_pct}
|
||||
sub={[inv.cpu.cores ? `${inv.cpu.cores} cores` : null, inv.cpu.load1 !== undefined ? `load ${inv.cpu.load1.toFixed(2)}` : null].filter(Boolean).join(" · ") || inv.cpu.model}
|
||||
/>
|
||||
<Vital
|
||||
label="Memory"
|
||||
value={`${formatBytes(inv.memory.used_bytes)} / ${formatBytes(inv.memory.total_bytes)}`}
|
||||
pct={memPct}
|
||||
sub={inv.swap_total_bytes > 0 ? `swap ${formatBytes(inv.swap_used_bytes)} / ${formatBytes(inv.swap_total_bytes)}` : "no swap"}
|
||||
/>
|
||||
<Vital
|
||||
label={disk ? `Disk ${disk.mountpoint}` : "Disk"}
|
||||
value={disk ? `${diskPct.toFixed(0)}%` : "—"}
|
||||
pct={disk ? diskPct : undefined}
|
||||
sub={disk ? `${formatBytes(disk.used_bytes)} / ${formatBytes(disk.total_bytes)}${disk.fstype ? ` · ${disk.fstype}` : ""}` : "no partitions reported"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Vital label="CPU" value="—" sub="no metrics reported" />
|
||||
<Vital label="Memory" value="—" sub="no metrics reported" />
|
||||
<Vital label="Disk" value="—" sub="no metrics reported" />
|
||||
</>
|
||||
)}
|
||||
<Vital label="Last seen" value={relativeAge(server.last_seen)} pct={server.status === "active" ? 100 : 0} sub={agentSub} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Formatting shared by the server detail panels. One copy, because the rail
|
||||
* and the storage panel must round the same bytes the same way. */
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024));
|
||||
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
export function relativeAge(iso?: string): string {
|
||||
if (!iso) return "never";
|
||||
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 60) return `${Math.round(secs)}s ago`;
|
||||
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
|
||||
if (secs < 86_400) return `${Math.round(secs / 3600)}h ago`;
|
||||
return `${Math.round(secs / 86_400)}d ago`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** The line icons the server actions menu uses. Heroicons outline, 1.5 stroke,
|
||||
* the same set and weight the sidebar draws. */
|
||||
|
||||
const props = { className: "h-4 w-4 shrink-0 transition-colors", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5 } as const;
|
||||
|
||||
export function ConsoleIcon() {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon() {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpCircleIcon() {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75l-3 3m3-3l3 3m-3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShieldIcon() {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.998 2.25a.75.75 0 01.298.062l7.5 3.214a.75.75 0 01.454.69v5.034c0 4.63-2.94 8.75-7.5 10.25a.75.75 0 01-.5 0c-4.56-1.5-7.5-5.62-7.5-10.25V6.216a.75.75 0 01.454-.69l7.5-3.214a.75.75 0 01.294-.062z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon() {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166M18.16 19.673A2.25 2.25 0 0115.916 21.75H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-11 .397c.34-.059.68-.114 1.022-.165M15.75 5.393v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshIcon() {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992V4.356m-4.992 4.992l3.181-3.03a8.25 8.25 0 00-13.803 3.03M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.03a8.25 8.25 0 0013.803-3.03"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ServerWithKeys } from "@/lib/api";
|
||||
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
|
||||
import { formatDate } from "../format";
|
||||
|
||||
export function AccessTab({ server, onGenerateKey }: { server: ServerWithKeys; onGenerateKey: () => void }) {
|
||||
const assignments = (server.keys ?? []).filter((a) => a.key);
|
||||
const active = assignments.filter((a) => !a.revoked_at).length;
|
||||
|
||||
return (
|
||||
<Card padding={false}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-text-primary">
|
||||
Installed SSH keys
|
||||
<span className="rounded-full bg-surface-2 px-2 py-0.5 font-mono text-[0.68rem] tabular-nums text-text-secondary">{active} active</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onGenerateKey}>
|
||||
Generate key
|
||||
</Button>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">
|
||||
Manage keys →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assignments.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-text-secondary">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{assignments.map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td label="Label">
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td label="Fingerprint">
|
||||
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
|
||||
</Td>
|
||||
<Td label="Source">
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
|
||||
</Td>
|
||||
<Td label="Status">
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
|
||||
</Td>
|
||||
<Td label="Assigned">
|
||||
<span className="text-xs text-text-secondary">{formatDate(assignment.assigned_at)}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api, ServerWithKeys } from "@/lib/api";
|
||||
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
|
||||
|
||||
/*
|
||||
* Everything that changes what is installed on the machine: its OS packages,
|
||||
* its agent, and its existence.
|
||||
*
|
||||
* The OS update list is a panel here rather than the modal it used to be. A
|
||||
* modal made the list a detour off a header button; the work of patching a
|
||||
* server is the reason this tab exists, so the list is the tab.
|
||||
*/
|
||||
|
||||
export function MaintenanceTab({
|
||||
server,
|
||||
latestVersion,
|
||||
onApplyUpdates,
|
||||
isApplying,
|
||||
applySuccess,
|
||||
onUpdateAgent,
|
||||
isUpdatingAgent,
|
||||
updateAgentSuccess,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
server: ServerWithKeys;
|
||||
latestVersion?: string;
|
||||
onApplyUpdates: () => void;
|
||||
isApplying: boolean;
|
||||
applySuccess: boolean;
|
||||
onUpdateAgent: () => void;
|
||||
isUpdatingAgent: boolean;
|
||||
updateAgentSuccess: boolean;
|
||||
onDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const updates = server.available_updates ?? [];
|
||||
const command = api.getUpdateCommand(server.os_info);
|
||||
const isWindows = server.os_info?.toLowerCase().includes("windows");
|
||||
const agentCurrent = !!latestVersion && !!server.agent_version && server.agent_version === latestVersion;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">OS updates</h2>
|
||||
{updates.length > 0 ? <Badge variant="warning">{updates.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
|
||||
</div>
|
||||
|
||||
{updates.length === 0 ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-text-secondary">No pending package updates. The agent checks hourly.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Capped so a host with 400 pending packages does not make the
|
||||
Apply button a scroll away. The count is on the badge. */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Package</Th>
|
||||
<Th>Current</Th>
|
||||
<Th>Available</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{updates.map((u) => (
|
||||
<Tr key={u.name}>
|
||||
<Td label="Package">
|
||||
<span className="font-mono text-sm font-medium">{u.name}</span>
|
||||
</Td>
|
||||
<Td label="Current">
|
||||
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
|
||||
</Td>
|
||||
<Td label="Available">
|
||||
<span className="font-mono text-xs text-success">{u.new_version}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border px-6 py-4">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApplyUpdates} disabled={server.status !== "active"} title={server.status !== "active" ? "Agent must be online to apply updates" : undefined}>
|
||||
{applySuccess ? "Sent!" : "Apply updates"}
|
||||
</Button>
|
||||
<p className="text-xs text-text-tertiary">Upgrade runs in the background and may take several minutes.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card padding={false}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Agent</h2>
|
||||
{latestVersion && server.agent_version && <Badge variant={agentCurrent ? "success" : "warning"}>{agentCurrent ? "up to date" : "update available"}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion}` : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-x-auto rounded border border-border bg-well px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">{isWindows ? "PS>" : "$"}</span> <span className="text-text-primary">{command}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copied ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdatingAgent}
|
||||
onClick={onUpdateAgent}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{updateAgentSuccess ? "Update sent!" : "Update agent"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={false} className="border-danger/30">
|
||||
<div className="border-b border-danger/30 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-danger">Remove server</h2>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Deletes this server and its history from Vantage. The agent stays installed on the machine and keeps trying to connect until you uninstall it there.
|
||||
</p>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-danger">Remove {server.hostname}?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={onDelete}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { ServerWithKeys } from "@/lib/api";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { formatBytes, formatDate } from "../format";
|
||||
import type { TabId } from "../ServerTabs";
|
||||
|
||||
/*
|
||||
* Overview answers one question: is anything wrong with this machine, and where
|
||||
* do I go about it. The detail lives on the other tabs — everything here either
|
||||
* states a fact about the host or points at the tab that can act on it.
|
||||
*/
|
||||
|
||||
export interface Attention {
|
||||
tone: "danger" | "warning";
|
||||
title: string;
|
||||
detail: string;
|
||||
/** The tab that can do something about it. */
|
||||
goTo: TabId;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function StoragePanel({ server }: { server: ServerWithKeys }) {
|
||||
const partitions = server.inventory?.partitions ?? [];
|
||||
|
||||
return (
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Storage</h2>
|
||||
<span className="font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary">
|
||||
{partitions.length} partition{partitions.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{partitions.length === 0 ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-text-secondary">No partitions reported. The agent sends a full inventory every 15 minutes.</p>
|
||||
) : (
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{partitions.map((p) => {
|
||||
const pct = p.total_bytes > 0 ? (p.used_bytes / p.total_bytes) * 100 : 0;
|
||||
return (
|
||||
<div key={p.mountpoint}>
|
||||
<div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<span className="font-mono text-sm text-text-primary">{p.mountpoint}</span>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)}
|
||||
{p.fstype ? ` · ${p.fstype}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-[3px] w-full overflow-hidden rounded-full bg-well">
|
||||
<div className={clsx("h-full rounded-full", pct >= 90 ? "bg-danger" : pct >= 75 ? "bg-warning" : "bg-accent")} style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ term, children, mono = true }: { term: string; children: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 border-b border-border-soft py-2.5 last:border-b-0">
|
||||
<dt className="shrink-0 text-xs text-text-secondary">{term}</dt>
|
||||
<dd className={clsx("min-w-0 break-all text-right text-sm text-text-primary", mono && "font-mono text-xs")}>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MachinePanel({ server }: { server: ServerWithKeys }) {
|
||||
return (
|
||||
<Card padding={false}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Machine</h2>
|
||||
</div>
|
||||
<dl className="px-6 py-2">
|
||||
<Fact term="OS" mono={false}>
|
||||
{server.os_info || "unknown"}
|
||||
</Fact>
|
||||
{server.inventory?.kernel && <Fact term="Kernel">{server.inventory.kernel}</Fact>}
|
||||
{server.inventory?.cpu.model && <Fact term="CPU">{server.inventory.cpu.model}</Fact>}
|
||||
<Fact term="Agent version">{server.agent_version ? `v${server.agent_version}` : "unknown"}</Fact>
|
||||
<Fact term="Last seen" mono={false}>
|
||||
{server.last_seen ? formatDate(server.last_seen) : "Never"}
|
||||
</Fact>
|
||||
<Fact term="Registered" mono={false}>
|
||||
{formatDate(server.created_at)}
|
||||
</Fact>
|
||||
<Fact term="Server ID">{server.server_id}</Fact>
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionPanel({ items, onGoTo }: { items: Attention[]; onGoTo: (tab: TabId) => void }) {
|
||||
return (
|
||||
<Card padding={false}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<CardHeader className="mb-0">
|
||||
<CardTitle className="text-lg font-semibold">Needs attention</CardTitle>
|
||||
</CardHeader>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-success">Nothing outstanding on this server.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border-soft">
|
||||
{items.map((item) => (
|
||||
<li key={item.title} className="flex flex-wrap items-center justify-between gap-3 px-6 py-3.5">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{/* The dot is recognition, never the message — the title says
|
||||
what is wrong on its own. */}
|
||||
<span className={clsx("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", item.tone === "danger" ? "bg-danger" : "bg-warning")} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{item.title}</p>
|
||||
<p className="font-mono text-xs text-text-secondary">{item.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => onGoTo(item.goTo)} className="text-sm font-semibold text-accent transition-colors hover:text-accent-hover">
|
||||
{item.action} →
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ server, attention, onGoTo }: { server: ServerWithKeys; attention: Attention[]; onGoTo: (tab: TabId) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AttentionPanel items={attention} onGoTo={onGoTo} />
|
||||
{/* Collapses at xl, not lg: the 240px sidebar leaves a 1280px laptop
|
||||
about 1010px, which is not enough for a two-thirds split. */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<div className="xl:col-span-2">
|
||||
<StoragePanel server={server} />
|
||||
</div>
|
||||
<MachinePanel server={server} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-1
@@ -996,11 +996,14 @@ export interface VulnAlertRuleInput {
|
||||
}
|
||||
|
||||
export const vulnerabilities = {
|
||||
list(params?: { severity?: string; state?: string; server?: string; tags?: Record<string, string> }): Promise<VulnGroup[]> {
|
||||
list(params?: { severity?: string; state?: string; server?: string; hasFix?: boolean; tags?: Record<string, string> }): Promise<VulnGroup[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.severity) q.set("severity", params.severity);
|
||||
if (params?.state) q.set("state", params.state);
|
||||
if (params?.server) q.set("server", params.server);
|
||||
// Explicitly undefined-checked: `false` is a real selection here (the
|
||||
// unfixable set), so a truthiness test would silently drop it.
|
||||
if (params?.hasFix !== undefined) q.set("has_fix", String(params.hasFix));
|
||||
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
|
||||
const qs = q.toString();
|
||||
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user