Compare commits

...
9 Commits
Author SHA1 Message Date
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
mrhid6 6dced22499 fix: Fixed agent collect packages
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 21s
Agent Release / build (push) Successful in 58s
Agent Release / msi (push) Successful in 1m34s
2026-08-06 16:20:34 +01:00
mrhid6 5cee53dc5f feat: Better button description
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 2m23s
2026-08-06 15:51:18 +01:00
mrhid6 81248bb159 style: bring the vulnerabilities page onto the house page shape
Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.

The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
2026-08-06 15:49:39 +01:00
mrhid6 6354d54de8 feat: added addon price to pricing page 2026-08-06 15:48:20 +01:00
mrhid6 da6d64f95c fix: give the scratch server image a /tmp for the vulnerability database
The runtime stage is FROM scratch, which has no /tmp, so vulnsched died at
startup with "temp dir: stat /tmp: no such file or directory" and no scan
ever ran. Nothing in the server wrote to a temporary directory before the
trivy-db puller, which is why this only appeared now.

scratch cannot mkdir its own, so the directory is staged in the builder at
1777 and copied in. Also corrects CLAUDE.md, which described this image as
Alpine; the time/tzdata import it justifies is if anything more load-bearing
on scratch.
2026-08-06 15:46:30 +01:00
mrhid6 9ba3d4a61f feat: Vulnerability Scanning feature on license page
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m20s
2026-08-06 15:40:13 +01:00
mrhid6 eee236a072 fix: tidy server go.mod for the vulnerability database dependencies
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Canceled after 2m11s
trivy-db, oras-go, the three version comparators and robfig/cron were
recorded as indirect with an incomplete go.sum, which builds locally
against a warm module cache but fails in CI with "updates to go.mod
needed". trivy-db pulls testify into the build graph, and its hashes
were missing entirely.
2026-08-06 15:18:57 +01:00
mrhid6 9df89e2db4 fix: surface vuln_scanning across licence, staff and pricing UI
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Canceled after 59s
The catalogue row alone was not enough; the feature was invisible in
three places and mislabelled in a fourth.

PlanConfigurator rendered every key that was not "console" as "Single
sign-on", so the staff checkbox granting vulnerability scanning was
labelled single sign-on. Feature wording was duplicated between the staff
configurator and the purchase form and the copies had drifted, so it now
lives in adminsite/lib/features.ts and both read from it.

The customer licence panel showed raw keys; it now labels them.

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