Complete Docker container lifecycle surfaces

Refs #1034
This commit is contained in:
rcourtman 2026-06-12 22:16:11 +01:00
parent 8b3ed322e7
commit 620f1c44de
14 changed files with 279 additions and 12 deletions

View file

@ -294,7 +294,11 @@ network, mount, update, governed lifecycle actions, and host/runtime columns
rather than embedding `WorkloadsSurface`. The lifecycle action column is a
compact icon-button primitive over unified-resource capabilities and the shared
resource-action API client; it must not grow Docker/Podman shell, SSH, or
provider calls inside the table component. Swarm services must surface the
provider calls inside the table component. The same governed lifecycle controls
may appear in the resource detail header, while the platform table and drawer
shells remain presentation owners only: they may pass a post-success refresh
callback to their existing resource query, but must not own execution,
approval, policy, or provider dispatch. Swarm services must surface the
API-reported rollout/update
state in the native services table, and engine storage rows must expose a
stable row hook so platform-page browser proof can verify the storage tab is
@ -2615,7 +2619,7 @@ owns `PlatformResourceDetailToggleButton`, which composes
label, `aria-expanded`, `aria-controls`, and propagation containment. Platform
tables that use `createPlatformResourceDetailState` or render local inline
detail rows must compose that toggle; they may still own the detail row content,
drawer payload, and platform-specific fields.
drawer payload, post-success refresh callbacks, and platform-specific fields.
The shared help icon now follows that same owner split.
`frontend-modern/src/components/shared/HelpIcon.tsx` stays the render shell,
`frontend-modern/src/components/shared/useHelpIconState.ts` owns open state,

View file

@ -5170,7 +5170,10 @@
"frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts",
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
@ -5337,7 +5340,10 @@
"frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts",
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
@ -5411,6 +5417,8 @@
"frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/features/docker/__tests__/DockerAlertsTable.test.tsx",
"frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts",
"frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts",
"frontend-modern/src/features/docker/__tests__/DockerHostsTable.test.tsx",
"frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx",
"frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts",

View file

@ -1242,7 +1242,14 @@ Docker / Podman container lifecycle controls in
they may enable start/stop/restart only from backend-advertised resource
capabilities and must use `sourceStatus`, `docker.agentId`, `docker.runtime`,
and `docker.security` only for disabled-state explanation, never as a
feature-local execution bypass.
feature-local execution bypass. The same controls may render in
`DockerContainersTable.tsx` and the canonical `ResourceDetailDrawer.tsx` header
only for Docker-source app containers backed by Docker or Podman runtime
metadata; other app-container sources such as TrueNAS must not inherit Docker
lifecycle controls from runtime-shaped metadata alone. After a governed request
completes, the owning surface may ask its existing `useUnifiedResources` query
to refetch the resource snapshot, but refresh is not an alternate execution,
verification, or provider-control path.
`InfrastructureSummary.tsx` and `infrastructureSummaryModel.ts` now surface
`degraded` and `alerting` resource counts alongside the existing `online` and

View file

