+
{(spec) => {
- const disabled = () =>
- Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) ||
- (runningAction() !== null && runningAction() !== spec.action);
+ const disabled = () => Boolean(getDockerContainerLifecycleDisabledReason(props.resource, spec.action)) || (planningAction() !== null && planningAction() !== spec.action);
const Icon = iconForAction(spec.action);
-
return (
-
+
setReviewDetail(null)}
+ onChanged={async (detail) => {
+ setReviewDetail(detail);
+ const action = reviewAction();
+ if (!action || !['completed', 'failed', 'rejected', 'expired'].includes(detail.audit.state)) return;
+ try {
+ await props.onActionSettled?.({ action, actionId: detail.audit.id, resource: props.resource });
+ } catch {
+ notificationStore.warning('Action recorded. Refresh container inventory to see the latest state.');
+ }
+ }}
+ />
);
};
diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
index ae4720a40..d231d95c8 100644
--- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
+++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
@@ -55,6 +55,19 @@ vi.mock('@/api/resourceActions', () => ({
result: { success: true },
audit: {},
}),
+ getAction: vi.fn().mockResolvedValue({
+ audit: {
+ id: 'action-1',
+ createdAt: '2026-06-12T20:00:00Z',
+ updatedAt: '2026-06-12T20:00:00Z',
+ state: 'pending_approval',
+ decisionRevision: 0,
+ request: { requestId: 'request-1', resourceId: 'container-1', capabilityName: 'restart', reason: 'restart Docker container edge-web', requestedBy: 'ui:docker-page' },
+ plan: { actionId: 'action-1', requestId: 'request-1', allowed: true, requiresApproval: true, approvalPolicy: 'admin', rollbackAvailable: false, expiresAt: '2026-06-12T20:05:00Z', policyDecision: { version: 0, status: 'legacy_unknown', scope: { orgId: '', resourceId: '', capabilityName: '' }, authorities: [], approvalRequirement: { version: 0, floor: 'admin', quorum: 1, disallowRequester: false }, planningAllowed: false, requiresApproval: true } },
+ verificationOutcome: { status: 'unknown' },
+ },
+ events: [],
+ }),
},
}));
@@ -62,6 +75,7 @@ vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
+ warning: vi.fn(),
},
}));
@@ -722,7 +736,7 @@ describe('Docker native tables', () => {
);
});
- it('runs Docker lifecycle row actions through the governed action API', async () => {
+ it('opens canonical review on the first Docker lifecycle click without auto-approving or executing', async () => {
const onLifecycleActionSettled = vi.fn();
renderInRouter(() => (
@@ -767,10 +781,9 @@ describe('Docker native tables', () => {
));
const restartButton = screen.getByRole('button', {
- name: 'Restart edge-web through governed action',
+ name: 'Review restart for edge-web',
});
fireEvent.click(restartButton);
- fireEvent.click(screen.getByRole('button', { name: 'Click again to restart edge-web' }));
await waitFor(() =>
expect(ResourceActionsAPI.planAction).toHaveBeenCalledWith(
@@ -781,18 +794,11 @@ describe('Docker native tables', () => {
}),
),
);
- 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'),
- );
- expect(onLifecycleActionSettled).toHaveBeenCalledTimes(1);
+ expect(await screen.findByRole('dialog', { name: 'Restart' })).toBeInTheDocument();
+ expect(screen.getByText('Why Pulse allows this review')).toBeInTheDocument();
+ expect(ResourceActionsAPI.decideAction).not.toHaveBeenCalled();
+ expect(ResourceActionsAPI.executeAction).not.toHaveBeenCalled();
+ expect(onLifecycleActionSettled).not.toHaveBeenCalled();
});
it('shows disabled Docker lifecycle buttons with explicit unavailable reasons', () => {
diff --git a/frontend-modern/src/features/patrol/PatrolAutopilotAcknowledgementDialog.tsx b/frontend-modern/src/features/patrol/PatrolAutopilotAcknowledgementDialog.tsx
new file mode 100644
index 000000000..d6099aafb
--- /dev/null
+++ b/frontend-modern/src/features/patrol/PatrolAutopilotAcknowledgementDialog.tsx
@@ -0,0 +1,48 @@
+import { For, createSignal, type Component } from 'solid-js';
+import XIcon from 'lucide-solid/icons/x';
+import { Button } from '@/components/shared/Button';
+import { Dialog } from '@/components/shared/Dialog';
+import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
+
+const scopeLabel = (scope: string): string => {
+ switch (scope) {
+ case 'policy_authorized_actions': return 'Only actions authorized by current server policy';
+ case 'capability_allowlisted_only': return 'Only capabilities on the server allowlist';
+ case 'outcome_truth_not_inferred': return 'Execution success is never presented as verified outcome truth';
+ case 'revocation_and_version_rotation_bound': return 'Activation stops after revocation or acknowledgement version rotation';
+ default: return scope.replace(/_/g, ' ');
+ }
+};
+
+export const PatrolAutopilotAcknowledgementDialog: Component<{ state: PatrolIntelligenceState }> = (props) => {
+ const [accepted, setAccepted] = createSignal(false);
+ const status = () => props.state.autopilotStatus();
+ const close = () => { setAccepted(false); props.state.setAutopilotDialogOpen(false); };
+
+ return (
+
+ );
+};
diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
index 0a0ddcbb4..a678a62b5 100644
--- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
+++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
@@ -9,6 +9,7 @@ import { PageHeader } from '@/components/shared/PageHeader';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { CountdownTimer } from '@/components/patrol';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
+import { Button } from '@/components/shared/Button';
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
@@ -28,6 +29,7 @@ import type { PatrolConfigurationFailureInput } from './patrolInvestigationConte
import { getPatrolAutonomyAvailabilityPresentation } from './patrolAutonomyAvailability';
import { PATROL_AUTONOMY_POLICY_PRESENTATION } from './patrolControlPresentation';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
+import { PatrolAutopilotAcknowledgementDialog } from './PatrolAutopilotAcknowledgementDialog';
export { PATROL_AUTONOMY_POLICY_PRESENTATION } from './patrolControlPresentation';
@@ -273,11 +275,23 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
Saving Patrol mode
+