Skip to content

Commit 05c023f

Browse files
committed
πŸ› fix(ui): scope pending update state by stable container identity (#256)
1 parent c81e25a commit 05c023f

File tree

6 files changed

+229
-48
lines changed

6 files changed

+229
-48
lines changed

β€Žui/src/utils/container-action-key.tsβ€Ž

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
interface ContainerActionKeyInput {
22
id?: unknown;
33
name?: unknown;
4+
server?: unknown;
45
}
56

67
function asNonEmptyString(value: unknown): string | undefined {
@@ -15,6 +16,17 @@ export function getContainerActionKey(container: ContainerActionKeyInput): strin
1516
return asNonEmptyString(container.id) ?? asNonEmptyString(container.name) ?? '';
1617
}
1718

19+
export function getContainerActionIdentityKey(container: ContainerActionKeyInput): string {
20+
const server = asNonEmptyString(container.server);
21+
const name = asNonEmptyString(container.name);
22+
23+
if (server && name) {
24+
return `${server}::${name}`;
25+
}
26+
27+
return getContainerActionKey(container);
28+
}
29+
1830
export function hasTrackedContainerAction(
1931
trackedActions: Pick<Set<string>, 'has'>,
2032
container: ContainerActionKeyInput,

β€Žui/src/views/ContainersView.vueβ€Ž

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useViewMode } from '../preferences/useViewMode';
1616
import type { ContainerGroup } from '../services/container';
1717
import { getAllContainers, getContainerGroups, refreshAllContainers } from '../services/container';
1818
import type { Container } from '../types/container';
19+
import { getContainerActionIdentityKey } from '../utils/container-action-key';
1920
import { mapApiContainers } from '../utils/container-mapper';
2021
import {
2122
maturityColor,
@@ -766,10 +767,12 @@ const displayContainers = computed(() => {
766767
}
767768
: container,
768769
);
769-
const liveNames = new Set(live.map((container) => container.name));
770-
const ghosts = [...actionPending.value.entries()]
771-
.filter(([name]) => !liveNames.has(name))
772-
.map(([, snapshot]) => ({ ...snapshot, _pending: true as const }));
770+
const liveIdentityKeys = new Set(
771+
live.map((container) => getContainerActionIdentityKey(container)).filter(Boolean),
772+
);
773+
const ghosts = [...actionPending.value.values()]
774+
.filter((snapshot) => !liveIdentityKeys.has(getContainerActionIdentityKey(snapshot)))
775+
.map((snapshot) => ({ ...snapshot, _pending: true as const }));
773776
return [...live, ...ghosts];
774777
});
775778

β€Žui/src/views/containers/useContainerActions.tsβ€Ž