@ -8,6 +8,7 @@ import { ActionAuditAPI } from '@/api/actionAudit';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { apiFetchJSON } from '@/utils/apiClient';
import type {
ActionAuditRecord,
ActionDecisionResponse,
ActionExecutionResponse,
ResourceActionRequest,
@ -257,10 +258,37 @@ describe('ActionAuditAPI', () => {
});
it('records decisions and executes actions through encoded governed action routes', async () => {
const audit: ActionAuditRecord = {
id: 'action/docker/restart',
createdAt: '2026-06-12T20:39:00Z',
updatedAt: '2026-06-12T20:40:00Z',
state: 'approved',
request: {
requestId: 'req-docker-restart',
resourceId: 'docker:container:abc123',
capabilityName: 'docker.container.restart',
reason: 'operator confirmed restart',
requestedBy: 'operator',
},
plan: {
actionId: 'action/docker/restart',
requestId: 'req-docker-restart',
allowed: true,
requiresApproval: true,
approvalPolicy: 'admin',
rollbackAvailable: false,
},
};
const decision: ActionDecisionResponse = {
actionId: 'action/docker/restart',
outcome: 'approved',
decidedAt: '2026-06-12T20:40:00Z',
state: 'approved',
approval: {
actor: 'operator',
method: 'api',
timestamp: '2026-06-12T20:40:00Z',
outcome: 'approved',
},
audit,
};
const execution: ActionExecutionResponse = {
actionId: 'action/docker/restart',
@ -269,6 +297,15 @@ describe('ActionAuditAPI', () => {
success: true,
output: 'container restarted',
},
audit: {
...audit,
updatedAt: '2026-06-12T20:41:00Z',
state: 'completed',
result: {
success: true,
output: 'container restarted',
},
},
};
apiFetchJSONMock.mockResolvedValueOnce(decision).mockResolvedValueOnce(execution);

View file

@ -24,6 +24,8 @@ import {
DEFAULT_RESOURCE_DETAIL_DRAWER_PRESENTATION,
type ResourceDetailDrawerPresentation,
} from './resourceDetailDrawerPresentation';
import { DockerContainerLifecycleControls } from '@/features/docker/DockerContainerLifecycleControls';
import { isDockerContainerLifecycleResource } from '@/features/docker/dockerContainerLifecycleActions';
interface ResourceDetailDrawerProps {
resource: Resource;
@ -32,6 +34,7 @@ interface ResourceDetailDrawerProps {
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean;
initialShowTrueNASDetails?: boolean;
onResourceActionSettled?: () => void | Promise<void>;
}
const TabAvailabilityNotice: Component<{ message: string }> = (props) => (
@ -106,6 +109,13 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
</div>
<div class="flex shrink-0 items-center gap-1.5">
<Show when={isDockerContainerLifecycleResource(props.resource)}>
<DockerContainerLifecycleControls
resource={props.resource}
surface="resource-detail"
onActionSettled={props.onResourceActionSettled}
/>
</Show>
<Show when={drawer.assistantAvailable()}>
<button
type="button"
@ -353,6 +363,7 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext}
initialShowTrueNASDetails={props.initialShowTrueNASDetails}
onResourceActionSettled={props.onResourceActionSettled}
/>
);
};

View file

@ -1,5 +1,6 @@
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ResourceActionsAPI } from '@/api/resourceActions';
import type { Resource } from '@/types/resource';
import { ResourceDetailDrawer } from '../ResourceDetailDrawer';
@ -32,6 +33,37 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
),
}));
vi.mock('@/api/resourceActions', () => ({
ResourceActionsAPI: {
planAction: vi.fn().mockResolvedValue({
actionId: 'detail-action-1',
requestId: 'detail-request-1',
allowed: true,
requiresApproval: true,
approvalPolicy: 'admin',
rollbackAvailable: false,
}),
decideAction: vi.fn().mockResolvedValue({
actionId: 'detail-action-1',
outcome: 'approved',
decidedAt: '2026-06-12T20:00:01Z',
}),
executeAction: vi.fn().mockResolvedValue({
actionId: 'detail-action-1',
state: 'completed',
result: { success: true },
}),
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
const resource = (overrides: Partial<Resource>): Resource =>
({
id: overrides.id ?? 'app-container-1',
@ -52,6 +84,70 @@ afterEach(() => {
});
describe('ResourceDetailDrawer for Docker containers', () => {
it('renders governed lifecycle controls in Docker container details', async () => {
const onResourceActionSettled = vi.fn();
render(() => (
<ResourceDetailDrawer
presentation="table-row"
onResourceActionSettled={onResourceActionSettled}
resource={resource({
id: 'app-container-web',
name: 'edge-web',
docker: {
agentId: 'agent-edge',
containerId: 'abc123def456',
containerState: 'running',
runtime: 'docker',
image: 'ghcr.io/example/edge-web:2026.05',
},
capabilities: [
{
name: 'restart',
type: 'common',
platform: 'docker',
minimumApprovalLevel: 'admin',
},
],
sourceStatus: { docker: { status: 'online' } },
})}
/>
));
const restartButton = screen.getByRole('button', {
name: 'Restart edge-web through governed action',
});
expect(restartButton.closest('[data-docker-container-actions-surface]')).toHaveAttribute(
'data-docker-container-actions-surface',
'resource-detail',
);
fireEvent.click(restartButton);
fireEvent.click(screen.getByRole('button', { name: 'Click again to restart edge-web' }));
await waitFor(() =>
expect(ResourceActionsAPI.planAction).toHaveBeenCalledWith(
expect.objectContaining({
resourceId: 'app-container-web',
capabilityName: 'restart',
requestedBy: 'ui:resource-detail',
}),
),
);
await waitFor(() =>
expect(ResourceActionsAPI.executeAction).toHaveBeenCalledWith(
'detail-action-1',
expect.stringContaining('from the resource details'),
),
);
expect(ResourceActionsAPI.decideAction).toHaveBeenCalledWith(
'detail-action-1',
'approved',
expect.stringContaining('restart Docker container edge-web'),
);
await waitFor(() => expect(onResourceActionSettled).toHaveBeenCalledTimes(1));
});
it('adds a metrics history tab for app-containers with a metrics target', async () => {
render(() => (
<ResourceDetailDrawer

View file

@ -2904,6 +2904,13 @@ describe('shared primitive guardrails', () => {
);
expect(platformResourceDetailTableRowSource).toContain('subjectLabel={`details for');
expect(platformResourceDetailTableRowSource).toContain('SummaryRowActionButton');
expect(platformResourceDetailTableRowSource).toContain(
'onResourceActionSettled?: () => void | Promise<void>',
);
expect(platformResourceDetailTableRowSource).toContain(
'onResourceActionSettled={props.onResourceActionSettled}',
);
expect(platformResourceDetailTableRowSource).not.toContain('ResourceActionsAPI');
for (const source of [
dockerHostsTableSource,

View file

@ -14,6 +14,21 @@ import {
type DockerContainerLifecycleAction,
} from './dockerContainerLifecycleActions';
export type DockerContainerLifecycleSurface = 'docker-page' | 'resource-detail';
export type DockerContainerLifecycleSettledContext = {
action: DockerContainerLifecycleAction;
actionId: string;
resource: Resource;
};
export type DockerContainerLifecycleControlsProps = {
resource: Resource;
class?: string;
surface?: DockerContainerLifecycleSurface;
onActionSettled?: (context: DockerContainerLifecycleSettledContext) => void | Promise<void>;
};
const buttonBaseClass =
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border text-muted transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface';
const enabledButtonClass =
@ -47,7 +62,15 @@ const iconForAction = (action: DockerContainerLifecycleAction): Component<{ clas
const errorMessage = (error: unknown): string =>
error instanceof Error && error.message.trim() ? error.message.trim() : 'Action failed';
export const DockerContainerLifecycleControls: Component<{ resource: Resource }> = (props) => {
const surfaceLabel = (surface: DockerContainerLifecycleSurface | undefined): string =>
surface === 'resource-detail' ? 'resource details' : 'Docker page';
const requestedByForSurface = (surface: DockerContainerLifecycleSurface | undefined): string =>
surface === 'resource-detail' ? 'ui:resource-detail' : 'ui:docker-page';
export const DockerContainerLifecycleControls: Component<DockerContainerLifecycleControlsProps> = (
props,
) => {
const [confirmingAction, setConfirmingAction] =
createSignal<DockerContainerLifecycleAction | null>(null);
const [runningAction, setRunningAction] = createSignal<DockerContainerLifecycleAction | null>(
@ -69,7 +92,9 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
const containerName = dockerContainerLifecycleName(props.resource);
const runtimeLabel = dockerContainerRuntimeLabel(props.resource);
const reason = `${action} ${runtimeLabel} container ${containerName} from the Docker page.`;
const reason = `${action} ${runtimeLabel} container ${containerName} from the ${surfaceLabel(
props.surface,
)}.`;
setRunningAction(action);
setConfirmingAction(null);
setLastError('');
@ -81,7 +106,7 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
capabilityName: action,
params: {},
reason,
requestedBy: 'ui:docker-page',
requestedBy: requestedByForSurface(props.surface),
});
if (!plan.allowed) {
throw new Error(plan.message || 'Pulse refused the action plan.');
@ -93,6 +118,15 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
if (result.result && !result.result.success) {
throw new Error(result.result.errorMessage || 'The action did not complete successfully.');
}
try {
await props.onActionSettled?.({
action,
actionId: plan.actionId,
resource: props.resource,
});
} catch {
notificationStore.warning('Action requested. Refresh container inventory to verify state.');
}
setCompletedAction(action);
window.setTimeout(
() => setCompletedAction((current) => (current === action ? null : current)),
@ -128,7 +162,11 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
};
return (
<div class="inline-flex items-center justify-end gap-1" data-prevent-toggle>
<div
class={`inline-flex items-center justify-end gap-1 ${props.class ?? ''}`.trim()}
data-prevent-toggle
data-docker-container-actions-surface={props.surface ?? 'docker-page'}
>
<For each={DOCKER_CONTAINER_LIFECYCLE_ACTIONS}>
{(spec) => {
const disabled = () =>

View file

@ -54,6 +54,10 @@ import { DockerContainerLifecycleControls } from './DockerContainerLifecycleCont
type DockerNetwork = NonNullable<NonNullable<Resource['docker']>['networks']>[number];
type DockerMount = NonNullable<NonNullable<Resource['docker']>['mounts']>[number];
type DockerContainersTableProps = DockerNativeTableProps & {
onLifecycleActionSettled?: () => void | Promise<void>;
};
const networkLabel = (network: DockerNetwork): string => {
const name = asTrimmedString(network.name);
const address = asTrimmedString(network.ipv4) || asTrimmedString(network.ipv6);
@ -182,7 +186,7 @@ const containerUpdateAction = (
};
};
export const DockerContainersTable: Component<DockerNativeTableProps> = (props) => {
export const DockerContainersTable: Component<DockerContainersTableProps> = (props) => {
const breakpoint = useBreakpoint();
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const tableState = createPlatformTableFilterState({
@ -538,7 +542,10 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
case 'actions':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<DockerContainerLifecycleControls resource={resource} />
<DockerContainerLifecycleControls
resource={resource}
onActionSettled={props.onLifecycleActionSettled}
/>
</TableCell>
);
default:
@ -567,6 +574,7 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
colSpan={drawerColSpan()}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(resource)}
onResourceActionSettled={props.onLifecycleActionSettled}
/>
</>
);

View file

@ -84,6 +84,9 @@ export function DockerPageSurface() {
const serverVersionDisplay = createMemo(() =>
formatAgentVersionDisplay(agentUpdateTargetVersion()),
);
const refreshDockerResources = async () => {
await refetch();
};
return (
<div data-testid="docker-page" class="space-y-3">
@ -136,6 +139,7 @@ export function DockerPageSurface() {
hostFilter={hostFilter()}
containers={model().containers}
incidents={model().incidents}
onLifecycleActionSettled={refreshDockerResources}
/>
</Show>
<Show when={activeTab() === 'images'}>
@ -307,6 +311,7 @@ function DockerOverview(props: {
hostFilter: string;
containers: ReturnType<typeof buildDockerPageModel>['containers'];
incidents: ReturnType<typeof buildDockerPageModel>['incidents'];
onLifecycleActionSettled?: () => void | Promise<void>;
}) {
const hostMatchesFilter = (resource: Resource, hostFilter: string): boolean => {
const normalized = hostFilter.trim();
@ -338,6 +343,7 @@ function DockerOverview(props: {
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman containers"
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
onLifecycleActionSettled={props.onLifecycleActionSettled}
/>
<Show when={props.incidents.length > 0}>
<DockerAlertsTable

View file

@ -453,6 +453,8 @@ describe('Docker native tables', () => {
});
it('runs Docker lifecycle row actions through the governed action API', async () => {
const onLifecycleActionSettled = vi.fn();
renderInRouter(() => (
<DockerContainersTable
resources={[
@ -490,6 +492,7 @@ describe('Docker native tables', () => {
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
onLifecycleActionSettled={onLifecycleActionSettled}
/>
));
@ -519,6 +522,7 @@ describe('Docker native tables', () => {
'approved',
expect.stringContaining('restart Docker container edge-web'),
);
expect(onLifecycleActionSettled).toHaveBeenCalledTimes(1);
});
it('shows disabled Docker lifecycle buttons with explicit unavailable reasons', () => {

View file

@ -4,6 +4,7 @@ import type { Resource } from '@/types/resource';
import {
dockerContainerLifecycleCapability,
getDockerContainerLifecycleDisabledReason,
isDockerContainerLifecycleResource,
} from '../dockerContainerLifecycleActions';
const resource = (overrides: Partial<Resource> = {}): Resource => ({
@ -44,6 +45,7 @@ describe('dockerContainerLifecycleActions', () => {
it('enables advertised runtime-matched capabilities', () => {
const running = resource();
expect(isDockerContainerLifecycleResource(running)).toBe(true);
expect(dockerContainerLifecycleCapability(running, 'restart')?.name).toBe('restart');
expect(getDockerContainerLifecycleDisabledReason(running, 'restart')).toBeUndefined();
expect(getDockerContainerLifecycleDisabledReason(running, 'stop')).toBeUndefined();
@ -52,6 +54,33 @@ describe('dockerContainerLifecycleActions', () => {
);
});
it('only identifies Docker and Podman app containers as lifecycle resources', () => {
expect(
isDockerContainerLifecycleResource(
resource({
platformType: 'truenas',
sources: ['truenas'],
docker: { runtime: 'docker', containerState: 'running' },
}),
),
).toBe(false);
expect(
isDockerContainerLifecycleResource(
resource({ type: 'docker-host', docker: { runtime: 'docker' } }),
),
).toBe(false);
expect(
isDockerContainerLifecycleResource(
resource({ docker: { runtime: 'containerd', containerState: 'running' } }),
),
).toBe(false);
expect(
isDockerContainerLifecycleResource(
resource({ docker: { runtime: undefined, containerState: 'running' } }),
),
).toBe(false);
});
it('enables start for stopped containers and explains non-running stop/restart states', () => {
const stopped = resource({
status: 'offline',

View file

@ -21,6 +21,16 @@ const STALE_SOURCE_STATUSES = new Set(['stale', 'offline', 'missing']);
const normalizeToken = (value: unknown): string => (asTrimmedString(value) ?? '').toLowerCase();
export const isDockerContainerLifecycleResource = (resource: Resource): boolean => {
if (resource.type !== 'app-container') return false;
const runtime = normalizeToken(resource.docker?.runtime);
const hasDockerSource =
normalizeToken(resource.platformType) === 'docker' ||
(resource.sources ?? []).some((source) => normalizeToken(source) === 'docker') ||
(resource.platformScopes ?? []).some((scope) => normalizeToken(scope) === 'docker');
return hasDockerSource && (runtime === 'docker' || runtime === 'podman');
};
export const dockerContainerLifecycleName = (resource: Resource): string =>
(asTrimmedString(resource.name) ||
asTrimmedString(resource.displayName) ||

View file

@ -95,6 +95,7 @@ export const PlatformResourceDetailTableRow: Component<{
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
initialShowAccessContext?: boolean;
initialShowTrueNASDetails?: boolean;
onResourceActionSettled?: () => void | Promise<void>;
onClose?: () => void;
}> = (props) => {
const initialShowTrueNASDetails = () =>
@ -114,6 +115,7 @@ export const PlatformResourceDetailTableRow: Component<{
resolveResourceLabel={props.resolveResourceLabel}
initialShowAccessContext={props.initialShowAccessContext}
initialShowTrueNASDetails={initialShowTrueNASDetails()}
onResourceActionSettled={props.onResourceActionSettled}
onClose={props.onClose}
/>
</InlineDetailTableRow>