diff --git a/src/lib/server/pending-updates-core.ts b/src/lib/server/pending-updates-core.ts index c7385ea..e23f8f0 100644 --- a/src/lib/server/pending-updates-core.ts +++ b/src/lib/server/pending-updates-core.ts @@ -58,3 +58,25 @@ export function pendingRowsToClear( return toClear; } + +/** + * Count pending rows that still map to a LIVE container (#1006). The dashboard tile + * used to show the raw row count, but a row is keyed on containerId, so a container + * recreated/removed OUT-OF-BAND (compose CLI, docker directly, Watchtower, image retag) + * leaves an orphan row nothing prunes between checks. The containers page already + * live-matches (a row shows only if its containerId exists), so the tile over-counted + * and disagreed with the list. Counting only rows whose containerId is a currently + * live container makes the tile agree with the list — no schema change, and it never + * hides a real update (a live container with a pending row is still counted). + */ +export function countLivePending( + pending: Array<{ containerId: string }>, + liveContainerIds: Iterable +): number { + const live = liveContainerIds instanceof Set ? liveContainerIds : new Set(liveContainerIds); + let n = 0; + for (const row of pending ?? []) { + if (row?.containerId && live.has(row.containerId)) n++; + } + return n; +} diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts index aa757b6..2e148f2 100644 --- a/src/routes/api/dashboard/stats/+server.ts +++ b/src/routes/api/dashboard/stats/+server.ts @@ -17,6 +17,7 @@ import { getDiskUsage } from '$lib/server/docker'; import { listComposeStacks } from '$lib/server/stacks'; +import { countLivePending } from '$lib/server/pending-updates-core'; import { authorize } from '$lib/server/authorize'; import { parseLabels } from '$lib/utils/label-colors'; @@ -281,7 +282,16 @@ export const GET: RequestHandler = async ({ cookies, url }) => { today: eventStats.today }; - envStats.containers.pendingUpdates = pendingUpdates.length; + // Count only pending rows that still map to a live container (#1006): + // an out-of-band recreate/removal leaves orphan rows nothing prunes, and + // the containers page already live-matches, so the raw count over-reported. + // GUARD: only live-match when we actually HAVE the container list. If + // listContainers timed out (`containers` is []) while there ARE pending + // rows, the list is untrustworthy — fall back to the raw count rather than + // hide real updates (over-count is annoying; under-count hides updates). + envStats.containers.pendingUpdates = containers.length > 0 + ? countLivePending(pendingUpdates, containers.map((c: any) => c.id)) + : pendingUpdates.length; } catch (error) { // Convert technical error messages to user-friendly ones diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index bdc2c0a..c1d82fa 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -20,6 +20,7 @@ import { DockerConnectionError } from '$lib/server/docker'; import { listComposeStacks } from '$lib/server/stacks'; +import { countLivePending } from '$lib/server/pending-updates-core'; import { authorize } from '$lib/server/authorize'; import { prefersJSON, sseToJSON } from '$lib/server/sse'; import type { EnvironmentStats } from '../+server'; @@ -220,6 +221,8 @@ async function getEnvironmentStatsProgressive( today: eventStats.today }; + // pendingUpdates is live-matched below once the container list is fetched (#1006); + // seed to the raw count so a container-list timeout still shows something. envStats.containers.pendingUpdates = pendingUpdates.length; if (recentEventsResult.events.length > 0) { @@ -309,7 +312,11 @@ async function getEnvironmentStatsProgressive( envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length; envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length; envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length; - // Note: pendingUpdates is already set from DB query, preserve it + // Refine the seeded raw count against the (now non-null) container list so + // the tile agrees with the containers page (#1006). `containers` is + // guaranteed real here (the null-guard above threw on a timeout), so no + // empty-list fallback needed - unlike the non-stream twin. + envStats.containers.pendingUpdates = countLivePending(pendingUpdates, containers.map((c: any) => c.id)); envStats.loading!.containers = false; onPartialUpdate({