feat: show Ubuntu phased updates apart and leave them out of pending counts
Chart Release / chart (push) Successful in 29s
Server Deploy / deploy (push) Successful in 5m9s

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.
This commit is contained in:
2026-09-15 14:55:30 +00:00
parent 0e464c4bb8
commit fbcf436ef6
13 changed files with 87 additions and 15 deletions
+2 -2
View File
@@ -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;
+3 -2
View File
@@ -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<DotStatus, number> = {
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";
}
+19 -5
View File
@@ -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({
<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>
<div className="flex flex-wrap items-center gap-2">
{updates.length > 0 ? <Badge variant="warning">{updates.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
{installable.length > 0 ? <Badge variant="warning">{installable.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
{phased.length > 0 && <Badge variant="neutral">{phased.length} phased</Badge>}
{/* Sits with the updates panel because that is what
caused it. The agent never reboots a host itself. */}
{server.inventory?.reboot_required && <Badge variant="warning">reboot required</Badge>}
@@ -88,7 +92,7 @@ export function MaintenanceTab({
)}
</div>
{updates.length === 0 ? (
{installable.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">
{isWindows ? "No pending Windows updates. The agent checks hourly." : "No pending package updates. The agent checks hourly."}
</p>
@@ -106,7 +110,7 @@ export function MaintenanceTab({
</Tr>
</Thead>
<Tbody>
{updates.map((u) => (
{installable.map((u) => (
<Tr key={u.name}>
<Td label={isWindows ? "Update" : "Package"}>
<span className="font-mono text-sm font-medium">{u.name}</span>
@@ -131,6 +135,16 @@ export function MaintenanceTab({
</div>
</>
)}
{phased.length > 0 && (
<div className="border-t border-border px-6 py-4">
<p className="text-sm text-text-secondary">
<span className="text-text-primary">{phased.length} deferred by Ubuntu phasing.</span> 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.
</p>
<p className="mt-2 font-mono text-xs text-text-tertiary">{phased.map((u) => u.name).join(", ")}</p>
</div>
)}
</Card>
<div className="space-y-6">
+7
View File
@@ -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 {