mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
parent
8b3ed322e7
commit
620f1c44de
14 changed files with 279 additions and 12 deletions
|
|
@ -294,7 +294,11 @@ network, mount, update, governed lifecycle actions, and host/runtime columns
|
|||
rather than embedding `WorkloadsSurface`. The lifecycle action column is a
|
||||
compact icon-button primitive over unified-resource capabilities and the shared
|
||||
resource-action API client; it must not grow Docker/Podman shell, SSH, or
|
||||
provider calls inside the table component. Swarm services must surface the
|
||||
provider calls inside the table component. The same governed lifecycle controls
|
||||
may appear in the resource detail header, while the platform table and drawer
|
||||
shells remain presentation owners only: they may pass a post-success refresh
|
||||
callback to their existing resource query, but must not own execution,
|
||||
approval, policy, or provider dispatch. Swarm services must surface the
|
||||
API-reported rollout/update
|
||||
state in the native services table, and engine storage rows must expose a
|
||||
stable row hook so platform-page browser proof can verify the storage tab is
|
||||
|
|
@ -2615,7 +2619,7 @@ owns `PlatformResourceDetailToggleButton`, which composes
|
|||
label, `aria-expanded`, `aria-controls`, and propagation containment. Platform
|
||||
tables that use `createPlatformResourceDetailState` or render local inline
|
||||
detail rows must compose that toggle; they may still own the detail row content,
|
||||
drawer payload, and platform-specific fields.
|
||||
drawer payload, post-success refresh callbacks, and platform-specific fields.
|
||||
The shared help icon now follows that same owner split.
|
||||
`frontend-modern/src/components/shared/HelpIcon.tsx` stays the render shell,
|
||||
`frontend-modern/src/components/shared/useHelpIconState.ts` owns open state,
|
||||
|
|
|
|||
|
|
@ -5170,7 +5170,10 @@
|
|||
"frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx",
|
||||
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
|
||||
"frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts",
|
||||
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx",
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
|
||||
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
|
||||
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
|
||||
|
|
@ -5337,7 +5340,10 @@
|
|||
"frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx",
|
||||
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
|
||||
"frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts",
|
||||
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx",
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
|
||||
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
|
||||
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
|
||||
|
|
@ -5411,6 +5417,8 @@
|
|||
"frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts",
|
||||
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"frontend-modern/src/features/docker/__tests__/DockerAlertsTable.test.tsx",
|
||||
"frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts",
|
||||
"frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts",
|
||||
"frontend-modern/src/features/docker/__tests__/DockerHostsTable.test.tsx",
|
||||
"frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx",
|
||||
"frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts",
|
||||
|
|
|
|||
|
|
@ -1242,7 +1242,14 @@ Docker / Podman container lifecycle controls in
|
|||
they may enable start/stop/restart only from backend-advertised resource
|
||||
capabilities and must use `sourceStatus`, `docker.agentId`, `docker.runtime`,
|
||||
and `docker.security` only for disabled-state explanation, never as a
|
||||
feature-local execution bypass.
|
||||
feature-local execution bypass. The same controls may render in
|
||||
`DockerContainersTable.tsx` and the canonical `ResourceDetailDrawer.tsx` header
|
||||
only for Docker-source app containers backed by Docker or Podman runtime
|
||||
metadata; other app-container sources such as TrueNAS must not inherit Docker
|
||||
lifecycle controls from runtime-shaped metadata alone. After a governed request
|
||||
completes, the owning surface may ask its existing `useUnifiedResources` query
|
||||
to refetch the resource snapshot, but refresh is not an alternate execution,
|
||||
verification, or provider-control path.
|
||||
|
||||
`InfrastructureSummary.tsx` and `infrastructureSummaryModel.ts` now surface
|
||||
`degraded` and `alerting` resource counts alongside the existing `online` and
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ActionAuditAPI } from '@/api/actionAudit';
|
|||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type {
|
||||
ActionAuditRecord,
|
||||
ActionDecisionResponse,
|
||||
ActionExecutionResponse,
|
||||
ResourceActionRequest,
|
||||
|
|
@ -257,10 +258,37 @@ describe('ActionAuditAPI', () => {
|
|||
});
|
||||
|
||||
it('records decisions and executes actions through encoded governed action routes', async () => {
|
||||
const audit: ActionAuditRecord = {
|
||||
id: 'action/docker/restart',
|
||||
createdAt: '2026-06-12T20:39:00Z',
|
||||
updatedAt: '2026-06-12T20:40:00Z',
|
||||
state: 'approved',
|
||||
request: {
|
||||
requestId: 'req-docker-restart',
|
||||
resourceId: 'docker:container:abc123',
|
||||
capabilityName: 'docker.container.restart',
|
||||
reason: 'operator confirmed restart',
|
||||
requestedBy: 'operator',
|
||||
},
|
||||
plan: {
|
||||
actionId: 'action/docker/restart',
|
||||
requestId: 'req-docker-restart',
|
||||
allowed: true,
|
||||
requiresApproval: true,
|
||||
approvalPolicy: 'admin',
|
||||
rollbackAvailable: false,
|
||||
},
|
||||
};
|
||||
const decision: ActionDecisionResponse = {
|
||||
actionId: 'action/docker/restart',
|
||||
outcome: 'approved',
|
||||
decidedAt: '2026-06-12T20:40:00Z',
|
||||
state: 'approved',
|
||||
approval: {
|
||||
actor: 'operator',
|
||||
method: 'api',
|
||||
timestamp: '2026-06-12T20:40:00Z',
|
||||
outcome: 'approved',
|
||||
},
|
||||
audit,
|
||||
};
|
||||
const execution: ActionExecutionResponse = {
|
||||
actionId: 'action/docker/restart',
|
||||
|
|
@ -269,6 +297,15 @@ describe('ActionAuditAPI', () => {
|
|||
success: true,
|
||||
output: 'container restarted',
|
||||
},
|
||||
audit: {
|
||||
...audit,
|
||||
updatedAt: '2026-06-12T20:41:00Z',
|
||||
state: 'completed',
|
||||
result: {
|
||||
success: true,
|
||||
output: 'container restarted',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce(decision).mockResolvedValueOnce(execution);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import {
|
|||
DEFAULT_RESOURCE_DETAIL_DRAWER_PRESENTATION,
|
||||
type ResourceDetailDrawerPresentation,
|
||||
} from './resourceDetailDrawerPresentation';
|
||||
import { DockerContainerLifecycleControls } from '@/features/docker/DockerContainerLifecycleControls';
|
||||
import { isDockerContainerLifecycleResource } from '@/features/docker/dockerContainerLifecycleActions';
|
||||
|
||||
interface ResourceDetailDrawerProps {
|
||||
resource: Resource;
|
||||
|
|
@ -32,6 +34,7 @@ interface ResourceDetailDrawerProps {
|
|||
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
|
||||
initialShowAccessContext?: boolean;
|
||||
initialShowTrueNASDetails?: boolean;
|
||||
onResourceActionSettled?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const TabAvailabilityNotice: Component<{ message: string }> = (props) => (
|
||||
|
|
@ -106,6 +109,13 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
|||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<Show when={isDockerContainerLifecycleResource(props.resource)}>
|
||||
<DockerContainerLifecycleControls
|
||||
resource={props.resource}
|
||||
surface="resource-detail"
|
||||
onActionSettled={props.onResourceActionSettled}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={drawer.assistantAvailable()}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -353,6 +363,7 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
|
|||
resolveResourceLabel={props.resolveResourceLabel}
|
||||
initialShowAccessContext={props.initialShowAccessContext}
|
||||
initialShowTrueNASDetails={props.initialShowTrueNASDetails}
|
||||
onResourceActionSettled={props.onResourceActionSettled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ResourceDetailDrawer } from '../ResourceDetailDrawer';
|
||||
|
||||
|
|
@ -32,6 +33,37 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/resourceActions', () => ({
|
||||
ResourceActionsAPI: {
|
||||
planAction: vi.fn().mockResolvedValue({
|
||||
actionId: 'detail-action-1',
|
||||
requestId: 'detail-request-1',
|
||||
allowed: true,
|
||||
requiresApproval: true,
|
||||
approvalPolicy: 'admin',
|
||||
rollbackAvailable: false,
|
||||
}),
|
||||
decideAction: vi.fn().mockResolvedValue({
|
||||
actionId: 'detail-action-1',
|
||||
outcome: 'approved',
|
||||
decidedAt: '2026-06-12T20:00:01Z',
|
||||
}),
|
||||
executeAction: vi.fn().mockResolvedValue({
|
||||
actionId: 'detail-action-1',
|
||||
state: 'completed',
|
||||
result: { success: true },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const resource = (overrides: Partial<Resource>): Resource =>
|
||||
({
|
||||
id: overrides.id ?? 'app-container-1',
|
||||
|
|
@ -52,6 +84,70 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe('ResourceDetailDrawer for Docker containers', () => {
|
||||
it('renders governed lifecycle controls in Docker container details', async () => {
|
||||
const onResourceActionSettled = vi.fn();
|
||||
|
||||
render(() => (
|
||||
<ResourceDetailDrawer
|
||||
presentation="table-row"
|
||||
onResourceActionSettled={onResourceActionSettled}
|
||||
resource={resource({
|
||||
id: 'app-container-web',
|
||||
name: 'edge-web',
|
||||
docker: {
|
||||
agentId: 'agent-edge',
|
||||
containerId: 'abc123def456',
|
||||
containerState: 'running',
|
||||
runtime: 'docker',
|
||||
image: 'ghcr.io/example/edge-web:2026.05',
|
||||
},
|
||||
capabilities: [
|
||||
{
|
||||
name: 'restart',
|
||||
type: 'common',
|
||||
platform: 'docker',
|
||||
minimumApprovalLevel: 'admin',
|
||||
},
|
||||
],
|
||||
sourceStatus: { docker: { status: 'online' } },
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
const restartButton = screen.getByRole('button', {
|
||||
name: 'Restart edge-web through governed action',
|
||||
});
|
||||
expect(restartButton.closest('[data-docker-container-actions-surface]')).toHaveAttribute(
|
||||
'data-docker-container-actions-surface',
|
||||
'resource-detail',
|
||||
);
|
||||
|
||||
fireEvent.click(restartButton);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Click again to restart edge-web' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(ResourceActionsAPI.planAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: 'app-container-web',
|
||||
capabilityName: 'restart',
|
||||
requestedBy: 'ui:resource-detail',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(ResourceActionsAPI.executeAction).toHaveBeenCalledWith(
|
||||
'detail-action-1',
|
||||
expect.stringContaining('from the resource details'),
|
||||
),
|
||||
);
|
||||
expect(ResourceActionsAPI.decideAction).toHaveBeenCalledWith(
|
||||
'detail-action-1',
|
||||
'approved',
|
||||
expect.stringContaining('restart Docker container edge-web'),
|
||||
);
|
||||
await waitFor(() => expect(onResourceActionSettled).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('adds a metrics history tab for app-containers with a metrics target', async () => {
|
||||
render(() => (
|
||||
<ResourceDetailDrawer
|
||||
|
|
|
|||
|
|
@ -2904,6 +2904,13 @@ describe('shared primitive guardrails', () => {
|
|||
);
|
||||
expect(platformResourceDetailTableRowSource).toContain('subjectLabel={`details for');
|
||||
expect(platformResourceDetailTableRowSource).toContain('SummaryRowActionButton');
|
||||
expect(platformResourceDetailTableRowSource).toContain(
|
||||
'onResourceActionSettled?: () => void | Promise<void>',
|
||||
);
|
||||
expect(platformResourceDetailTableRowSource).toContain(
|
||||
'onResourceActionSettled={props.onResourceActionSettled}',
|
||||
);
|
||||
expect(platformResourceDetailTableRowSource).not.toContain('ResourceActionsAPI');
|
||||
|
||||
for (const source of [
|
||||
dockerHostsTableSource,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,21 @@ import {
|
|||
type DockerContainerLifecycleAction,
|
||||
} from './dockerContainerLifecycleActions';
|
||||
|
||||
export type DockerContainerLifecycleSurface = 'docker-page' | 'resource-detail';
|
||||
|
||||
export type DockerContainerLifecycleSettledContext = {
|
||||
action: DockerContainerLifecycleAction;
|
||||
actionId: string;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
export type DockerContainerLifecycleControlsProps = {
|
||||
resource: Resource;
|
||||
class?: string;
|
||||
surface?: DockerContainerLifecycleSurface;
|
||||
onActionSettled?: (context: DockerContainerLifecycleSettledContext) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const buttonBaseClass =
|
||||
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border text-muted transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface';
|
||||
const enabledButtonClass =
|
||||
|
|
@ -47,7 +62,15 @@ const iconForAction = (action: DockerContainerLifecycleAction): Component<{ clas
|
|||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error && error.message.trim() ? error.message.trim() : 'Action failed';
|
||||
|
||||
export const DockerContainerLifecycleControls: Component<{ resource: Resource }> = (props) => {
|
||||
const surfaceLabel = (surface: DockerContainerLifecycleSurface | undefined): string =>
|
||||
surface === 'resource-detail' ? 'resource details' : 'Docker page';
|
||||
|
||||
const requestedByForSurface = (surface: DockerContainerLifecycleSurface | undefined): string =>
|
||||
surface === 'resource-detail' ? 'ui:resource-detail' : 'ui:docker-page';
|
||||
|
||||
export const DockerContainerLifecycleControls: Component<DockerContainerLifecycleControlsProps> = (
|
||||
props,
|
||||
) => {
|
||||
const [confirmingAction, setConfirmingAction] =
|
||||
createSignal<DockerContainerLifecycleAction | null>(null);
|
||||
const [runningAction, setRunningAction] = createSignal<DockerContainerLifecycleAction | null>(
|
||||
|
|
@ -69,7 +92,9 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
|
|||
|
||||
const containerName = dockerContainerLifecycleName(props.resource);
|
||||
const runtimeLabel = dockerContainerRuntimeLabel(props.resource);
|
||||
const reason = `${action} ${runtimeLabel} container ${containerName} from the Docker page.`;
|
||||
const reason = `${action} ${runtimeLabel} container ${containerName} from the ${surfaceLabel(
|
||||
props.surface,
|
||||
)}.`;
|
||||
setRunningAction(action);
|
||||
setConfirmingAction(null);
|
||||
setLastError('');
|
||||
|
|
@ -81,7 +106,7 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
|
|||
capabilityName: action,
|
||||
params: {},
|
||||
reason,
|
||||
requestedBy: 'ui:docker-page',
|
||||
requestedBy: requestedByForSurface(props.surface),
|
||||
});
|
||||
if (!plan.allowed) {
|
||||
throw new Error(plan.message || 'Pulse refused the action plan.');
|
||||
|
|
@ -93,6 +118,15 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
|
|||
if (result.result && !result.result.success) {
|
||||
throw new Error(result.result.errorMessage || 'The action did not complete successfully.');
|
||||
}
|
||||
try {
|
||||
await props.onActionSettled?.({
|
||||
action,
|
||||
actionId: plan.actionId,
|
||||
resource: props.resource,
|
||||
});
|
||||
} catch {
|
||||
notificationStore.warning('Action requested. Refresh container inventory to verify state.');
|
||||
}
|
||||
setCompletedAction(action);
|
||||
window.setTimeout(
|
||||
() => setCompletedAction((current) => (current === action ? null : current)),
|
||||
|
|
@ -128,7 +162,11 @@ export const DockerContainerLifecycleControls: Component<{ resource: Resource }>
|
|||
};
|
||||
|
||||
return (
|
||||
<div class="inline-flex items-center justify-end gap-1" data-prevent-toggle>
|
||||
<div
|
||||
class={`inline-flex items-center justify-end gap-1 ${props.class ?? ''}`.trim()}
|
||||
data-prevent-toggle
|
||||
data-docker-container-actions-surface={props.surface ?? 'docker-page'}
|
||||
>
|
||||
<For each={DOCKER_CONTAINER_LIFECYCLE_ACTIONS}>
|
||||
{(spec) => {
|
||||
const disabled = () =>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ import { DockerContainerLifecycleControls } from './DockerContainerLifecycleCont
|
|||
type DockerNetwork = NonNullable<NonNullable<Resource['docker']>['networks']>[number];
|
||||
type DockerMount = NonNullable<NonNullable<Resource['docker']>['mounts']>[number];
|
||||
|
||||
type DockerContainersTableProps = DockerNativeTableProps & {
|
||||
onLifecycleActionSettled?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
const networkLabel = (network: DockerNetwork): string => {
|
||||
const name = asTrimmedString(network.name);
|
||||
const address = asTrimmedString(network.ipv4) || asTrimmedString(network.ipv6);
|
||||
|
|
@ -182,7 +186,7 @@ const containerUpdateAction = (
|
|||
};
|
||||
};
|
||||
|
||||
export const DockerContainersTable: Component<DockerNativeTableProps> = (props) => {
|
||||
export const DockerContainersTable: Component<DockerContainersTableProps> = (props) => {
|
||||
const breakpoint = useBreakpoint();
|
||||
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
|
||||
const tableState = createPlatformTableFilterState({
|
||||
|
|
@ -538,7 +542,10 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
|||
case 'actions':
|
||||
return (
|
||||
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
|
||||
<DockerContainerLifecycleControls resource={resource} />
|
||||
<DockerContainerLifecycleControls
|
||||
resource={resource}
|
||||
onActionSettled={props.onLifecycleActionSettled}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
|
|
@ -567,6 +574,7 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
|||
colSpan={drawerColSpan()}
|
||||
resolveResourceLabel={resolveResourceLabel}
|
||||
onClose={() => drawer.close(resource)}
|
||||
onResourceActionSettled={props.onLifecycleActionSettled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ export function DockerPageSurface() {
|
|||
const serverVersionDisplay = createMemo(() =>
|
||||
formatAgentVersionDisplay(agentUpdateTargetVersion()),
|
||||
);
|
||||
const refreshDockerResources = async () => {
|
||||
await refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="docker-page" class="space-y-3">
|
||||
|
|
@ -136,6 +139,7 @@ export function DockerPageSurface() {
|
|||
hostFilter={hostFilter()}
|
||||
containers={model().containers}
|
||||
incidents={model().incidents}
|
||||
onLifecycleActionSettled={refreshDockerResources}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'images'}>
|
||||
|
|
@ -307,6 +311,7 @@ function DockerOverview(props: {
|
|||
hostFilter: string;
|
||||
containers: ReturnType<typeof buildDockerPageModel>['containers'];
|
||||
incidents: ReturnType<typeof buildDockerPageModel>['incidents'];
|
||||
onLifecycleActionSettled?: () => void | Promise<void>;
|
||||
}) {
|
||||
const hostMatchesFilter = (resource: Resource, hostFilter: string): boolean => {
|
||||
const normalized = hostFilter.trim();
|
||||
|
|
@ -338,6 +343,7 @@ function DockerOverview(props: {
|
|||
emptyIcon={dockerIcon()}
|
||||
emptyTitle="No Docker or Podman containers"
|
||||
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
|
||||
onLifecycleActionSettled={props.onLifecycleActionSettled}
|
||||
/>
|
||||
<Show when={props.incidents.length > 0}>
|
||||
<DockerAlertsTable
|
||||
|
|
|
|||
|
|
@ -453,6 +453,8 @@ describe('Docker native tables', () => {
|
|||
});
|
||||
|
||||
it('runs Docker lifecycle row actions through the governed action API', async () => {
|
||||
const onLifecycleActionSettled = vi.fn();
|
||||
|
||||
renderInRouter(() => (
|
||||
<DockerContainersTable
|
||||
resources={[
|
||||
|
|
@ -490,6 +492,7 @@ describe('Docker native tables', () => {
|
|||
emptyTitle="No containers"
|
||||
emptyDescription="No containers"
|
||||
showToolbar={false}
|
||||
onLifecycleActionSettled={onLifecycleActionSettled}
|
||||
/>
|
||||
));
|
||||
|
||||
|
|
@ -519,6 +522,7 @@ describe('Docker native tables', () => {
|
|||
'approved',
|
||||
expect.stringContaining('restart Docker container edge-web'),
|
||||
);
|
||||
expect(onLifecycleActionSettled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows disabled Docker lifecycle buttons with explicit unavailable reasons', () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Resource } from '@/types/resource';
|
|||
import {
|
||||
dockerContainerLifecycleCapability,
|
||||
getDockerContainerLifecycleDisabledReason,
|
||||
isDockerContainerLifecycleResource,
|
||||
} from '../dockerContainerLifecycleActions';
|
||||
|
||||
const resource = (overrides: Partial<Resource> = {}): Resource => ({
|
||||
|
|
@ -44,6 +45,7 @@ describe('dockerContainerLifecycleActions', () => {
|
|||
it('enables advertised runtime-matched capabilities', () => {
|
||||
const running = resource();
|
||||
|
||||
expect(isDockerContainerLifecycleResource(running)).toBe(true);
|
||||
expect(dockerContainerLifecycleCapability(running, 'restart')?.name).toBe('restart');
|
||||
expect(getDockerContainerLifecycleDisabledReason(running, 'restart')).toBeUndefined();
|
||||
expect(getDockerContainerLifecycleDisabledReason(running, 'stop')).toBeUndefined();
|
||||
|
|
@ -52,6 +54,33 @@ describe('dockerContainerLifecycleActions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('only identifies Docker and Podman app containers as lifecycle resources', () => {
|
||||
expect(
|
||||
isDockerContainerLifecycleResource(
|
||||
resource({
|
||||
platformType: 'truenas',
|
||||
sources: ['truenas'],
|
||||
docker: { runtime: 'docker', containerState: 'running' },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDockerContainerLifecycleResource(
|
||||
resource({ type: 'docker-host', docker: { runtime: 'docker' } }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDockerContainerLifecycleResource(
|
||||
resource({ docker: { runtime: 'containerd', containerState: 'running' } }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDockerContainerLifecycleResource(
|
||||
resource({ docker: { runtime: undefined, containerState: 'running' } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('enables start for stopped containers and explains non-running stop/restart states', () => {
|
||||
const stopped = resource({
|
||||
status: 'offline',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ const STALE_SOURCE_STATUSES = new Set(['stale', 'offline', 'missing']);
|
|||
|
||||
const normalizeToken = (value: unknown): string => (asTrimmedString(value) ?? '').toLowerCase();
|
||||
|
||||
export const isDockerContainerLifecycleResource = (resource: Resource): boolean => {
|
||||
if (resource.type !== 'app-container') return false;
|
||||
const runtime = normalizeToken(resource.docker?.runtime);
|
||||
const hasDockerSource =
|
||||
normalizeToken(resource.platformType) === 'docker' ||
|
||||
(resource.sources ?? []).some((source) => normalizeToken(source) === 'docker') ||
|
||||
(resource.platformScopes ?? []).some((scope) => normalizeToken(scope) === 'docker');
|
||||
return hasDockerSource && (runtime === 'docker' || runtime === 'podman');
|
||||
};
|
||||
|
||||
export const dockerContainerLifecycleName = (resource: Resource): string =>
|
||||
(asTrimmedString(resource.name) ||
|
||||
asTrimmedString(resource.displayName) ||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export const PlatformResourceDetailTableRow: Component<{
|
|||
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
|
||||
initialShowAccessContext?: boolean;
|
||||
initialShowTrueNASDetails?: boolean;
|
||||
onResourceActionSettled?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
}> = (props) => {
|
||||
const initialShowTrueNASDetails = () =>
|
||||
|
|
@ -114,6 +115,7 @@ export const PlatformResourceDetailTableRow: Component<{
|
|||
resolveResourceLabel={props.resolveResourceLabel}
|
||||
initialShowAccessContext={props.initialShowAccessContext}
|
||||
initialShowTrueNASDetails={initialShowTrueNASDetails()}
|
||||
onResourceActionSettled={props.onResourceActionSettled}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</InlineDetailTableRow>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue