Skip to content

Commit 6615ccb

Browse files
committed
✨ feat(containers): add container action operations and dashboard updates
1 parent 5880b4c commit 6615ccb

File tree

18 files changed

+630
-26
lines changed

18 files changed

+630
-26
lines changed

app/api/container-actions.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,42 @@ describe('Container Actions Router', () => {
491491
expect(contractValidation.errors).toStrictEqual([]);
492492
});
493493

494+
test('should persist provided batch queue metadata on accepted updates', async () => {
495+
const container = {
496+
id: 'c1',
497+
name: 'nginx',
498+
image: { name: 'nginx' },
499+
updateAvailable: true,
500+
};
501+
mockGetContainer.mockReturnValue(container);
502+
const mockTriggerFn = vi.fn().mockResolvedValue(undefined);
503+
const trigger = { type: 'docker', trigger: mockTriggerFn };
504+
mockGetState.mockReturnValue({ trigger: { 'docker.default': trigger } });
505+
const updateOperationStore = await import('../store/update-operation');
506+
507+
const handler = getHandler('post', '/:id/update');
508+
const req = createMockRequest({
509+
params: { id: 'c1' },
510+
body: {
511+
batchId: 'batch-1',
512+
queuePosition: 2,
513+
queueTotal: 4,
514+
},
515+
});
516+
const res = createMockResponse();
517+
await handler(req, res);
518+
519+
expect(updateOperationStore.insertOperation).toHaveBeenCalledWith(
520+
expect.objectContaining({
521+
batchId: 'batch-1',
522+
queuePosition: 2,
523+
queueTotal: 4,
524+
status: 'queued',
525+
phase: 'queued',
526+
}),
527+
);
528+
});
529+
494530
test('should accept update immediately with a dockercompose trigger', async () => {
495531
const container = {
496532
id: 'c1',

app/api/container-actions.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,45 @@ type DockerWatcher = {
4848
};
4949
};
5050

51+
type UpdateQueueBatchMetadata = {
52+
batchId: string;
53+
queuePosition: number;
54+
queueTotal: number;
55+
};
56+
57+
function parsePositiveInteger(value: unknown): number | undefined {
58+
if (typeof value === 'number') {
59+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
60+
}
61+
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
62+
return undefined;
63+
}
64+
65+
const parsed = Number.parseInt(value, 10);
66+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
67+
}
68+
69+
function parseUpdateQueueBatchMetadata(body: unknown): UpdateQueueBatchMetadata | undefined {
70+
if (!body || typeof body !== 'object') {
71+
return undefined;
72+
}
73+
74+
const candidate = body as Record<string, unknown>;
75+
const batchId = typeof candidate.batchId === 'string' ? candidate.batchId.trim() : '';
76+
const queuePosition = parsePositiveInteger(candidate.queuePosition);
77+
const queueTotal = parsePositiveInteger(candidate.queueTotal);
78+
79+
if (!batchId || !queuePosition || !queueTotal || queuePosition > queueTotal) {
80+
return undefined;
81+
}
82+
83+
return {
84+
batchId,
85+
queuePosition,
86+
queueTotal,
87+
};
88+
}
89+
5190
function clearManualUpdateDetectionState(id: string) {
5291
const containerAfterTrigger = storeContainer.getContainer(id);
5392
if (
@@ -227,6 +266,7 @@ async function updateContainer(req: Request, res: Response) {
227266
}
228267

229268
const operationId = crypto.randomUUID();
269+
const batchMetadata = parseUpdateQueueBatchMetadata(req.body);
230270
getContainerActionsCounter()?.inc({ action: 'container-update' });
231271

232272
updateOperationStore.insertOperation({
@@ -235,6 +275,7 @@ async function updateContainer(req: Request, res: Response) {
235275
containerName: container.name,
236276
status: 'queued',
237277
phase: 'queued',
278+
...batchMetadata,
238279
});
239280

240281
void (async () => {

app/api/container/crud.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2379,6 +2379,9 @@ describe('api/container/crud', () => {
23792379
updatedAt: '2026-04-01T12:00:00.000Z',
23802380
fromVersion: '1.0.0',
23812381
toVersion: '1.1.0',
2382+
batchId: 'batch-1',
2383+
queuePosition: 1,
2384+
queueTotal: 3,
23822385
});
23832386

23842387
const listRes = callGetContainers(harness.handlers);
@@ -2397,6 +2400,9 @@ describe('api/container/crud', () => {
23972400
phase: 'old-stopped',
23982401
fromVersion: '1.0.0',
23992402
toVersion: '1.1.0',
2403+
batchId: 'batch-1',
2404+
queuePosition: 1,
2405+
queueTotal: 3,
24002406
}),
24012407
}),
24022408
],
@@ -2408,6 +2414,9 @@ describe('api/container/crud', () => {
24082414
id: 'op-1',
24092415
status: 'in-progress',
24102416
phase: 'old-stopped',
2417+
batchId: 'batch-1',
2418+
queuePosition: 1,
2419+
queueTotal: 3,
24112420
}),
24122421
}),
24132422
);

