Add a discoverable per-container "Update now" action, shown only when the
image status is `outdated`, plus a bulk "Update selected" action in the
containers list.
Both manual paths share ONE apply primitive (applyContainerUpdate /
useUpdateContainerImage) that also backs the future M4 auto-update job:
- standalone container -> recreate-with-pull (existing recreate endpoint)
- stack-managed -> stack redeploy-with-pull (existing git/file stack
update mutations), so the container stays in its
stack and is never recreated out-of-band
- externally-managed -> refused; the details button is disabled with an
compose explanatory tooltip and the bulk action skips it
Decision logic lives in the pure, unit-tested resolveContainerUpdatePath /
groupContainersForUpdate helpers. The bulk action filters to outdated
containers and redeploys each owning stack exactly once even when several of
its containers are selected, reporting per-item success/failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { Stack } from '@/react/common/stacks/types';
|
||||
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
|
||||
|
||||
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
|
||||
|
||||
function buildStack(overrides: Partial<Stack>): Stack {
|
||||
return {
|
||||
Id: 1,
|
||||
Name: 'my-stack',
|
||||
EndpointId: 3,
|
||||
...overrides,
|
||||
} as Stack;
|
||||
}
|
||||
|
||||
describe('resolveContainerUpdatePath', () => {
|
||||
it('returns standalone when there is no compose project label', () => {
|
||||
expect(
|
||||
resolveContainerUpdatePath({ labels: {}, environmentId: 3 }, [])
|
||||
).toEqual({ kind: 'standalone' });
|
||||
});
|
||||
|
||||
it('returns stack when the compose project matches a Portainer stack', () => {
|
||||
const stack = buildStack({ Id: 7, Name: 'my-stack', EndpointId: 3 });
|
||||
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[stack]
|
||||
)
|
||||
).toEqual({ kind: 'stack', stackId: 7, isGitStack: false });
|
||||
});
|
||||
|
||||
it('flags git stacks so they redeploy via the git path', () => {
|
||||
const stack = buildStack({
|
||||
Id: 9,
|
||||
Name: 'git-stack',
|
||||
EndpointId: 3,
|
||||
GitConfig: { URL: 'https://example.com/repo.git' } as Stack['GitConfig'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'git-stack' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[stack]
|
||||
)
|
||||
).toEqual({ kind: 'stack', stackId: 9, isGitStack: true });
|
||||
});
|
||||
|
||||
it('returns external when the compose project has no matching stack', () => {
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'unknown-project' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[buildStack({ Name: 'other' })]
|
||||
)
|
||||
).toEqual({ kind: 'external' });
|
||||
});
|
||||
|
||||
it('does not match a stack from a different endpoint', () => {
|
||||
const stack = buildStack({ Name: 'my-stack', EndpointId: 99 });
|
||||
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[stack]
|
||||
)
|
||||
).toEqual({ kind: 'external' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user