mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
@@ -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
|
||||
|
||||
+126
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user