From fbcf436ef61b0559beedd35db990228d49c43f1d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 14:55:30 +0000 Subject: [PATCH] feat: show Ubuntu phased updates apart and leave them out of pending counts apt lists phased updates as upgradable while an upgrade defers them until Ubuntu selects the host, so a freshly patched server kept reporting pending updates. The agent now flags them; the server stores the flag and leaves them out of patch run counts, and the server page shows them in their own section. --- server/go.mod | 2 +- server/go.sum | 4 ++-- server/internal/api/docs/openapi.json | 4 ++++ server/internal/grpc/server.go | 1 + server/internal/mcp/tools_work.go | 6 +++-- server/internal/models/server.go | 3 +++ server/internal/services/patch_runs.go | 2 +- server/internal/services/updates_count.go | 17 +++++++++++++ .../internal/services/updates_count_test.go | 23 ++++++++++++++++++ web/app/(app)/servers/[id]/page.tsx | 4 ++-- web/app/(app)/servers/page.tsx | 5 ++-- .../servers/tabs/MaintenanceTab.tsx | 24 +++++++++++++++---- web/lib/api.ts | 7 ++++++ 13 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 server/internal/services/updates_count.go create mode 100644 server/internal/services/updates_count_test.go diff --git a/server/go.mod b/server/go.mod index b8a38e0..87556e7 100644 --- a/server/go.mod +++ b/server/go.mod @@ -47,7 +47,7 @@ require ( ) require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0 + gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 github.com/bytedance/sonic v1.15.3 // indirect github.com/bytedance/sonic/loader v0.5.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/server/go.sum b/server/go.sum index 6f58fea..3069ae0 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +1,5 @@ -gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0 h1:xwSIEkQKTd4Qk+BYHvoGN+h84Isr2h5qqnitUWF1m2w= -gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 h1:EtojZ1d3cN9foHpc/CAI3KzBewYGn4sKWdkWs2MV78Q= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck= 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-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs= diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index 97209c6..c3baa9e 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -1389,6 +1389,10 @@ }, "new_version": { "type": "string" + }, + "phased": { + "description": "Phased is an Ubuntu phased update the host is not yet selected for: apt\nlists it but an upgrade defers it, so pending counts leave it out.", + "type": "boolean" } }, "type": "object" diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index f9fbe84..390a376 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -103,6 +103,7 @@ func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdates Name: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, + Phased: u.Phased, } } if err := services.StoreAvailableUpdates(srv.ServerID, pkgs); err != nil { diff --git a/server/internal/mcp/tools_work.go b/server/internal/mcp/tools_work.go index 06b7ed8..ccd723e 100644 --- a/server/internal/mcp/tools_work.go +++ b/server/internal/mcp/tools_work.go @@ -80,6 +80,8 @@ type pendingUpdate struct { Package string `json:"package"` CurrentVersion string `json:"current_version,omitempty"` NewVersion string `json:"new_version"` + // Phased is an Ubuntu phased update apt defers until the host is selected. + Phased bool `json:"phased,omitempty"` } type listPendingUpdatesResult struct { @@ -390,7 +392,7 @@ func init() { } out = append(out, pendingUpdate{ ServerID: srv.ServerID, Hostname: srv.Hostname, - Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, + Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased, }) } return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil @@ -411,7 +413,7 @@ func init() { } out = append(out, pendingUpdate{ ServerID: srv.ServerID, Hostname: srv.Hostname, - Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, + Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased, }) } } diff --git a/server/internal/models/server.go b/server/internal/models/server.go index 4e98fe6..2672ac3 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -10,6 +10,9 @@ type PackageUpdate struct { Name string `bson:"name" json:"name"` CurrentVersion string `bson:"current_version,omitempty" json:"current_version,omitempty"` NewVersion string `bson:"new_version" json:"new_version"` + // Phased is an Ubuntu phased update the host is not yet selected for: apt + // lists it but an upgrade defers it, so pending counts leave it out. + Phased bool `bson:"phased,omitempty" json:"phased,omitempty"` } type CPUInfo struct { diff --git a/server/internal/services/patch_runs.go b/server/internal/services/patch_runs.go index dbde58d..cc93c3d 100644 --- a/server/internal/services/patch_runs.go +++ b/server/internal/services/patch_runs.go @@ -83,7 +83,7 @@ func serverBootTime(ctx context.Context, instanceID, serverID string) *time.Time } func newServerRun(s models.Server, now time.Time) models.PatchServerRun { - r := models.PatchServerRun{ServerID: s.ServerID, Hostname: s.Hostname, Status: models.PatchSrvQueued, PendingBefore: len(s.AvailableUpdates)} + r := models.PatchServerRun{ServerID: s.ServerID, Hostname: s.Hostname, Status: models.PatchSrvQueued, PendingBefore: InstallableUpdateCount(s.AvailableUpdates)} if !patchrun.AgentSupportsPatchResults(s.AgentVersion) { v := s.AgentVersion if v == "" { diff --git a/server/internal/services/updates_count.go b/server/internal/services/updates_count.go new file mode 100644 index 0000000..e3938ec --- /dev/null +++ b/server/internal/services/updates_count.go @@ -0,0 +1,17 @@ +package services + +import "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + +// InstallableUpdateCount is the number of pending updates an upgrade would +// install now. Ubuntu phased updates are listed by apt but deferred until the +// host's phase comes up, so they are not counted: counting them made a patch +// run look as if it installed less than it did. +func InstallableUpdateCount(ups []models.PackageUpdate) int { + n := 0 + for _, u := range ups { + if !u.Phased { + n++ + } + } + return n +} diff --git a/server/internal/services/updates_count_test.go b/server/internal/services/updates_count_test.go new file mode 100644 index 0000000..b9138fd --- /dev/null +++ b/server/internal/services/updates_count_test.go @@ -0,0 +1,23 @@ +package services + +import ( + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +// Phased updates are listed but not installable yet, so they never count as +// pending: they must not lower "updates installed" on a patch run. +func TestInstallableUpdateCount(t *testing.T) { + ups := []models.PackageUpdate{ + {Name: "curl"}, + {Name: "netplan.io", Phased: true}, + {Name: "openssl"}, + } + if got := InstallableUpdateCount(ups); got != 2 { + t.Fatalf("got %d, want 2", got) + } + if got := InstallableUpdateCount(nil); got != 0 { + t.Fatalf("got %d for nil, want 0", got) + } +} diff --git a/web/app/(app)/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx index af34417..0d01eb7 100644 --- a/web/app/(app)/servers/[id]/page.tsx +++ b/web/app/(app)/servers/[id]/page.tsx @@ -4,7 +4,7 @@ import { useMemo, useRef, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { api, GenerateKeyOptions, ServerStatus, vulnerabilities, workloads as workloadsApi } from "@/lib/api"; +import { api, GenerateKeyOptions, installableUpdates, ServerStatus, vulnerabilities, workloads as workloadsApi } from "@/lib/api"; import { Badge, friendlyMessage, useToast } from "@/components/ui"; import { useLicense } from "@/lib/useLicense"; import { TagChips } from "@/components/servers/TagChips"; @@ -163,7 +163,7 @@ 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 updateCount = installableUpdates(server?.available_updates).length; 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; diff --git a/web/app/(app)/servers/page.tsx b/web/app/(app)/servers/page.tsx index fe06ea0..cf890b1 100644 --- a/web/app/(app)/servers/page.tsx +++ b/web/app/(app)/servers/page.tsx @@ -3,7 +3,7 @@ import { Suspense, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "next/navigation"; -import { api, Server } from "@/lib/api"; +import { api, installableUpdates, Server } from "@/lib/api"; import { AsyncBoundary, Button, Card, CenteredSpinner, EmptyState, TableSkeleton } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; import { TagChips } from "@/components/servers/TagChips"; @@ -32,7 +32,8 @@ const STATUS_ORDER: Record = { function resolveStatus(server: Server, latestVersion: string | undefined): DotStatus { if (server.status === "offline" || server.status === "pending") return "offline"; if (latestVersion && server.agent_version && server.agent_version !== latestVersion) return "needs-update"; - if (server.available_updates && server.available_updates.length > 0) return "has-package-updates"; + // Phased updates are deferred by apt, so they do not make a server need patching. + if (installableUpdates(server.available_updates).length > 0) return "has-package-updates"; return "ok"; } diff --git a/web/components/servers/tabs/MaintenanceTab.tsx b/web/components/servers/tabs/MaintenanceTab.tsx index 6e36712..08fc2ab 100644 --- a/web/components/servers/tabs/MaintenanceTab.tsx +++ b/web/components/servers/tabs/MaintenanceTab.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; -import { api, ServerWithKeys } from "@/lib/api"; +import { api, installableUpdates, ServerWithKeys } from "@/lib/api"; import { Badge, Button, Card, ConfirmDialog, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui"; import { matchesTags } from "@/lib/targets"; import { RUN_STATUS } from "@/components/patching/status"; @@ -41,7 +41,10 @@ export function MaintenanceTab({ const [copied, setCopied] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); - const updates = server.available_updates ?? []; + // Phased updates are listed by apt but deferred until Ubuntu selects this + // host, so they are shown apart and never count as pending. + const installable = installableUpdates(server.available_updates); + const phased = (server.available_updates ?? []).filter((u) => u.phased); 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; @@ -63,7 +66,8 @@ export function MaintenanceTab({