app/api/container/handlers/list.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,32 @@ describe('attachInProgressUpdateOperation', () => {
9696
});
9797
});
9898

99+
test('keeps valid batch queue metadata from active operations', () => {
100+
const container = createContainer();
101+
const context = createMockContext({
102+
id: 'op-1',
103+
status: 'queued',
104+
phase: 'queued',
105+
updatedAt: '2026-04-01T12:00:00.000Z',
106+
batchId: 'batch-1',
107+
queuePosition: 2,
108+
queueTotal: 4,
109+
});
110+
111+
expect(attachInProgressUpdateOperation(context, container)).toEqual({
112+
...container,
113+
updateOperation: {
114+
id: 'op-1',
115+
status: 'queued',
116+
phase: 'queued',
117+
updatedAt: '2026-04-01T12:00:00.000Z',
118+
batchId: 'batch-1',
119+
queuePosition: 2,
120+
queueTotal: 4,
121+
},
122+
});
123+
});
124+
99125
test('prefers container-ID lookup over name-based lookup', () => {
100126
const container = createContainer({ id: 'c1', name: 'portainer_agent' });
101127
const byIdResult = {

app/api/container/handlers/list.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ import { parseBooleanQueryParam } from '../request-helpers.js';
2626

2727
export type ContainerListBasePath = '/api/containers' | '/api/containers/watch';
2828

29+
function parsePositiveInteger(value: unknown): number | undefined {
30+
if (typeof value === 'number') {
31+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
32+
}
33+
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
34+
return undefined;
35+
}
36+
37+
const parsed = Number.parseInt(value, 10);
38+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
39+
}
40+
2941
function stripContainerVulnerabilityArrays(container: Container): Container {
3042
if (!container.security) {
3143
return container;
@@ -63,6 +75,9 @@ function sanitizeInProgressUpdateOperation(
6375
const status = isContainerUpdateOperationStatus(candidate.status) ? candidate.status : undefined;
6476
const phase = isContainerUpdateOperationPhase(candidate.phase) ? candidate.phase : undefined;
6577
const updatedAt = typeof candidate.updatedAt === 'string' ? candidate.updatedAt : undefined;
78+
const batchId = typeof candidate.batchId === 'string' ? candidate.batchId : undefined;
79+
const queuePosition = parsePositiveInteger(candidate.queuePosition);
80+
const queueTotal = parsePositiveInteger(candidate.queueTotal);
6681

6782
if (!id || !status || !phase || !updatedAt) {
6883
return undefined;
@@ -76,6 +91,13 @@ function sanitizeInProgressUpdateOperation(
7691
...(typeof candidate.fromVersion === 'string' ? { fromVersion: candidate.fromVersion } : {}),
7792
...(typeof candidate.toVersion === 'string' ? { toVersion: candidate.toVersion } : {}),
7893
...(typeof candidate.targetImage === 'string' ? { targetImage: candidate.targetImage } : {}),
94+
...(batchId && queuePosition && queueTotal && queuePosition <= queueTotal
95+
? {
96+
batchId,
97+
queuePosition,
98+
queueTotal,
99+
}
100+
: {}),
79101
};
80102
}
81103

app/model/container.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ export interface ContainerUpdateOperationState {
128128
status: ContainerUpdateOperationStatus;
129129
phase: ContainerUpdateOperationPhase;
130130
updatedAt: string;
131+
batchId?: string;
132+
queuePosition?: number;
133+
queueTotal?: number;
131134
fromVersion?: string;
132135
toVersion?: string;
133136
targetImage?: string;

ui/src/services/container-actions.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
export interface UpdateContainerBatchMetadata {
2+
batchId?: string;
3+
queuePosition?: number;
4+
queueTotal?: number;
5+
}
6+
7+
function hasValidUpdateContainerBatchMetadata(
8+
metadata?: UpdateContainerBatchMetadata,
9+
): metadata is {
10+
batchId: string;
11+
queuePosition: number;
12+
queueTotal: number;
13+
} {
14+
return (
15+
!!metadata?.batchId &&
16+
Number.isSafeInteger(metadata.queuePosition) &&
17+
metadata.queuePosition > 0 &&
18+
Number.isSafeInteger(metadata.queueTotal) &&
19+
metadata.queueTotal > 0 &&
20+
metadata.queuePosition <= metadata.queueTotal
21+
);
22+
}
23+
124
async function startContainer(containerId: string) {
225
const response = await fetch(`/api/v1/containers/${containerId}/start`, {
326
method: 'POST',
@@ -34,10 +57,18 @@ async function restartContainer(containerId: string) {
3457
return response.json();
3558
}
3659

37-
async function updateContainer(containerId: string) {
60+
async function updateContainer(containerId: string, metadata?: UpdateContainerBatchMetadata) {
3861
const response = await fetch(`/api/v1/containers/${containerId}/update`, {
3962
method: 'POST',
4063
credentials: 'include',
64+
...(hasValidUpdateContainerBatchMetadata(metadata)
65+
? {
66+
headers: {
67+
'Content-Type': 'application/json',
68+
},
69+
body: JSON.stringify(metadata),
70+
}
71+
: {}),
4172
});
4273
if (!response.ok) {
4374
const body = await response.json().catch(() => ({}));

ui/src/types/api.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export interface ApiContainerUpdateOperation {
7272
phase: ApiContainerUpdateOperationPhase;
7373
createdAt: string;
7474
updatedAt: string;
75+
batchId?: string;
76+
queuePosition?: number;
77+
queueTotal?: number;
7578
containerId?: string;
7679
containerName?: string;
7780
triggerName?: string;

ui/src/types/container.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export interface ContainerUpdateOperation {
4343
status: ContainerUpdateOperationStatus;
4444
phase: ContainerUpdateOperationPhase;
4545
updatedAt: string;
46+
batchId?: string;
47+
queuePosition?: number;
48+
queueTotal?: number;
4649
fromVersion?: string;
4750
toVersion?: string;
4851
targetImage?: string;

ui/src/utils/container-mapper.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ function asNonEmptyString(value: unknown): string | undefined {
162162
return trimmed.length > 0 ? trimmed : undefined;
163163
}
164164

165+
function asPositiveInteger(value: unknown): number | undefined {
166+
if (typeof value === 'number') {
167+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
168+
}
169+
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
170+
return undefined;
171+
}
172+
173+
const parsed = Number.parseInt(value, 10);
174+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
175+
}
176+
165177
function asOptionalBoolean(value: unknown): boolean | undefined {
166178
return typeof value === 'boolean' ? value : undefined;
167179
}
@@ -590,6 +602,9 @@ function deriveUpdateOperation(
590602
const status = asContainerUpdateOperationStatus(operation.status);
591603
const phase = asContainerUpdateOperationPhase(operation.phase);
592604
const updatedAt = asNonEmptyString(operation.updatedAt);
605+
const batchId = asNonEmptyString(operation.batchId);
606+
const queuePosition = asPositiveInteger(operation.queuePosition);
607+
const queueTotal = asPositiveInteger(operation.queueTotal);
593608

594609
if (!id || !status || !phase || !updatedAt) {
595610
return undefined;
@@ -609,6 +624,9 @@ function deriveUpdateOperation(
609624
...(asNonEmptyString(operation.targetImage)
610625
? { targetImage: asNonEmptyString(operation.targetImage) }
611626
: {}),
627+
...(batchId && queuePosition && queueTotal && queuePosition <= queueTotal
628+
? { batchId, queuePosition, queueTotal }
629+
: {}),
612630
};
613631
}
614632

0 commit comments

Comments
 (0)