e63d2ffe9b
Clicking "Update" on a stack member (and the native auto-update daemon updating one) redeployed the whole compose stack instead of updating just that container. Match Watchtower behaviour: always recreate the single container with a re-pull. The recreate endpoint preserves config + compose labels, so the container stays part of its project. Collapse all update surfaces to a single-container recreate and drop the now-dead stack-aware routing: - frontend: "Update now" button, list badge and bulk "Update selected" now recreate each container individually; remove standalone/stack/external routing, the external refusal, the PortainerStackUpdate gate and the stack-update confirm dialog. - daemon: route every outdated candidate through updateStandalone; remove updateStack, the stack/external grouping and the stackDeployer dependency. - add a regression test asserting a Portainer-managed compose-stack member is recreated individually, not stack-redeployed. Behavioural notes: git/external compose containers are now auto-updated too (were detect-only), and updating a stack member no longer requires PortainerStackUpdate (same auth as the normal Recreate action). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
3.4 KiB
TypeScript
129 lines
3.4 KiB
TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
import {
|
|
notifyError,
|
|
notifySuccess,
|
|
notifyWarning,
|
|
} from '@/portainer/services/notifications';
|
|
|
|
import {
|
|
getContainerImageStatus,
|
|
STALE_TIME,
|
|
} from '../queries/useContainerImageStatus';
|
|
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
|
|
|
|
import { applyContainerUpdate } from './applyContainerUpdate';
|
|
import { invalidateContainerUpdateQueries } from './useUpdateContainerImage';
|
|
import { ContainerUpdateContext } from './types';
|
|
|
|
interface BulkUpdateParams {
|
|
contexts: ContainerUpdateContext[];
|
|
}
|
|
|
|
/**
|
|
* Bulk "Update selected": recreates each selected container that is `outdated`
|
|
* with a fresh image pull (Watchtower-style, one recreate per container), and
|
|
* skips up-to-date/unknown ones with a summary. Reports per-item success/failure.
|
|
*/
|
|
export function useBulkUpdateContainerImages() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: ({ contexts }: BulkUpdateParams) =>
|
|
bulkUpdate(queryClient, contexts),
|
|
});
|
|
}
|
|
|
|
async function bulkUpdate(
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
contexts: ContainerUpdateContext[]
|
|
) {
|
|
// Resolve each container's status (cached where the badge already loaded it).
|
|
const statuses = await Promise.all(
|
|
contexts.map((context) =>
|
|
queryClient
|
|
.fetchQuery(
|
|
containerQueryKeys.imageStatus(
|
|
context.environmentId,
|
|
context.id,
|
|
context.nodeName
|
|
),
|
|
() =>
|
|
getContainerImageStatus(
|
|
context.environmentId,
|
|
context.id,
|
|
context.nodeName
|
|
),
|
|
{ staleTime: STALE_TIME }
|
|
)
|
|
.then((status) => status.Status)
|
|
.catch(() => 'error' as const)
|
|
)
|
|
);
|
|
|
|
const outdated = contexts.filter((_c, i) => statuses[i] === 'outdated');
|
|
const skippedNotOutdated = contexts.length - outdated.length;
|
|
|
|
// Nothing actionable: make the no-op explicit instead of a vague "skipped".
|
|
if (outdated.length === 0) {
|
|
notifyWarning(
|
|
'Nothing to update',
|
|
'None of the selected containers have updates available.'
|
|
);
|
|
return { containersUpdated: 0, failures: [], skipped: 0 };
|
|
}
|
|
|
|
let containersUpdated = 0;
|
|
const failures: string[] = [];
|
|
|
|
// Recreate each outdated container individually (Watchtower-style).
|
|
await runSequential(outdated, async (context) => {
|
|
try {
|
|
await applyContainerUpdate(context);
|
|
invalidateContainerUpdateQueries(queryClient, context);
|
|
containersUpdated += 1;
|
|
} catch (err) {
|
|
failures.push(context.name);
|
|
notifyError('Failure', err as Error, `Unable to update ${context.name}`);
|
|
}
|
|
});
|
|
|
|
if (containersUpdated > 0) {
|
|
notifySuccess(
|
|
'Success',
|
|
`${containersUpdated} ${pluralize(
|
|
containersUpdated,
|
|
'container'
|
|
)} updated`
|
|
);
|
|
}
|
|
|
|
if (skippedNotOutdated > 0) {
|
|
notifyWarning(
|
|
'Some containers were skipped',
|
|
`${skippedNotOutdated} not outdated`
|
|
);
|
|
}
|
|
|
|
return {
|
|
containersUpdated,
|
|
failures,
|
|
skipped: skippedNotOutdated,
|
|
};
|
|
}
|
|
|
|
async function runSequential<T>(
|
|
items: T[],
|
|
fn: (item: T) => Promise<void>
|
|
): Promise<void> {
|
|
// Sequential to avoid hammering registries with parallel pulls.
|
|
await items.reduce(
|
|
(chain, item) => chain.then(() => fn(item)),
|
|
Promise.resolve()
|
|
);
|
|
}
|
|
|
|
function pluralize(count: number, word: string) {
|
|
return count === 1 ? word : `${word}s`;
|
|
}
|