Add Docker container lifecycle controls

Refs #1034
This commit is contained in:
rcourtman
2026-06-12 21:41:06 +01:00
parent 1708db68a0
commit 8b3ed322e7
15 changed files with 775 additions and 11 deletions
@@ -41,6 +41,7 @@ product API routes free of maintainer commercial analytics.
5. `frontend-modern/src/types/api.ts`
6. `frontend-modern/src/types/actionAudit.ts`
7. `frontend-modern/src/api/actionAudit.ts`
7a. `frontend-modern/src/api/resourceActions.ts`
6. `frontend-modern/src/api/responseUtils.ts`
7. `frontend-modern/src/components/Settings/APITokenManager.tsx`
8. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`
@@ -3523,6 +3524,11 @@ and its typed payload mirror in `frontend-modern/src/types/actionAudit.ts` are
the canonical browser-side reader for `GET /api/audit/actions`; resource
workflow surfaces must consume that client instead of rebuilding audit query
URLs or silently ignoring the endpoint's gated-unavailable state.
The frontend resource-action client in `frontend-modern/src/api/resourceActions.ts`
is the canonical browser-side writer for `/api/actions/plan`,
`/api/actions/{id}/decision`, and `/api/actions/{id}/execute`; Docker / Podman
container lifecycle controls and future resource workflow buttons must consume
that client rather than posting directly from feature-local fetch helpers.
Those relationship and timeline payloads now also carry `lastSeenAt` freshness
and optional metadata through the same owned contract, so the drawer can
preserve provenance without inventing a separate relationship-detail schema.
@@ -290,8 +290,12 @@ unified-resource owner supplies API-object-specific container, image, volume,
network, Swarm node, task, secret, and config columns through dedicated native
tables. The Docker containers tab must use the native
`DockerContainersTable` for container state, health, restart, image, port,
network, mount, update, and host/runtime columns rather than embedding
`WorkloadsSurface`. Swarm services must surface the API-reported rollout/update
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
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
hydrated from runtime disk-usage data. Kubernetes deployments must surface the
@@ -2364,6 +2368,13 @@ presentation for product surfaces that display state rather than toggle it.
Product components must compose `StatusIndicatorBadge` instead of calling
`getStatusIndicatorBadgeToneClasses` directly; low-level status utilities may
still expose the tone mapping for that primitive and utility-level tests.
Read-only metadata badges follow the same primitive-owned shell rule.
`frontend-modern/src/components/shared/MetadataBadge.tsx` owns filled and
outlined appearances, compact sizing, shape, typed tone vocabulary, fit
behavior, and whitespace handling. Product surfaces such as Patrol findings
may own the labels and state-to-tone mapping in their presentation helpers, but
they must render visible metadata chips through `MetadataBadge` instead of
recreating local bordered xs spans.
Patrol run-history, status-bar, and runtime-summary status labels follow this
state-badge boundary: Patrol may derive the status label and typed variant in
`patrolRunPresentation.ts` or `patrolSummaryPresentation.ts`, but
@@ -54,6 +54,9 @@ cross-source deduplication.
32. `frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx`
33. `frontend-modern/src/features/docker/DockerConfigsTable.tsx`
34. `frontend-modern/src/features/docker/DockerContainersTable.tsx`
34a. `frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx`
34b. `frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts`
34c. `frontend-modern/src/features/docker/dockerContainerTableModel.ts`
35. `frontend-modern/src/features/docker/DockerImagesTable.tsx`
36. `frontend-modern/src/features/docker/DockerNativeTableShared.tsx`
37. `frontend-modern/src/features/docker/DockerNetworksTable.tsx`
@@ -1233,6 +1236,13 @@ trail inside the resource-detail workflow through the canonical
`frontend-modern/src/utils/actionAuditPresentation.ts` labels. The resource
drawer must treat gated action-audit reads as unavailable rather than turning
ordinary infrastructure inspection into an upgrade-prompt path.
Docker / Podman container lifecycle controls in
`frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx` and
`dockerContainerLifecycleActions.ts` are unified-resource capability consumers:
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.
`InfrastructureSummary.tsx` and `infrastructureSummaryModel.ts` now surface
`degraded` and `alerting` resource counts alongside the existing `online` and
@@ -5,7 +5,13 @@ vi.mock('@/utils/apiClient', () => ({
}));
import { ActionAuditAPI } from '@/api/actionAudit';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { apiFetchJSON } from '@/utils/apiClient';
import type {
ActionDecisionResponse,
ActionExecutionResponse,
ResourceActionRequest,
} from '@/types/actionAudit';
describe('ActionAuditAPI', () => {
const apiFetchJSONMock = vi.mocked(apiFetchJSON);
@@ -217,4 +223,85 @@ describe('ActionAuditAPI', () => {
resourceId: 'vm:42',
});
});
it('plans resource actions through the governed action endpoint', async () => {
const request: ResourceActionRequest = {
requestId: 'req-docker-restart',
resourceId: 'docker:container:abc123',
capabilityName: 'docker.container.restart',
reason: 'restart after configuration update',
requestedBy: 'operator',
};
apiFetchJSONMock.mockResolvedValueOnce({
actionId: 'action-docker-restart',
requestId: request.requestId,
allowed: true,
requiresApproval: true,
approvalPolicy: 'admin',
rollbackAvailable: false,
});
const response = await ResourceActionsAPI.planAction(request);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/actions/plan', {
method: 'POST',
body: JSON.stringify(request),
});
expect(response).toMatchObject({
actionId: 'action-docker-restart',
requestId: request.requestId,
allowed: true,
requiresApproval: true,
});
});
it('records decisions and executes actions through encoded governed action routes', async () => {
const decision: ActionDecisionResponse = {
actionId: 'action/docker/restart',
outcome: 'approved',
decidedAt: '2026-06-12T20:40:00Z',
};
const execution: ActionExecutionResponse = {
actionId: 'action/docker/restart',
state: 'completed',
result: {
success: true,
output: 'container restarted',
},
};
apiFetchJSONMock.mockResolvedValueOnce(decision).mockResolvedValueOnce(execution);
await expect(
ResourceActionsAPI.decideAction(
'action/docker/restart',
'approved',
'operator confirmed restart',
),
).resolves.toEqual(decision);
await expect(ResourceActionsAPI.executeAction('action/docker/restart')).resolves.toEqual(
execution,
);
expect(apiFetchJSONMock).toHaveBeenNthCalledWith(
1,
'/api/actions/action%2Fdocker%2Frestart/decision',
{
method: 'POST',
body: JSON.stringify({
outcome: 'approved',
reason: 'operator confirmed restart',
}),
},
);
expect(apiFetchJSONMock).toHaveBeenNthCalledWith(
2,
'/api/actions/action%2Fdocker%2Frestart/execute',
{
method: 'POST',
body: JSON.stringify({}),
},
);
});
});
@@ -0,0 +1,45 @@
import { apiFetchJSON } from '@/utils/apiClient';
import type {
ActionAuditPlan,
ActionDecisionResponse,
ActionExecutionResponse,
ResourceActionRequest,
} from '@/types/actionAudit';
export type ActionDecisionOutcome = 'approved' | 'rejected';
export class ResourceActionsAPI {
static async planAction(request: ResourceActionRequest): Promise<ActionAuditPlan> {
return apiFetchJSON<ActionAuditPlan>('/api/actions/plan', {
method: 'POST',
body: JSON.stringify(request),
});
}
static async decideAction(
actionId: string,
outcome: ActionDecisionOutcome,
reason?: string,
): Promise<ActionDecisionResponse> {
return apiFetchJSON<ActionDecisionResponse>(
`/api/actions/${encodeURIComponent(actionId)}/decision`,
{
method: 'POST',
body: JSON.stringify({
outcome,
...(reason ? { reason } : {}),
}),
},
);
}
static async executeAction(actionId: string, reason?: string): Promise<ActionExecutionResponse> {
return apiFetchJSON<ActionExecutionResponse>(
`/api/actions/${encodeURIComponent(actionId)}/execute`,
{
method: 'POST',
body: JSON.stringify(reason ? { reason } : {}),
},
);
}
}
@@ -0,0 +1,170 @@
import { For, Match, Switch, createSignal, type Component } from 'solid-js';
import Loader2Icon from 'lucide-solid/icons/loader-2';
import PlayIcon from 'lucide-solid/icons/play';
import RotateCwIcon from 'lucide-solid/icons/rotate-cw';
import SquareIcon from 'lucide-solid/icons/square';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { notificationStore } from '@/stores/notifications';
import type { Resource } from '@/types/resource';
import {
DOCKER_CONTAINER_LIFECYCLE_ACTIONS,
dockerContainerLifecycleName,
dockerContainerRuntimeLabel,
getDockerContainerLifecycleDisabledReason,
type DockerContainerLifecycleAction,
} from './dockerContainerLifecycleActions';
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 =
'border-border-subtle bg-surface hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 dark:hover:bg-blue-950/40 dark:hover:text-blue-300';
const confirmButtonClass =
'border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300';
const disabledButtonClass = 'cursor-not-allowed border-border-subtle bg-surface-alt opacity-55';
const runningButtonClass =
'cursor-wait border-blue-400 bg-blue-50 text-blue-700 dark:bg-blue-950/40';
const successButtonClass =
'border-emerald-400 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40';
const newRequestId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `docker-container-action-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const iconForAction = (action: DockerContainerLifecycleAction): Component<{ class?: string }> => {
switch (action) {
case 'start':
return PlayIcon;
case 'stop':
return SquareIcon;
case 'restart':
return RotateCwIcon;
}
};
const errorMessage = (error: unknown): string =>
error instanceof Error && error.message.trim() ? error.message.trim() : 'Action failed';
export const DockerContainerLifecycleControls: Component<{ resource: Resource }> = (props) => {
const [confirmingAction, setConfirmingAction] =
createSignal<DockerContainerLifecycleAction | null>(null);
const [runningAction, setRunningAction] = createSignal<DockerContainerLifecycleAction | null>(
null,
);
const [completedAction, setCompletedAction] = createSignal<DockerContainerLifecycleAction | null>(
null,
);
const [lastError, setLastError] = createSignal('');
const executeLifecycleAction = async (action: DockerContainerLifecycleAction) => {
const disabledReason = getDockerContainerLifecycleDisabledReason(props.resource, action);
if (disabledReason || runningAction()) return;
if (confirmingAction() !== action) {
setConfirmingAction(action);
setLastError('');
return;
}
const containerName = dockerContainerLifecycleName(props.resource);
const runtimeLabel = dockerContainerRuntimeLabel(props.resource);
const reason = `${action} ${runtimeLabel} container ${containerName} from the Docker page.`;
setRunningAction(action);
setConfirmingAction(null);
setLastError('');
try {
const plan = await ResourceActionsAPI.planAction({
requestId: newRequestId(),
resourceId: props.resource.id,
capabilityName: action,
params: {},
reason,
requestedBy: 'ui:docker-page',
});
if (!plan.allowed) {
throw new Error(plan.message || 'Pulse refused the action plan.');
}
if (plan.requiresApproval) {
await ResourceActionsAPI.decideAction(plan.actionId, 'approved', reason);
}
const result = await ResourceActionsAPI.executeAction(plan.actionId, reason);
if (result.result && !result.result.success) {
throw new Error(result.result.errorMessage || 'The action did not complete successfully.');
}
setCompletedAction(action);
window.setTimeout(
() => setCompletedAction((current) => (current === action ? null : current)),
2000,
);
notificationStore.success(`${runtimeLabel} container ${containerName}: ${action} requested`);
} catch (error) {
const message = errorMessage(error);
setLastError(message);
notificationStore.error(message);
} finally {
setRunningAction(null);
}
};
const titleForAction = (action: DockerContainerLifecycleAction, label: string): string => {
const disabledReason = getDockerContainerLifecycleDisabledReason(props.resource, action);
const containerName = dockerContainerLifecycleName(props.resource);
if (disabledReason) return `${label} unavailable: ${disabledReason}`;
if (runningAction() === action) return `${label} ${containerName} through governed action`;
if (confirmingAction() === action) return `Click again to ${action} ${containerName}`;
if (lastError()) return `${label} ${containerName}; last error: ${lastError()}`;
return `${label} ${containerName} through governed action`;
};
const classForAction = (action: DockerContainerLifecycleAction): string => {
const disabledReason = getDockerContainerLifecycleDisabledReason(props.resource, action);
if (runningAction() === action) return `${buttonBaseClass} ${runningButtonClass}`;
if (completedAction() === action) return `${buttonBaseClass} ${successButtonClass}`;
if (disabledReason || runningAction()) return `${buttonBaseClass} ${disabledButtonClass}`;
if (confirmingAction() === action) return `${buttonBaseClass} ${confirmButtonClass}`;
return `${buttonBaseClass} ${enabledButtonClass}`;
};
return (
<div class="inline-flex items-center justify-end gap-1" data-prevent-toggle>
<For each={DOCKER_CONTAINER_LIFECYCLE_ACTIONS}>
{(spec) => {
const disabled = () =>
Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) ||
(runningAction() !== null && runningAction() !== spec.action);
const Icon = iconForAction(spec.action);
return (
<button
type="button"
class={classForAction(spec.action)}
disabled={disabled()}
title={titleForAction(spec.action, spec.label)}
aria-label={titleForAction(spec.action, spec.label)}
data-docker-container-action={spec.action}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void executeLifecycleAction(spec.action);
}}
>
<Switch>
<Match when={runningAction() === spec.action}>
<Loader2Icon class="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
</Match>
<Match when={true}>
<Icon class="h-3.5 w-3.5" aria-hidden="true" />
</Match>
</Switch>
</button>
);
}}
</For>
</div>
);
};
export default DockerContainerLifecycleControls;
@@ -49,6 +49,7 @@ import {
} from './dockerContainerTableModel';
import type { DockerContainerUpdateStatus } from '@/types/api';
import type { Resource } from '@/types/resource';
import { DockerContainerLifecycleControls } from './DockerContainerLifecycleControls';
type DockerNetwork = NonNullable<NonNullable<Resource['docker']>['networks']>[number];
type DockerMount = NonNullable<NonNullable<Resource['docker']>['mounts']>[number];
@@ -534,6 +535,12 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
</Show>
</TableCell>
);
case 'actions':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<DockerContainerLifecycleControls resource={resource} />
</TableCell>
);
default:
column.id satisfies never;
return <></>;
@@ -4,6 +4,7 @@ import type { JSX } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MonitoringAPI } from '@/api/monitoring';
import { ResourceActionsAPI } from '@/api/resourceActions';
import type { Resource } from '@/types/resource';
import { DockerContainersTable } from '../DockerContainersTable';
import { DockerConfigsTable } from '../DockerConfigsTable';
@@ -22,6 +23,48 @@ vi.mock('@/api/monitoring', () => ({
},
}));
vi.mock('@/api/resourceActions', () => ({
ResourceActionsAPI: {
planAction: vi.fn().mockResolvedValue({
actionId: 'action-1',
requestId: 'request-1',
allowed: true,
requiresApproval: true,
approvalPolicy: 'admin',
rollbackAvailable: false,
plannedAt: '2026-06-12T20:00:00Z',
expiresAt: '2026-06-12T20:05:00Z',
resourceVersion: 'resource-version',
policyVersion: 'policy-version',
planHash: 'plan-hash',
}),
decideAction: vi.fn().mockResolvedValue({
actionId: 'action-1',
state: 'approved',
approval: {
actor: 'operator',
method: 'api',
timestamp: '2026-06-12T20:00:01Z',
outcome: 'approved',
},
audit: {},
}),
executeAction: vi.fn().mockResolvedValue({
actionId: 'action-1',
state: 'completed',
result: { success: true },
audit: {},
}),
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/stores/containerUpdates', () => ({
clearContainerUpdateState: vi.fn(),
getContainerUpdateState: vi.fn(() => undefined),
@@ -116,6 +159,7 @@ describe('Docker native tables', () => {
cpu: { current: 42 },
memory: { current: 50, used: 512 * 1024 * 1024, total: 1024 * 1024 * 1024 },
docker: {
agentId: 'agent-1',
hostname: 'edge-01',
runtime: 'docker',
runtimeVersion: '27.5.1',
@@ -148,6 +192,7 @@ describe('Docker native tables', () => {
name: 'edge-cache',
status: 'running',
docker: {
agentId: 'agent-2',
hostname: 'edge-02',
runtime: 'podman',
runtimeVersion: '5.2.1',
@@ -171,6 +216,7 @@ describe('Docker native tables', () => {
expect(screen.getByText('Memory')).toBeInTheDocument();
expect(screen.getByText('Restarts')).toBeInTheDocument();
expect(screen.getByText('Updates')).toBeInTheDocument();
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(screen.queryByText('Health')).not.toBeInTheDocument();
expect(screen.queryByText('State')).not.toBeInTheDocument();
expect(screen.getByText('edge-web')).toBeInTheDocument();
@@ -406,6 +452,109 @@ describe('Docker native tables', () => {
);
});
it('runs Docker lifecycle row actions through the governed action API', async () => {
renderInRouter(() => (
<DockerContainersTable
resources={[
makeResource({
id: 'container-1',
type: 'app-container',
name: 'edge-web',
status: 'running',
docker: {
agentId: 'agent-edge',
hostSourceId: 'docker-host-edge',
containerId: 'native-container-1',
hostname: 'edge-01',
image: 'nginx:latest',
runtime: 'docker',
containerState: 'running',
},
capabilities: [
{
name: 'restart',
type: 'common',
platform: 'docker',
minimumApprovalLevel: 'admin',
},
{
name: 'stop',
type: 'common',
platform: 'docker',
minimumApprovalLevel: 'admin',
},
],
}),
]}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
));
const restartButton = screen.getByRole('button', {
name: 'Restart edge-web through governed action',
});
fireEvent.click(restartButton);
fireEvent.click(screen.getByRole('button', { name: 'Click again to restart edge-web' }));
await waitFor(() =>
expect(ResourceActionsAPI.planAction).toHaveBeenCalledWith(
expect.objectContaining({
resourceId: 'container-1',
capabilityName: 'restart',
requestedBy: 'ui:docker-page',
}),
),
);
await waitFor(() =>
expect(ResourceActionsAPI.executeAction).toHaveBeenCalledWith(
'action-1',
expect.stringContaining('restart Docker container edge-web'),
),
);
expect(ResourceActionsAPI.decideAction).toHaveBeenCalledWith(
'action-1',
'approved',
expect.stringContaining('restart Docker container edge-web'),
);
});
it('shows disabled Docker lifecycle buttons with explicit unavailable reasons', () => {
renderInRouter(() => (
<DockerContainersTable
resources={[
makeResource({
id: 'container-1',
type: 'app-container',
name: 'edge-web',
status: 'running',
docker: {
agentId: 'agent-edge',
containerId: 'native-container-1',
hostname: 'edge-01',
image: 'nginx:latest',
runtime: 'docker',
containerState: 'running',
},
sourceStatus: { docker: { status: 'stale' } },
}),
]}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
));
expect(
screen.getByRole('button', {
name: 'Restart unavailable: Docker inventory is stale; refresh inventory before running lifecycle actions.',
}),
).toBeDisabled();
});
it('renders container rows with status mapped from containerState + health + exitCode, attention rows first', () => {
renderInRouter(() => (
<DockerContainersTable
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
dockerContainerLifecycleCapability,
getDockerContainerLifecycleDisabledReason,
} from '../dockerContainerLifecycleActions';
const resource = (overrides: Partial<Resource> = {}): Resource => ({
id: 'app-container:docker-host:web',
name: 'web',
displayName: 'web',
platformId: 'docker-1',
platformType: 'docker',
sourceType: 'agent',
sources: ['docker'],
status: 'running',
type: 'app-container',
lastSeen: 1_700_000_000_000,
docker: {
runtime: 'docker',
agentId: 'agent-1',
containerId: 'abc123',
containerState: 'running',
},
capabilities: [
{
name: 'stop',
type: 'common',
platform: 'docker',
minimumApprovalLevel: 'admin',
},
{
name: 'restart',
type: 'common',
platform: 'docker',
minimumApprovalLevel: 'admin',
},
],
...overrides,
});
describe('dockerContainerLifecycleActions', () => {
it('enables advertised runtime-matched capabilities', () => {
const running = resource();
expect(dockerContainerLifecycleCapability(running, 'restart')?.name).toBe('restart');
expect(getDockerContainerLifecycleDisabledReason(running, 'restart')).toBeUndefined();
expect(getDockerContainerLifecycleDisabledReason(running, 'stop')).toBeUndefined();
expect(getDockerContainerLifecycleDisabledReason(running, 'start')).toBe(
'Container is already running.',
);
});
it('enables start for stopped containers and explains non-running stop/restart states', () => {
const stopped = resource({
status: 'offline',
docker: {
runtime: 'podman',
agentId: 'agent-1',
containerId: 'abc123',
containerState: 'exited',
},
capabilities: [
{
name: 'start',
type: 'common',
platform: 'podman',
minimumApprovalLevel: 'admin',
},
],
});
expect(getDockerContainerLifecycleDisabledReason(stopped, 'start')).toBeUndefined();
expect(getDockerContainerLifecycleDisabledReason(stopped, 'restart')).toBe(
'Container must be running before restart.',
);
});
it('returns clear disabled reasons for missing agent, stale inventory, policy block, unsupported runtime, and missing capability', () => {
expect(
getDockerContainerLifecycleDisabledReason(
resource({ docker: { runtime: 'docker', containerState: 'running' } }),
'restart',
),
).toBe('No reporting Pulse agent is attached to this Docker host.');
expect(
getDockerContainerLifecycleDisabledReason(
resource({ sourceStatus: { docker: { status: 'stale' } } }),
'restart',
),
).toBe('Docker inventory is stale; refresh inventory before running lifecycle actions.');
expect(
getDockerContainerLifecycleDisabledReason(
resource({
docker: {
runtime: 'docker',
agentId: 'agent-1',
containerId: 'abc123',
containerState: 'running',
security: {
mutatingCommandsBlocked: true,
mutatingCommandsBlockedReason: 'daemon authorization plugin blocks mutation',
},
},
}),
'restart',
),
).toBe('daemon authorization plugin blocks mutation');
expect(
getDockerContainerLifecycleDisabledReason(
resource({ docker: { runtime: 'containerd', agentId: 'agent-1' } }),
'restart',
),
).toBe('containerd is not supported for governed container lifecycle actions.');
expect(
getDockerContainerLifecycleDisabledReason(resource({ capabilities: [] }), 'restart'),
).toBe(
'Pulse does not currently advertise a fresh restart command capability for this container.',
);
});
});
@@ -7,17 +7,17 @@ import {
} from '../dockerContainerTableModel';
describe('dockerContainerTableModel', () => {
it('keeps the mobile container table on identity, state, live metrics, and update action', () => {
it('keeps the mobile container table on identity, state, live metrics, and governed actions', () => {
const columns = getDockerContainerVisibleColumnsForLayout('mobile', true, true, true);
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']);
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates', 'actions']);
expect(getDockerContainerTableMinWidthClass()).toBe('min-w-full');
expect(getDockerContainerColumnWidthStyle('container', 'mobile', ids)).toEqual({
width: '32%',
width: '28.0702%',
});
expect(getDockerContainerColumnWidthStyle('memory', 'mobile', ids)).toEqual({
width: '22%',
width: '19.2982%',
});
});
@@ -26,7 +26,7 @@ describe('dockerContainerTableModel', () => {
getDockerContainerVisibleColumnsForLayout('tablet', true, false, false).map(
(column) => column.id,
),
).toEqual(['container', 'host', 'cpu', 'memory', 'updates']);
).toEqual(['container', 'host', 'cpu', 'memory', 'updates', 'actions']);
});
it('adds restarts only when the current row set has restart signal to scan', () => {
@@ -74,6 +74,7 @@ describe('dockerContainerTableModel', () => {
'restarts',
'ports',
'updates',
'actions',
]);
expect(ids).not.toContain('health');
expect(ids).not.toContain('networks');
@@ -0,0 +1,115 @@
import type { Resource, ResourceCapability } from '@/types/resource';
import { asTrimmedString } from '@/utils/stringUtils';
export type DockerContainerLifecycleAction = 'start' | 'stop' | 'restart';
export type DockerContainerLifecycleActionSpec = {
action: DockerContainerLifecycleAction;
label: string;
activeLabel: string;
};
export const DOCKER_CONTAINER_LIFECYCLE_ACTIONS: readonly DockerContainerLifecycleActionSpec[] = [
{ action: 'start', label: 'Start', activeLabel: 'Starting' },
{ action: 'stop', label: 'Stop', activeLabel: 'Stopping' },
{ action: 'restart', label: 'Restart', activeLabel: 'Restarting' },
] as const;
const SUPPORTED_RUNTIMES = new Set(['docker', 'podman']);
const STARTABLE_STATES = new Set(['created', 'exited', 'dead', 'stopped']);
const STALE_SOURCE_STATUSES = new Set(['stale', 'offline', 'missing']);
const normalizeToken = (value: unknown): string => (asTrimmedString(value) ?? '').toLowerCase();
export const dockerContainerLifecycleName = (resource: Resource): string =>
(asTrimmedString(resource.name) ||
asTrimmedString(resource.displayName) ||
asTrimmedString(resource.docker?.displayName) ||
asTrimmedString(resource.docker?.containerId)) ??
resource.id;
export const dockerContainerRuntimeLabel = (resource: Resource): string => {
const runtime = normalizeToken(resource.docker?.runtime);
if (runtime === 'podman') return 'Podman';
if (runtime === 'docker') return 'Docker';
return asTrimmedString(resource.docker?.runtime) ?? 'Container runtime';
};
export const dockerContainerLifecycleCapability = (
resource: Resource,
action: DockerContainerLifecycleAction,
): ResourceCapability | undefined => {
const runtime = normalizeToken(resource.docker?.runtime);
return resource.capabilities?.find((capability) => {
if (capability.name !== action) return false;
const platform = normalizeToken(capability.platform);
return !platform || !runtime || platform === runtime;
});
};
const sourceStatusDisabledReason = (
resource: Resource,
runtimeLabel: string,
): string | undefined => {
const dockerStatus = normalizeToken(resource.sourceStatus?.docker?.status);
if (STALE_SOURCE_STATUSES.has(dockerStatus)) {
return `${runtimeLabel} inventory is ${dockerStatus}; refresh inventory before running lifecycle actions.`;
}
const dockerError = asTrimmedString(resource.sourceStatus?.docker?.error);
if (dockerError) return `${runtimeLabel} inventory is not healthy: ${dockerError}`;
if (!resource.lastSeen || resource.lastSeen <= 0) {
return `${runtimeLabel} inventory has not reported a valid last-seen timestamp.`;
}
return undefined;
};
const stateDisabledReason = (
resource: Resource,
action: DockerContainerLifecycleAction,
): string | undefined => {
const state = normalizeToken(resource.docker?.containerState || resource.status);
if (action === 'start') {
if (state === 'running') return 'Container is already running.';
if (STARTABLE_STATES.has(state)) return undefined;
return state ? `Container state ${state} is not startable.` : 'Container state is unknown.';
}
if (state !== 'running') {
return state ? `Container must be running before ${action}.` : 'Container state is unknown.';
}
return undefined;
};
export const getDockerContainerLifecycleDisabledReason = (
resource: Resource,
action: DockerContainerLifecycleAction,
): string | undefined => {
const runtime = normalizeToken(resource.docker?.runtime);
const runtimeLabel = dockerContainerRuntimeLabel(resource);
if (runtime && !SUPPORTED_RUNTIMES.has(runtime)) {
return `${runtimeLabel} is not supported for governed container lifecycle actions.`;
}
if (!runtime) return 'Container runtime is not reported.';
const agentId = asTrimmedString(resource.docker?.agentId);
if (!agentId) return `No reporting Pulse agent is attached to this ${runtimeLabel} host.`;
const sourceReason = sourceStatusDisabledReason(resource, runtimeLabel);
if (sourceReason) return sourceReason;
const security = resource.docker?.security;
if (security?.mutatingCommandsBlocked) {
return (
asTrimmedString(security.mutatingCommandsBlockedReason) ??
`${runtimeLabel} host policy blocks mutating container lifecycle commands.`
);
}
const stateReason = stateDisabledReason(resource, action);
if (stateReason) return stateReason;
if (!dockerContainerLifecycleCapability(resource, action)) {
return `Pulse does not currently advertise a fresh ${action} command capability for this container.`;
}
return undefined;
};
@@ -15,7 +15,8 @@ export type DockerContainerTableColumnId =
| 'ports'
| 'networks'
| 'mounts'
| 'updates';
| 'updates'
| 'actions';
export type DockerContainerTableColumn = {
id: DockerContainerTableColumnId;
@@ -39,6 +40,7 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
cpu: 'mobile',
memory: 'mobile',
updates: 'mobile',
actions: 'mobile',
host: 'tablet',
restarts: 'tablet',
image: 'compact',
@@ -61,6 +63,7 @@ const DOCKER_CONTAINER_COLUMNS: DockerContainerTableColumn[] = [
{ id: 'networks', label: 'Networks', kind: 'text' },
{ id: 'mounts', label: 'Mounts', kind: 'text' },
{ id: 'updates', label: 'Updates', kind: 'badge' },
{ id: 'actions', label: 'Actions', kind: 'badge' },
];
const DOCKER_CONTAINER_DESKTOP_WIDTHS: Record<DockerContainerTableColumnId, number> = {
@@ -76,6 +79,7 @@ const DOCKER_CONTAINER_DESKTOP_WIDTHS: Record<DockerContainerTableColumnId, numb
networks: 8,
mounts: 9,
updates: 7,
actions: 8,
};
const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
@@ -88,6 +92,7 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
cpu: 18,
memory: 22,
updates: 14,
actions: 14,
},
tablet: {
container: 28,
@@ -97,6 +102,7 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
memory: 18,
restarts: 6,
updates: 7,
actions: 8,
},
compact: {
container: 18,
@@ -109,6 +115,7 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
restarts: 7,
ports: 12,
updates: 8,
actions: 8,
},
};
+16
View File
@@ -18,6 +18,8 @@ export interface ActionAuditRequest {
requestedBy: string;
}
export type ResourceActionRequest = ActionAuditRequest;
export interface ActionAuditPreflight {
target?: string;
currentState?: string;
@@ -106,3 +108,17 @@ export interface ActionAuditListResponse {
resourceId?: string;
available: boolean;
}
export interface ActionDecisionResponse {
actionId: string;
state: ActionAuditState;
approval: ActionAuditApprovalRecord;
audit: ActionAuditRecord;
}
export interface ActionExecutionResponse {
actionId: string;
state: ActionAuditState;
result?: ActionAuditExecutionResult;
audit: ActionAuditRecord;
}
+15 -1
View File
@@ -14,6 +14,7 @@ import type {
HostSensorSummary,
HostRAIDArray,
Memory,
DockerRuntimeCommand,
PBSBackupJob,
PBSGarbageJob,
PBSJobHealthEvidence,
@@ -311,6 +312,12 @@ export interface ResourceCapability {
params?: ResourceCapabilityParam[];
}
export interface ResourceSourceStatus {
status?: string;
lastSeen?: string;
error?: string;
}
export interface ResourceChange {
id: string;
observedAt: string;
@@ -642,6 +649,7 @@ export interface ResourceDockerMeta {
serviceId?: string;
serviceName?: string;
hostSourceId?: string;
agentId?: string;
containerId?: string;
hostname?: string;
displayName?: string;
@@ -669,7 +677,12 @@ export interface ResourceDockerMeta {
containersUsage?: DockerStorageUsageMeta;
volumesUsage?: DockerStorageUsageMeta;
buildCacheUsage?: DockerStorageUsageMeta;
command?: Record<string, unknown>;
command?: DockerRuntimeCommand | Record<string, unknown>;
security?: {
authorizationPlugins?: string[];
mutatingCommandsBlocked?: boolean;
mutatingCommandsBlockedReason?: string;
};
image?: string;
imageId?: string;
repoTags?: string[];
@@ -1409,6 +1422,7 @@ export interface Resource {
policy?: ResourcePolicy;
aiSafeSummary?: string;
capabilities?: ResourceCapability[];
sourceStatus?: Record<string, ResourceSourceStatus>;
relationships?: ResourceRelationship[];
recentChanges?: ResourceChange[];
facetCounts?: ResourceFacetCounts;
@@ -2860,8 +2860,8 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 457,
"heading_line": 119,
"line": 458,
"heading_line": 120,
}
],
)