Skip to content

Commit ea413c3

Browse files
committed
✨ feat(ui): identity-keyed container state tracking across dashboard and container views
- Add identityKey to Container type and map from API response - Use identity keys for dashboard update reconciliation and pruning - Resolve recent status by identity key when duplicate container names exist - Scope standalone queued updates to yield to persisted batch heads - Key dashboard update sequence by container id for duplicate-name correctness
1 parent 923e092 commit ea413c3

18 files changed

+486
-66
lines changed

ui/src/services/container.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type ContainerRecentStatus = 'updated' | 'pending' | 'failed';
3232

3333
interface ContainerRecentStatusResponse {
3434
statuses: Record<string, ContainerRecentStatus>;
35+
statusesByIdentity: Record<string, ContainerRecentStatus>;
3536
}
3637

3738
interface GetAllContainersOptions {

ui/src/types/container.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface ContainerUpdateOperation {
5353

5454
export interface Container {
5555
id: string;
56+
identityKey: string;
5657
name: string;
5758
image: string;
5859
icon: string;

ui/src/utils/container-action-key.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ interface ContainerActionKeyInput {
22
id?: unknown;
33
name?: unknown;
44
server?: unknown;
5+
identityKey?: unknown;
6+
agent?: unknown;
7+
watcher?: unknown;
58
}
69

710
function asNonEmptyString(value: unknown): string | undefined {
@@ -16,15 +19,24 @@ export function getContainerActionKey(container: ContainerActionKeyInput): strin
1619
return asNonEmptyString(container.id) ?? asNonEmptyString(container.name) ?? '';
1720
}
1821

19-
export function getContainerActionIdentityKey(container: ContainerActionKeyInput): string {
20-
const server = asNonEmptyString(container.server);
21-
const name = asNonEmptyString(container.name);
22+
export function buildContainerIdentityKey(container: ContainerActionKeyInput): string {
23+
const explicitIdentityKey = asNonEmptyString(container.identityKey);
24+
if (explicitIdentityKey) {
25+
return explicitIdentityKey;
26+
}
2227

23-
if (server && name) {
24-
return `${server}::${name}`;
28+
const watcher = asNonEmptyString(container.watcher);
29+
const name = asNonEmptyString(container.name);
30+
if (watcher && name) {
31+
const agent = asNonEmptyString(container.agent) ?? '';
32+
return `${agent}::${watcher}::${name}`;
2533
}
2634

27-
return getContainerActionKey(container);
35+
return '';
36+
}
37+
38+
export function getContainerActionIdentityKey(container: ContainerActionKeyInput): string {
39+
return buildContainerIdentityKey(container) || getContainerActionKey(container);
2840
}
2941

3042
export function hasTrackedContainerAction(

ui/src/utils/container-mapper.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
type ContainerUpdateOperationStatus,
3131
} from '../types/update-operation';
3232
import { normalizeSeverityCount } from '../views/security/securityViewUtils';
33+
import { buildContainerIdentityKey } from './container-action-key';
3334
import {
3435
maturityMinAgeDaysToMilliseconds,
3536
normalizeMaturityMode,
@@ -647,6 +648,7 @@ export function mapApiContainer(apiContainer: ApiContainerInput): Container {
647648

648649
return {
649650
id,
651+
identityKey: buildContainerIdentityKey(apiContainer) || id || name,
650652
name: displayName ?? name,
651653
image: imageName,
652654
icon: getEffectiveDisplayIcon(displayIcon, imageName),

ui/src/utils/container-update.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,38 @@ function isStandaloneQueuedUpdateOperation(operation?: UpdateOperationSequenceLi
2525
return operation?.status === 'queued' && !hasPersistedUpdateBatchSequence(operation);
2626
}
2727

28+
function getPersistedBatchHeadIds(
29+
containers: readonly UpdateOperationContainerLike[],
30+
): Set<string> {
31+
const queuedHeads = new Map<string, { id: string; position: number }>();
32+
const inProgressHeads = new Map<string, { id: string; position: number }>();
33+
34+
for (const container of containers) {
35+
const operation = container.updateOperation;
36+
if (!hasPersistedUpdateBatchSequence(operation) || !operation?.batchId) {
37+
continue;
38+
}
39+
40+
const targetHeads = operation.status === 'in-progress' ? inProgressHeads : queuedHeads;
41+
const currentHead = targetHeads.get(operation.batchId);
42+
if (!currentHead || operation.queuePosition! < currentHead.position) {
43+
targetHeads.set(operation.batchId, {
44+
id: container.id,
45+
position: operation.queuePosition!,
46+
});
47+
}
48+
}
49+
50+
const headIds = new Set<string>();
51+
for (const [batchId, queuedHead] of queuedHeads.entries()) {
52+
headIds.add((inProgressHeads.get(batchId) ?? queuedHead).id);
53+
}
54+
for (const inProgressHead of inProgressHeads.values()) {
55+
headIds.add(inProgressHead.id);
56+
}
57+
return headIds;
58+
}
59+
2860
function parseUpdateOperationTimestamp(updatedAt?: string): number {
2961
if (typeof updatedAt !== 'string') {
3062
return Number.POSITIVE_INFINITY;
@@ -38,11 +70,16 @@ export function shouldRenderStandaloneQueuedUpdateAsUpdating(args: {
3870
containers: readonly UpdateOperationContainerLike[];
3971
operation?: UpdateOperationSequenceLike;
4072
targetId?: string;
73+
hasExternalActiveHead?: boolean;
4174
}): boolean {
4275
if (!isStandaloneQueuedUpdateOperation(args.operation)) {
4376
return false;
4477
}
4578

79+
if (args.hasExternalActiveHead === true) {
80+
return false;
81+
}
82+
4683
const hasActivePredecessor = args.containers.some(
4784
(container) =>
4885
container.id !== args.targetId && container.updateOperation?.status === 'in-progress',
@@ -51,6 +88,11 @@ export function shouldRenderStandaloneQueuedUpdateAsUpdating(args: {
5188
return false;
5289
}
5390

91+
const persistedBatchHeadIds = getPersistedBatchHeadIds(args.containers);
92+
if ([...persistedBatchHeadIds].some((id) => id !== args.targetId)) {
93+
return false;
94+
}
95+
5496
let headId: string | undefined;
5597
let headTimestamp = Number.POSITIVE_INFINITY;
5698

ui/src/views/DashboardView.vue

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ const {
156156
loading,
157157
maintenanceCountdownNow,
158158
recentStatusByContainer,
159+
recentStatusByIdentity,
159160
registries,
160161
serverInfo,
161162
watchers,
@@ -190,6 +191,7 @@ const {
190191
hidePinned: computed(() => preferences.containers.filters.hidePinned),
191192
maintenanceCountdownNow,
192193
recentStatusByContainer,
194+
recentStatusByIdentity,
193195
registries,
194196
serverInfo,
195197
watchers,
@@ -200,22 +202,38 @@ const pendingUpdates = computed(() =>
200202
(row) =>
201203
row.status === 'pending' &&
202204
!row.blocked &&
203-
!dashboardUpdateSequence.value.has(getDashboardRecentUpdateRowKey(row)),
205+
!dashboardUpdateSequence.value.has(getDashboardRecentUpdateSequenceKey(row)),
204206
),
205207
);
206208
207209
const displayRecentUpdates = computed<RecentUpdateRow[]>(() => {
208-
const liveRowKeys = new Set(
209-
recentUpdates.value.map((row) => getDashboardRecentUpdateRowKey(row)),
210+
const liveRowIdentityKeys = new Set(
211+
recentUpdates.value.map((row) => getDashboardRecentUpdateReconciliationKey(row)),
210212
);
211213
const ghosts = [...dashboardPendingUpdateRows.value.values()]
212-
.filter(({ row }) => !liveRowKeys.has(getDashboardRecentUpdateRowKey(row)))
214+
.filter(({ row }) => !liveRowIdentityKeys.has(getDashboardRecentUpdateReconciliationKey(row)))
213215
.map(({ row }) => row);
214216
return [...recentUpdates.value, ...ghosts];
215217
});
216218
217-
function getDashboardRecentUpdateRowKey(row: Pick<RecentUpdateRow, 'id' | 'name'>): string {
218-
return row.id || row.name;
219+
function getDashboardRecentUpdateSequenceKey(
220+
row: Pick<RecentUpdateRow, 'id' | 'identityKey' | 'name'>,
221+
): string {
222+
return row.id || row.identityKey || row.name;
223+
}
224+
225+
function getDashboardRecentUpdateReconciliationKey(
226+
row: Pick<RecentUpdateRow, 'identityKey' | 'id' | 'name'>,
227+
): string {
228+
return row.identityKey || row.id || row.name;
229+
}
230+
231+
function getDashboardContainerReconciliationKey(container: {
232+
identityKey?: string;
233+
id?: string;
234+
name?: string;
235+
}): string {
236+
return container.identityKey || container.id || container.name || '';
219237
}
220238
221239
function stopDashboardPendingUpdatePolling() {
@@ -233,9 +251,9 @@ function hasDashboardTrackedUpdates() {
233251
}
234252
235253
function getVisibleDashboardTrackedUpdateKeys() {
236-
const keys = new Set(recentUpdates.value.map((row) => getDashboardRecentUpdateRowKey(row)));
237-
for (const key of dashboardPendingUpdateRows.value.keys()) {
238-
keys.add(key);
254+
const keys = new Set(recentUpdates.value.map((row) => getDashboardRecentUpdateSequenceKey(row)));
255+
for (const { row } of dashboardPendingUpdateRows.value.values()) {
256+
keys.add(getDashboardRecentUpdateSequenceKey(row));
239257
}
240258
return keys;
241259
}
@@ -284,12 +302,14 @@ function clearDashboardPendingUpdateRow(key: string) {
284302
}
285303
286304
function pruneDashboardPendingUpdateRows(now: number = Date.now()) {
287-
const liveContainerKeys = new Set(
288-
containers.value.map((container) => container.id || container.name),
305+
const liveContainerIdentityKeys = new Set(
306+
containers.value
307+
.map((container) => getDashboardContainerReconciliationKey(container))
308+
.filter((key) => key.length > 0),
289309
);
290310
for (const [key, pendingRow] of dashboardPendingUpdateRows.value.entries()) {
291311
if (
292-
liveContainerKeys.has(key) ||
312+
liveContainerIdentityKeys.has(key) ||
293313
now - pendingRow.startedAt > DASHBOARD_PENDING_UPDATE_TIMEOUT_MS
294314
) {
295315
clearDashboardPendingUpdateRow(key);
@@ -335,12 +355,14 @@ function startDashboardPendingUpdatePolling() {
335355
}
336356
337357
function capturePendingDashboardRows(rows: RecentUpdateRow[]) {
338-
const liveContainerKeys = new Set(
339-
containers.value.map((container) => container.id || container.name),
358+
const liveContainerIdentityKeys = new Set(
359+
containers.value
360+
.map((container) => getDashboardContainerReconciliationKey(container))
361+
.filter((key) => key.length > 0),
340362
);
341363
for (const row of rows) {
342-
const key = getDashboardRecentUpdateRowKey(row);
343-
if (liveContainerKeys.has(key)) {
364+
const key = getDashboardRecentUpdateReconciliationKey(row);
365+
if (!key || liveContainerIdentityKeys.has(key)) {
344366
continue;
345367
}
346368
const existing = dashboardPendingUpdateRows.value.get(key);
@@ -453,7 +475,9 @@ function confirmDashboardUpdateAll() {
453475
const pendingRowsSnapshot = pendingUpdates.value.filter((row) => !row.blocked);
454476
dashboardUpdateAllInProgress.value = true;
455477
dashboardUpdateError.value = null;
456-
const snapshotRowKeys = pendingRowsSnapshot.map((row) => getDashboardRecentUpdateRowKey(row));
478+
const snapshotRowKeys = pendingRowsSnapshot.map((row) =>
479+
getDashboardRecentUpdateSequenceKey(row),
480+
);
457481
const batchId = createContainerUpdateBatchId();
458482
const queueTotal = pendingRowsSnapshot.length;
459483
let acceptedRowKeys = [...snapshotRowKeys];
@@ -479,12 +503,12 @@ function confirmDashboardUpdateAll() {
479503
successfulRows.push(row);
480504
continue;
481505
}
482-
const rowKey = getDashboardRecentUpdateRowKey(row);
506+
const rowKey = getDashboardRecentUpdateSequenceKey(row);
483507
acceptedRowKeys = acceptedRowKeys.filter((key) => key !== rowKey);
484508
syncDashboardUpdateSequenceValue(snapshotRowKeys, acceptedRowKeys);
485509
staleRows.push(row);
486510
} catch (e: unknown) {
487-
const rowKey = getDashboardRecentUpdateRowKey(row);
511+
const rowKey = getDashboardRecentUpdateSequenceKey(row);
488512
acceptedRowKeys = acceptedRowKeys.filter((key) => key !== rowKey);
489513
syncDashboardUpdateSequenceValue(snapshotRowKeys, acceptedRowKeys);
490514
if (!firstRejectedUpdate) {

ui/src/views/containers/useContainerActions.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ interface ContainerActionGroup {
3939
containers: Container[];
4040
}
4141

42-
type ContainerActionTarget = string | Pick<Container, 'id' | 'name' | 'server' | 'updateOperation'>;
42+
type ContainerActionTarget =
43+
| string
44+
| Pick<Container, 'id' | 'name' | 'identityKey' | 'updateOperation'>;
4345

4446
interface UseContainerActionsInput {
4547
activeDetailTab: Readonly<Ref<string>>;
@@ -334,6 +336,21 @@ function getGroupUpdateHeadPosition(
334336
return headPosition;
335337
}
336338

339+
function hasOtherLocalGroupQueueHead(
340+
groupUpdateSequence: Readonly<Ref<Map<string, GroupUpdateSequenceEntry>>>,
341+
targetId?: string,
342+
) {
343+
for (const [containerId, sequence] of groupUpdateSequence.value.entries()) {
344+
if (containerId === targetId) {
345+
continue;
346+
}
347+
if (getGroupUpdateHeadPosition(groupUpdateSequence, sequence.groupKey) === sequence.position) {
348+
return true;
349+
}
350+
}
351+
return false;
352+
}
353+
337354
function getPersistedUpdateBatchSequence(
338355
operation?: Pick<
339356
NonNullable<Container['updateOperation']>,
@@ -448,6 +465,7 @@ async function updateAllInGroupState(args: {
448465
});
449466
const frozenUpdateTargets = updatableContainers.map((container) => ({
450467
id: container.id,
468+
identityKey: container.identityKey,
451469
name: container.name,
452470
}));
453471
if (frozenUpdateTargets.length === 0) {
@@ -1155,6 +1173,7 @@ export function useContainerActions(input: UseContainerActionsInput) {
11551173
const isGroupQueueHead =
11561174
sequence !== undefined &&
11571175
getGroupUpdateHeadPosition(groupUpdateSequence, sequence.groupKey) === sequence.position;
1176+
const otherLocalGroupQueueHead = hasOtherLocalGroupQueueHead(groupUpdateSequence, target.id);
11581177
if (
11591178
target.updateOperation?.status === 'in-progress' ||
11601179
freshContainer?.updateOperation?.status === 'in-progress'
@@ -1171,6 +1190,7 @@ export function useContainerActions(input: UseContainerActionsInput) {
11711190
if (
11721191
shouldRenderStandaloneQueuedUpdateAsUpdating({
11731192
containers: input.containers.value,
1193+
hasExternalActiveHead: otherLocalGroupQueueHead,
11741194
operation: liveOperation,
11751195
targetId: target.id,
11761196
})
@@ -1203,6 +1223,7 @@ export function useContainerActions(input: UseContainerActionsInput) {
12031223
const isGroupQueueHead =
12041224
sequence !== undefined &&
12051225
getGroupUpdateHeadPosition(groupUpdateSequence, sequence.groupKey) === sequence.position;
1226+
const otherLocalGroupQueueHead = hasOtherLocalGroupQueueHead(groupUpdateSequence, target.id);
12061227
if (
12071228
target.updateOperation?.status === 'in-progress' ||
12081229
freshContainer?.updateOperation?.status === 'in-progress' ||
@@ -1218,6 +1239,7 @@ export function useContainerActions(input: UseContainerActionsInput) {
12181239
if (
12191240
shouldRenderStandaloneQueuedUpdateAsUpdating({
12201241
containers: input.containers.value,
1242+
hasExternalActiveHead: otherLocalGroupQueueHead,
12211243
operation: liveOperation,
12221244
targetId: target.id,
12231245
})

ui/src/views/dashboard/components/DashboardRecentUpdatesWidget.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,15 @@ const isDashboardBulkUpdateLocked = computed(
122122
hasPersistedBackendQueue.value,
123123
);
124124
125-
function getRowSequence(row: Record<string, unknown>) {
125+
function getDashboardRecentUpdateRowKey(row: Record<string, unknown>) {
126+
const id = row.id as string | undefined;
126127
const name = row.name as string | undefined;
127-
return name ? props.dashboardUpdateSequence.get(name) : undefined;
128+
return id || name;
129+
}
130+
131+
function getRowSequence(row: Record<string, unknown>) {
132+
const key = getDashboardRecentUpdateRowKey(row);
133+
return key ? props.dashboardUpdateSequence.get(key) : undefined;
128134
}
129135
130136
function getRowUpdateState(row: Record<string, unknown>): 'queued' | 'updating' | null {

ui/src/views/dashboard/dashboardTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export interface DashboardStatCard {
134134

135135
export interface RecentUpdateRow {
136136
id: string;
137+
identityKey: string;
137138
name: string;
138139
image: string;
139140
icon: string;

0 commit comments

Comments
 (0)