Lines changed: 84 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import {
1313
updateContainer as apiUpdateContainer,
1414
} from '../../services/container-actions';
1515
import type { Container } from '../../types/container';
16-
import { getContainerActionKey, hasTrackedContainerAction } from '../../utils/container-action-key';
16+
import {
17+
getContainerActionIdentityKey,
18+
getContainerActionKey,
19+
hasTrackedContainerAction,
20+
} from '../../utils/container-action-key';
1721
import {
1822
createContainerUpdateBatchId,
1923
formatContainerUpdateStartedCountMessage,
@@ -35,7 +39,7 @@ interface ContainerActionGroup {
3539
containers: Container[];
3640
}
3741

38-
type ContainerActionTarget = string | Pick<Container, 'id' | 'name' | 'updateOperation'>;
42+
type ContainerActionTarget = string | Pick<Container, 'id' | 'name' | 'server' | 'updateOperation'>;
3943

4044
interface UseContainerActionsInput {
4145
activeDetailTab: Readonly<Ref<string>>;
@@ -91,54 +95,73 @@ function hasPendingContainerAction(
9195
target: ContainerActionTarget,
9296
actionPending: Readonly<Ref<Map<string, Container>>>,
9397
) {
94-
const name = typeof target === 'string' ? target : target.name;
95-
return typeof name === 'string' && name.length > 0 && actionPending.value.has(name);
98+
const pendingKey = resolveContainerActionTargetKey(target);
99+
if (pendingKey && actionPending.value.has(pendingKey)) {
100+
return true;
101+
}
102+
103+
const identityKey = typeof target === 'string' ? target : getContainerActionIdentityKey(target);
104+
if (!identityKey) {
105+
return false;
106+
}
107+
108+
return [...actionPending.value.values()].some((snapshot) => {
109+
return typeof target === 'string'
110+
? snapshot.name === identityKey
111+
: getContainerActionIdentityKey(snapshot) === identityKey;
112+
});
96113
}
97114

98115
function hasInProgressUpdateOperation(
99116
target: ContainerActionTarget,
100117
containers: Readonly<Ref<Container[]>>,
101118
) {
102119
const targetId = typeof target === 'string' ? undefined : target.id;
103-
const targetName = typeof target === 'string' ? target : target.name;
120+
const targetIdentityKey =
121+
typeof target === 'string' ? target : getContainerActionIdentityKey(target);
104122

105123
return containers.value.some((container) => {
106-
const matches = targetId ? container.id === targetId : container.name === targetName;
107-
return matches && container.updateOperation?.status === 'in-progress';
124+
const matchesId = Boolean(targetId && container.id === targetId);
125+
const matchesIdentity =
126+
typeof target === 'string'
127+
? container.name === targetIdentityKey
128+
: getContainerActionIdentityKey(container) === targetIdentityKey;
129+
return (matchesId || matchesIdentity) && container.updateOperation?.status === 'in-progress';
108130
});
109131
}
110132

111133
function markPendingActionState(args: {
112134
actionPending: Ref<Map<string, Container>>;
113135
actionPendingLifecycleModes: Ref<Map<string, PendingActionLifecycleMode>>;
114136
actionPendingLifecycleObserved: Ref<Set<string>>;
115-
startPolling: (name: string) => void;
116-
name: string;
137+
startPolling: (pendingKey: string) => void;
138+
pendingKey: string;
117139
snapshot?: Container;
118140
mode: PendingActionLifecycleMode;
119141
}) {
120142
if (!args.snapshot) {
121143
return;
122144
}
123145

124-
args.actionPending.value.set(args.name, args.snapshot);
125-
args.actionPendingLifecycleModes.value.set(args.name, args.mode);
146+
args.actionPending.value.set(args.pendingKey, args.snapshot);
147+
args.actionPendingLifecycleModes.value.set(args.pendingKey, args.mode);
126148

127149
const nextObserved = new Set(args.actionPendingLifecycleObserved.value);
128150
if (args.mode === 'update') {
129-
nextObserved.delete(args.name);
151+
nextObserved.delete(args.pendingKey);
130152
} else {
131-
nextObserved.add(args.name);
153+
nextObserved.add(args.pendingKey);
132154
}
133155
args.actionPendingLifecycleObserved.value = nextObserved;
134-
args.startPolling(args.name);
156+
args.startPolling(args.pendingKey);
135157
}
136158

137159
async function executeContainerActionState(args: {
138160
containerActionsEnabled: boolean;
139161
containerActionsDisabledReason: string;
140162
containerId?: string;
141163
actionKey: string;
164+
pendingKey: string;
142165
name: string;
143166
actionInProgress: Ref<Set<string>>;
144167
inputError: Ref<string | null>;
@@ -149,7 +172,7 @@ async function executeContainerActionState(args: {
149172
actionPending: Ref<Map<string, Container>>;
150173
actionPendingLifecycleModes: Ref<Map<string, PendingActionLifecycleMode>>;
151174
actionPendingLifecycleObserved: Ref<Set<string>>;
152-
startPolling: (name: string) => void;
175+
startPolling: (pendingKey: string) => void;
153176
selectedContainerId: string | undefined;
154177
activeDetailTab: string;
155178
refreshActionTabData: () => Promise<void>;
@@ -184,7 +207,7 @@ async function executeContainerActionState(args: {
184207
actionPendingLifecycleModes: args.actionPendingLifecycleModes,
185208
actionPendingLifecycleObserved: args.actionPendingLifecycleObserved,
186209
startPolling: args.startPolling,
187-
name: args.name,
210+
pendingKey: args.pendingKey,
188211
snapshot,
189212
mode: 'update',
190213
});
@@ -200,7 +223,7 @@ async function executeContainerActionState(args: {
200223
actionPendingLifecycleModes: args.actionPendingLifecycleModes,
201224
actionPendingLifecycleObserved: args.actionPendingLifecycleObserved,
202225
startPolling: args.startPolling,
203-
name: args.name,
226+
pendingKey: args.pendingKey,
204227
snapshot,
205228
mode: args.pendingLifecycleMode ?? 'presence',
206229
});
@@ -372,7 +395,10 @@ function getPersistedTargetSequence(
372395
) {
373396
const liveContainer =
374397
containers.find((container) => container.id === target.id) ??
375-
containers.find((container) => container.name === target.name);
398+
containers.find(
399+
(container) =>
400+
getContainerActionIdentityKey(container) === getContainerActionIdentityKey(target),
401+
);
376402
const sequence = getPersistedUpdateBatchSequence(
377403
liveContainer?.updateOperation ?? target.updateOperation,
378404
);
@@ -570,25 +596,25 @@ function clearPendingActionState(args: {
570596
actionPendingLifecycleObserved: Ref<Set<string>>;
571597
groupUpdateQueue: Ref<Set<string>>;
572598
groupUpdateSequence: Ref<Map<string, GroupUpdateSequenceEntry>>;
573-
name: string;
599+
pendingKey: string;
574600
}) {
575-
const snapshot = args.actionPending.value.get(args.name);
601+
const snapshot = args.actionPending.value.get(args.pendingKey);
576602
if (snapshot?.id) {
577603
setGroupUpdateQueueValue(args.groupUpdateQueue, [snapshot.id], false);
578604
const nextSequence = new Map(args.groupUpdateSequence.value);
579605
nextSequence.delete(snapshot.id);
580606
args.groupUpdateSequence.value = nextSequence;
581607
}
582-
args.actionPending.value.delete(args.name);
583-
args.actionPendingStartTimes.value.delete(args.name);
584-
args.actionPendingLifecycleModes.value.delete(args.name);
608+
args.actionPending.value.delete(args.pendingKey);
609+
args.actionPendingStartTimes.value.delete(args.pendingKey);
610+
args.actionPendingLifecycleModes.value.delete(args.pendingKey);
585611
const nextObserved = new Set(args.actionPendingLifecycleObserved.value);
586-
nextObserved.delete(args.name);
612+
nextObserved.delete(args.pendingKey);
587613
args.actionPendingLifecycleObserved.value = nextObserved;
588614
}
589615

590616
export function isPendingUpdateSettled(args: {
591-
name: string;
617+
pendingKey: string;
592618
now: number;
593619
startTime: number;
594620
liveContainer?: Container;
@@ -603,7 +629,7 @@ export function isPendingUpdateSettled(args: {
603629
args.liveContainer.status !== expectedStatus;
604630
if (observedLifecycleSignal) {
605631
const nextObserved = new Set(args.actionPendingLifecycleObserved.value);
606-
nextObserved.add(args.name);
632+
nextObserved.add(args.pendingKey);
607633
args.actionPendingLifecycleObserved.value = nextObserved;
608634
}
609635

@@ -623,7 +649,7 @@ export function isPendingUpdateSettled(args: {
623649
}
624650

625651
return (
626-
args.actionPendingLifecycleObserved.value.has(args.name) ||
652+
args.actionPendingLifecycleObserved.value.has(args.pendingKey) ||
627653
args.now - args.startTime >= PENDING_UPDATE_GRACE_MS
628654
);
629655
}
@@ -640,21 +666,36 @@ function prunePendingActionsState(args: {
640666
pollTimeout: number;
641667
stopPendingActionsPolling: () => void;
642668
}) {
643-
const liveContainersByName = new Map(
644-
args.containers.value.map((container) => [container.name, container] as const),
645-
);
646-
for (const [name, startTime] of args.actionPendingStartTimes.value.entries()) {
647-
const liveContainer = liveContainersByName.get(name);
648-
const pendingMode = args.actionPendingLifecycleModes.value.get(name);
669+
const liveContainersByActionKey = new Map<string, Container>();
670+
const liveContainersByIdentityKey = new Map<string, Container>();
671+
672+
for (const container of args.containers.value) {
673+
const actionKey = getContainerActionKey(container);
674+
if (actionKey) {
675+
liveContainersByActionKey.set(actionKey, container);
676+
}
677+
const identityKey = getContainerActionIdentityKey(container);
678+
if (identityKey) {
679+
liveContainersByIdentityKey.set(identityKey, container);
680+
}
681+
}
682+
683+
for (const [pendingKey, startTime] of args.actionPendingStartTimes.value.entries()) {
684+
const snapshot = args.actionPending.value.get(pendingKey);
685+
const snapshotIdentityKey = snapshot ? getContainerActionIdentityKey(snapshot) : '';
686+
const liveContainer =
687+
liveContainersByActionKey.get(pendingKey) ??
688+
(snapshotIdentityKey ? liveContainersByIdentityKey.get(snapshotIdentityKey) : undefined);
689+
const pendingMode = args.actionPendingLifecycleModes.value.get(pendingKey);
649690
const timedOut = args.now - startTime > args.pollTimeout;
650691
const settled =
651692
pendingMode === 'update'
652693
? isPendingUpdateSettled({
653-
name,
694+
pendingKey,
654695
now: args.now,
655696
startTime,
656697
liveContainer,
657-
snapshot: args.actionPending.value.get(name),
698+
snapshot,
658699
actionPendingLifecycleObserved: args.actionPendingLifecycleObserved,
659700
})
660701
: Boolean(liveContainer);
@@ -667,7 +708,7 @@ function prunePendingActionsState(args: {
667708
actionPendingLifecycleObserved: args.actionPendingLifecycleObserved,
668709
groupUpdateQueue: args.groupUpdateQueue,
669710
groupUpdateSequence: args.groupUpdateSequence,
670-
name,
711+
pendingKey,
671712
});
672713
}
673714
}
@@ -694,14 +735,14 @@ async function pollPendingActionsState(args: {
694735
}
695736

696737
function startPollingState(args: {
697-
name: string;
738+
pendingKey: string;
698739
actionPendingStartTimes: Ref<Map<string, number>>;
699740
pendingActionsPollTimer: Ref<ReturnType<typeof setInterval> | null>;
700741
pollInterval: number;
701742
pollPendingActions: () => Promise<void>;
702743
}) {
703-
if (!args.actionPendingStartTimes.value.has(args.name)) {
704-
args.actionPendingStartTimes.value.set(args.name, Date.now());
744+
if (!args.actionPendingStartTimes.value.has(args.pendingKey)) {
745+
args.actionPendingStartTimes.value.set(args.pendingKey, Date.now());
705746
}
706747
if (args.pendingActionsPollTimer.value) {
707748
return;
@@ -1077,9 +1118,9 @@ export function useContainerActions(input: UseContainerActionsInput) {
10771118
});
10781119
}
10791120

1080-
function startPolling(name: string) {
1121+
function startPolling(pendingKey: string) {
10811122
startPollingState({
1082-
name,
1123+
pendingKey,
10831124
actionPendingStartTimes,
10841125
pendingActionsPollTimer,
10851126
pollInterval: PENDING_ACTIONS_POLL_INTERVAL_MS,
@@ -1223,11 +1264,13 @@ export function useContainerActions(input: UseContainerActionsInput) {
12231264
options?.containerId,
12241265
);
12251266
const actionKey = containerId ?? resolveContainerActionTargetKey(target);
1267+
const pendingKey = resolveContainerActionTargetKey(target);
12261268
return executeContainerActionState({
12271269
containerActionsEnabled: containerActionsEnabled.value,
12281270
containerActionsDisabledReason: containerActionsDisabledReason.value,
12291271
containerId,
12301272
actionKey,
1273+
pendingKey,
12311274
name,
12321275
actionInProgress,
12331276
inputError: input.error,

β€Žui/tests/utils/container-action-key.spec.tsβ€Ž

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
getContainerActionIdentityKey,
23
getContainerActionKey,
34
hasTrackedContainerAction,
45
} from '../../src/utils/container-action-key';
@@ -55,3 +56,24 @@ describe('hasTrackedContainerAction', () => {
5556
);
5657
});
5758
});
59+
60+
describe('getContainerActionIdentityKey', () => {
61+
test('uses server and name so replacement containers keep the same identity', () => {
62+
expect(
63+
getContainerActionIdentityKey({
64+
id: 'host1-abc',
65+
name: 'portainer_agent',
66+
server: 'Datavault',
67+
}),
68+
).toBe('Datavault::portainer_agent');
69+
});
70+
71+
test('falls back to the action key when server is unavailable', () => {
72+
expect(
73+
getContainerActionIdentityKey({
74+
id: 'host1-abc',
75+
name: 'portainer_agent',
76+
}),
77+
).toBe('host1-abc');
78+
});
79+
});

0 commit comments

Comments
Β (0)