diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b2aedfc9f..bd839cc82 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 40e614233..65dff012d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 850f78814..c11db8c25 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/src/api/__tests__/actionAudit.test.ts b/frontend-modern/src/api/__tests__/actionAudit.test.ts index 3b1fe0d07..4f51b958d 100644 --- a/frontend-modern/src/api/__tests__/actionAudit.test.ts +++ b/frontend-modern/src/api/__tests__/actionAudit.test.ts @@ -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({}), + }, + ); + }); }); diff --git a/frontend-modern/src/api/resourceActions.ts b/frontend-modern/src/api/resourceActions.ts new file mode 100644 index 000000000..0c2117b30 --- /dev/null +++ b/frontend-modern/src/api/resourceActions.ts @@ -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 { + return apiFetchJSON('/api/actions/plan', { + method: 'POST', + body: JSON.stringify(request), + }); + } + + static async decideAction( + actionId: string, + outcome: ActionDecisionOutcome, + reason?: string, + ): Promise { + return apiFetchJSON( + `/api/actions/${encodeURIComponent(actionId)}/decision`, + { + method: 'POST', + body: JSON.stringify({ + outcome, + ...(reason ? { reason } : {}), + }), + }, + ); + } + + static async executeAction(actionId: string, reason?: string): Promise { + return apiFetchJSON( + `/api/actions/${encodeURIComponent(actionId)}/execute`, + { + method: 'POST', + body: JSON.stringify(reason ? { reason } : {}), + }, + ); + } +} diff --git a/frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx b/frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx new file mode 100644 index 000000000..0554beab7 --- /dev/null +++ b/frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx @@ -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(null); + const [runningAction, setRunningAction] = createSignal( + null, + ); + const [completedAction, setCompletedAction] = createSignal( + 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 ( +
+ + {(spec) => { + const disabled = () => + Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) || + (runningAction() !== null && runningAction() !== spec.action); + const Icon = iconForAction(spec.action); + + return ( + + ); + }} + +
+ ); +}; + +export default DockerContainerLifecycleControls; diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 898cc5e65..ca7aa79c7 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -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['networks']>[number]; type DockerMount = NonNullable['mounts']>[number]; @@ -534,6 +535,12 @@ export const DockerContainersTable: Component = (props) ); + case 'actions': + return ( + + + + ); default: column.id satisfies never; return <>; diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 1c2ec1017..e4046f406 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -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(() => ( + } + 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(() => ( + } + 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(() => ( = {}): 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.', + ); + }); +}); diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts index 7fc3218bc..4f5982cbb 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts @@ -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'); diff --git a/frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts b/frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts new file mode 100644 index 000000000..7a2452c9b --- /dev/null +++ b/frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts @@ -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; +}; diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts index 8a9c19927..124759f52 100644 --- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -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 = { @@ -76,6 +79,7 @@ const DOCKER_CONTAINER_DESKTOP_WIDTHS: Record; + command?: DockerRuntimeCommand | Record; + 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; relationships?: ResourceRelationship[]; recentChanges?: ResourceChange[]; facetCounts?: ResourceFacetCounts; diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index a8103c1a7..cdb90e2e7 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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, } ], )