OS updates

- {updates.length > 0 ? {updates.length} pending : up to date} + {installable.length > 0 ? {installable.length} pending : up to date} + {phased.length > 0 && {phased.length} phased} {/* Sits with the updates panel because that is what caused it. The agent never reboots a host itself. */} {server.inventory?.reboot_required && reboot required} @@ -88,7 +92,7 @@ export function MaintenanceTab({ )}
- {updates.length === 0 ? ( + {installable.length === 0 ? (

{isWindows ? "No pending Windows updates. The agent checks hourly." : "No pending package updates. The agent checks hourly."}

@@ -106,7 +110,7 @@ export function MaintenanceTab({ - {updates.map((u) => ( + {installable.map((u) => ( {u.name} @@ -131,6 +135,16 @@ export function MaintenanceTab({
)} + + {phased.length > 0 && ( +
+

+ {phased.length} deferred by Ubuntu phasing. Ubuntu releases these to a share of machines at a time and + apt holds them back until this server is selected, usually within a few days. They install on a later run; nothing needs doing. +

+

{phased.map((u) => u.name).join(", ")}

+
+ )}
diff --git a/web/lib/api.ts b/web/lib/api.ts index 75060cd..9d4033d 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -5,6 +5,13 @@ export interface PackageUpdate { name: string; current_version?: string; new_version: string; + /** Ubuntu phased update this host is not yet selected for: apt defers it. */ + phased?: boolean; +} + +/** Updates an upgrade would install now. Phased updates are pending but deferred by apt. */ +export function installableUpdates(updates: PackageUpdate[] | undefined): PackageUpdate[] { + return (updates ?? []).filter((u) => !u.phased); } export interface Inventory {