Skip to content

Commit 2b00c4b

Browse files
committed
🐛 fix(ui): restore standalone update state handling (#289)
1 parent 110aae3 commit 2b00c4b

File tree

5 files changed

+238
-3
lines changed

5 files changed

+238
-3
lines changed

ui/src/utils/container-update.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,78 @@
1+
import type { Container, ContainerUpdateOperation } from '../types/container';
12
import { isNoUpdateAvailableError } from './error';
23

34
export type ContainerUpdateRequestResult = 'accepted' | 'stale';
45

6+
type UpdateOperationSequenceLike = Pick<
7+
ContainerUpdateOperation,
8+
'status' | 'updatedAt' | 'batchId' | 'queuePosition' | 'queueTotal'
9+
>;
10+
11+
type UpdateOperationContainerLike = Pick<Container, 'id' | 'updateOperation'>;
12+
13+
function hasPersistedUpdateBatchSequence(operation?: UpdateOperationSequenceLike): boolean {
14+
return Boolean(
15+
operation?.batchId &&
16+
Number.isSafeInteger(operation.queuePosition) &&
17+
Number.isSafeInteger(operation.queueTotal) &&
18+
operation.queuePosition > 0 &&
19+
operation.queueTotal > 0 &&
20+
operation.queuePosition <= operation.queueTotal,
21+
);
22+
}
23+
24+
function isStandaloneQueuedUpdateOperation(operation?: UpdateOperationSequenceLike): boolean {
25+
return operation?.status === 'queued' && !hasPersistedUpdateBatchSequence(operation);
26+
}
27+
28+
function parseUpdateOperationTimestamp(updatedAt?: string): number {
29+
if (typeof updatedAt !== 'string') {
30+
return Number.POSITIVE_INFINITY;
31+
}
32+
33+
const parsed = Date.parse(updatedAt);
34+
return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
35+
}
36+
37+
export function shouldRenderStandaloneQueuedUpdateAsUpdating(args: {
38+
containers: readonly UpdateOperationContainerLike[];
39+
operation?: UpdateOperationSequenceLike;
40+
targetId?: string;
41+
}): boolean {
42+
if (!isStandaloneQueuedUpdateOperation(args.operation)) {
43+
return false;
44+
}
45+
46+
const hasActivePredecessor = args.containers.some(
47+
(container) =>
48+
container.id !== args.targetId && container.updateOperation?.status === 'in-progress',
49+
);
50+
if (hasActivePredecessor) {
51+
return false;
52+
}
53+
54+
let headId: string | undefined;
55+
let headTimestamp = Number.POSITIVE_INFINITY;
56+
57+
for (const container of args.containers) {
58+
if (!isStandaloneQueuedUpdateOperation(container.updateOperation)) {
59+
continue;
60+
}
61+
62+
const candidateTimestamp = parseUpdateOperationTimestamp(container.updateOperation?.updatedAt);
63+
if (candidateTimestamp < headTimestamp) {
64+
headTimestamp = candidateTimestamp;
65+
headId = container.id;
66+
}
67+
}
68+
69+
if (!headId) {
70+
return true;
71+
}
72+
73+
return headId === args.targetId;
74+
}
75+
576
export function createContainerUpdateBatchId(): string {
677
const randomUUID = globalThis.crypto?.randomUUID;
778
return typeof randomUUID === 'function'

ui/src/views/containers/useContainerActions.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
getForceContainerUpdateStartedMessage,
2323
isStaleContainerUpdateError,
2424
runContainerUpdateRequest,
25+
shouldRenderStandaloneQueuedUpdateAsUpdating,
2526
} from '../../utils/container-update';
2627
import { errorMessage } from '../../utils/error';
2728
import { useContainerBackups } from './useContainerBackups';
@@ -1108,6 +1109,7 @@ export function useContainerActions(input: UseContainerActionsInput) {
11081109
);
11091110
if (typeof target !== 'string') {
11101111
const freshContainer = input.containers.value.find((c) => c.id === target.id);
1112+
const liveOperation = freshContainer?.updateOperation ?? target.updateOperation;
11111113
const sequence = groupUpdateSequence.value.get(target.id);
11121114
const isGroupQueueHead =
11131115
sequence !== undefined &&
@@ -1125,6 +1127,15 @@ export function useContainerActions(input: UseContainerActionsInput) {
11251127
if (persistedSequence) {
11261128
return persistedSequence.isHead;
11271129
}
1130+
if (
1131+
shouldRenderStandaloneQueuedUpdateAsUpdating({
1132+
containers: input.containers.value,
1133+
operation: liveOperation,
1134+
targetId: target.id,
1135+
})
1136+
) {
1137+
return true;
1138+
}
11281139
if (
11291140
target.updateOperation?.status === 'queued' ||
11301141
freshContainer?.updateOperation?.status === 'queued' ||
@@ -1146,6 +1157,7 @@ export function useContainerActions(input: UseContainerActionsInput) {
11461157
return false;
11471158
}
11481159
const freshContainer = input.containers.value.find((container) => container.id === target.id);
1160+
const liveOperation = freshContainer?.updateOperation ?? target.updateOperation;
11491161
const sequence = groupUpdateSequence.value.get(target.id);
11501162
const isGroupQueueHead =
11511163
sequence !== undefined &&
@@ -1162,6 +1174,15 @@ export function useContainerActions(input: UseContainerActionsInput) {
11621174
if (persistedSequence) {
11631175
return !persistedSequence.isHead;
11641176
}
1177+
if (
1178+
shouldRenderStandaloneQueuedUpdateAsUpdating({
1179+
containers: input.containers.value,
1180+
operation: liveOperation,
1181+
targetId: target.id,
1182+
})
1183+
) {
1184+
return false;
1185+
}
11651186
if (
11661187
target.updateOperation?.status === 'queued' ||
11671188
freshContainer?.updateOperation?.status === 'queued'

ui/src/views/dashboard/useDashboardComputed.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type ComputedRef, computed, type Ref } from 'vue';
22
import { ROUTES } from '../../router/routes';
33
import type { Container } from '../../types/container';
4+
import { shouldRenderStandaloneQueuedUpdateAsUpdating } from '../../utils/container-update';
45
import {
56
buildDashboardContainerMetrics,
67
type ImageSecurityAggregate,
@@ -171,13 +172,20 @@ function getUpdateKindIcon(kind: UpdateKind | null): string {
171172

172173
function deriveRecentUpdateStatus(
173174
container: Container,
175+
containers: readonly Container[],
174176
recentStatusByContainer: Record<string, RecentAuditStatus>,
175177
): RecentUpdateRow['status'] {
176178
if (container.updateOperation?.status === 'in-progress') {
177179
return 'updating';
178180
}
179181
if (container.updateOperation?.status === 'queued') {
180-
return 'queued';
182+
return shouldRenderStandaloneQueuedUpdateAsUpdating({
183+
containers,
184+
operation: container.updateOperation,
185+
targetId: container.id,
186+
})
187+
? 'updating'
188+
: 'queued';
181189
}
182190
if (container.updatePolicyState === 'snoozed') {
183191
return 'snoozed';
@@ -571,6 +579,7 @@ function isPendingRecentUpdateContainer(container: Container): boolean {
571579

572580
function toPendingRecentUpdateCandidate(
573581
container: Container,
582+
containers: readonly Container[],
574583
recentStatusByContainer: Record<string, RecentAuditStatus>,
575584
blocked: boolean,
576585
): PendingRecentUpdateCandidate {
@@ -588,7 +597,7 @@ function toPendingRecentUpdateCandidate(
588597
oldVer: deriveCurrentVersion(container),
589598
newVer: deriveRecentUpdateVersion(container),
590599
releaseLink: container.releaseLink,
591-
status: deriveRecentUpdateStatus(container, recentStatusByContainer),
600+
status: deriveRecentUpdateStatus(container, containers, recentStatusByContainer),
592601
updateKind: container.updateKind ?? null,
593602
running: container.status === 'running',
594603
registryError: undefined,
@@ -620,6 +629,7 @@ function buildRecentUpdateRows(
620629
candidates.push(
621630
toPendingRecentUpdateCandidate(
622631
container,
632+
containers,
623633
recentStatusByContainer,
624634
container.bouncer === 'blocked',
625635
),

ui/tests/views/containers/useContainerActions.spec.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2651,7 +2651,7 @@ describe('useContainerActions', () => {
26512651
expect(composable.getContainerUpdateSequenceLabel(proxyB)).toBe('2 of 2');
26522652
});
26532653

2654-
it('treats queued update operations as queued before any persisted batch head is selected', async () => {
2654+
it('treats a standalone queued update operation as updating when no other active update exists', async () => {
26552655
const queued = makeContainer({
26562656
id: 'container-a',
26572657
name: 'socket-proxy-a',
@@ -2666,10 +2666,70 @@ describe('useContainerActions', () => {
26662666
containers: [queued],
26672667
});
26682668

2669+
expect(composable.isContainerUpdateQueued(queued)).toBe(false);
2670+
expect(composable.isContainerUpdateInProgress(queued)).toBe(true);
2671+
});
2672+
2673+
it('keeps a standalone queued update operation queued when another container is already updating', async () => {
2674+
const updating = makeContainer({
2675+
id: 'container-head',
2676+
name: 'socket-proxy-head',
2677+
updateOperation: {
2678+
id: 'op-head',
2679+
status: 'in-progress',
2680+
phase: 'pulling',
2681+
updatedAt: '2026-04-01T12:00:00.000Z',
2682+
},
2683+
});
2684+
const queued = makeContainer({
2685+
id: 'container-a',
2686+
name: 'socket-proxy-a',
2687+
updateOperation: {
2688+
id: 'op-a',
2689+
status: 'queued',
2690+
phase: 'queued',
2691+
updatedAt: '2026-04-01T12:00:00.000Z',
2692+
},
2693+
});
2694+
const { composable } = await mountActionsHarness({
2695+
containers: [updating, queued],
2696+
});
2697+
26692698
expect(composable.isContainerUpdateQueued(queued)).toBe(true);
26702699
expect(composable.isContainerUpdateInProgress(queued)).toBe(false);
26712700
});
26722701

2702+
it('treats only the oldest standalone queued update operation as updating before any active update exists', async () => {
2703+
const queuedHead = makeContainer({
2704+
id: 'container-head',
2705+
name: 'socket-proxy-head',
2706+
updateOperation: {
2707+
id: 'op-head',
2708+
status: 'queued',
2709+
phase: 'queued',
2710+
updatedAt: '2026-04-01T12:00:00.000Z',
2711+
},
2712+
});
2713+
const queuedTail = makeContainer({
2714+
id: 'container-tail',
2715+
name: 'socket-proxy-tail',
2716+
updateOperation: {
2717+
id: 'op-tail',
2718+
status: 'queued',
2719+
phase: 'queued',
2720+
updatedAt: '2026-04-01T12:00:01.000Z',
2721+
},
2722+
});
2723+
const { composable } = await mountActionsHarness({
2724+
containers: [queuedTail, queuedHead],
2725+
});
2726+
2727+
expect(composable.isContainerUpdateQueued(queuedHead)).toBe(false);
2728+
expect(composable.isContainerUpdateInProgress(queuedHead)).toBe(true);
2729+
expect(composable.isContainerUpdateQueued(queuedTail)).toBe(true);
2730+
expect(composable.isContainerUpdateInProgress(queuedTail)).toBe(false);
2731+
});
2732+
26732733
it('returns null for string targets when resolving update sequence labels', async () => {
26742734
const { composable } = await mountActionsHarness({});
26752735

ui/tests/views/dashboard/useDashboardComputed.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,79 @@ describe('useDashboardComputed recent updates', () => {
10391039
]);
10401040
});
10411041

1042+
it('treats a standalone queued update operation as updating when no other active update exists', () => {
1043+
const state = createState({
1044+
containers: [
1045+
makeBaseContainer({
1046+
id: 'queued-standalone',
1047+
name: 'queued-standalone',
1048+
newTag: null,
1049+
updateOperation: {
1050+
id: 'op-queued-standalone',
1051+
status: 'queued',
1052+
phase: 'queued',
1053+
updatedAt: '2026-04-04T10:00:00.000Z',
1054+
fromVersion: '1.0.0',
1055+
toVersion: '1.1.0',
1056+
},
1057+
}),
1058+
],
1059+
});
1060+
1061+
expect(state.recentUpdates.value).toEqual([
1062+
expect.objectContaining({
1063+
name: 'queued-standalone',
1064+
status: 'updating',
1065+
}),
1066+
]);
1067+
});
1068+
1069+
it('keeps a standalone queued update operation queued when another container is already updating', () => {
1070+
const state = createState({
1071+
containers: [
1072+
makeBaseContainer({
1073+
id: 'updating-head',
1074+
name: 'updating-head',
1075+
newTag: null,
1076+
updateOperation: {
1077+
id: 'op-updating-head',
1078+
status: 'in-progress',
1079+
phase: 'pulling',
1080+
updatedAt: '2026-04-04T10:00:00.000Z',
1081+
fromVersion: '1.0.0',
1082+
toVersion: '1.1.0',
1083+
},
1084+
}),
1085+
makeBaseContainer({
1086+
id: 'queued-tail',
1087+
name: 'queued-tail',
1088+
newTag: null,
1089+
updateOperation: {
1090+
id: 'op-queued-tail',
1091+
status: 'queued',
1092+
phase: 'queued',
1093+
updatedAt: '2026-04-04T10:00:01.000Z',
1094+
fromVersion: '2.0.0',
1095+
toVersion: '2.1.0',
1096+
},
1097+
}),
1098+
],
1099+
});
1100+
1101+
expect(state.recentUpdates.value).toEqual(
1102+
expect.arrayContaining([
1103+
expect.objectContaining({
1104+
name: 'updating-head',
1105+
status: 'updating',
1106+
}),
1107+
expect.objectContaining({
1108+
name: 'queued-tail',
1109+
status: 'queued',
1110+
}),
1111+
]),
1112+
);
1113+
});
1114+
10421115
it('maps mature-only suppressed updates to maturity-blocked status', () => {
10431116
const state = createState({
10441117
containers: [

0 commit comments

Comments
 (0)