fix(frontend): disable the container update button up front when the server refuses the update capability

The lifecycle buttons already read resource.actionReadiness and render
disabled with the refusal reason before any click. The update button did
not, so a user with a stale or disconnected agent could click Update,
confirm, and only then get the refusal from POST /api/actions/plan
(GitHub #1564). Wire the update button to the same per-resource
readiness data: extract the readiness-refusal resolver shared with the
lifecycle buttons into utils/actionReadiness.ts, thread actionReadiness
through UpdateButtonProps at both render sites (Docker containers table
and the workloads GuestRow via useWorkloads), and render the button
disabled with the refusal reason as its tooltip. The post-click
details.reason path from b3058d47e stays as the backstop for
websocket-hydrated rows, which carry no readiness data until the REST
revalidation lands.
This commit is contained in:
rcourtman
2026-07-17 14:14:38 +01:00
parent b3058d47ea
commit c4433edadf
11 changed files with 212 additions and 34 deletions
@@ -778,6 +778,7 @@ export function GuestRow(props: GuestRowProps) {
containerId={props.guest.containerId ?? ''}
containerName={props.guest.name}
resourceId={props.guest.id}
actionReadiness={props.guest.actionReadiness}
compact={true}
/>
</Show>
@@ -190,7 +190,7 @@ export const UpdateButton: Component<UpdateButtonProps> = (props) => {
<div class="inline-flex items-center gap-1" data-prevent-toggle>
<button
type="button"
class={getUpdateButtonClass(state.currentState())}
class={getUpdateButtonClass(state.currentState(), state.isUpdateUnavailable())}
onClick={state.handleClick}
onMouseDown={(e) => {
e.stopPropagation();
@@ -86,6 +86,57 @@ describe('ContainerUpdateBadge', () => {
expect(screen.getByRole('button', { name: /update/i })).toBeInTheDocument();
});
it('disables the button with the refusal reason when the server refuses the update capability', () => {
render(() => (
<UpdateButton
agentId="agent-1"
containerId="container-1"
containerName="web"
resourceId="resource-1"
actionReadiness={[
{
name: 'update',
available: false,
reasonCode: 'command_agent_disconnected',
reason: 'The Pulse agent on this host is still on an older version.',
},
]}
updateStatus={{
updateAvailable: true,
currentDigest: 'sha256:current',
latestDigest: 'sha256:latest',
lastChecked: 0,
}}
/>
));
const button = screen.getByRole('button', { name: /update unavailable/i });
expect(button).toBeDisabled();
expect(button.getAttribute('aria-label')).toContain(
'The Pulse agent on this host is still on an older version.',
);
});
it('keeps the button enabled when the update readiness entry is available', () => {
render(() => (
<UpdateButton
agentId="agent-1"
containerId="container-1"
containerName="web"
resourceId="resource-1"
actionReadiness={[{ name: 'restart', available: false, reason: 'restart refused' }]}
updateStatus={{
updateAvailable: true,
currentDigest: 'sha256:current',
latestDigest: 'sha256:latest',
lastChecked: 0,
}}
/>
));
expect(screen.getByRole('button', { name: /update/i })).not.toBeDisabled();
});
it('renders a visible current state when the checked image is up to date', () => {
render(() => (
<UpdateButton
@@ -1,4 +1,5 @@
import type { DockerContainerUpdateStatus } from '@/types/api';
import type { ResourceActionReadiness } from '@/types/resource';
export interface ContainerUpdateBadgeProps {
updateStatus?: DockerContainerUpdateStatus;
@@ -18,6 +19,10 @@ export interface UpdateButtonProps {
// Unified resource id used to plan the audited update action. Without it
// the button can only report that the update action is unavailable.
resourceId?: string;
// Server-evaluated readiness from the unified resource. When it carries an
// unavailable 'update' entry the button renders disabled with the refusal
// reason, instead of letting the click fail at POST /api/actions/plan.
actionReadiness?: ResourceActionReadiness[];
compact?: boolean;
onUpdateTriggered?: () => void;
externalState?: 'updating' | 'queued' | 'error';
@@ -80,7 +85,10 @@ export function getUpdateIconTooltip(updateStatus?: DockerContainerUpdateStatus)
return `Update available\nCurrent: ${current}...\nLatest: ${latest}...`;
}
export function getUpdateButtonClass(state: UpdateState): string {
export function getUpdateButtonClass(state: UpdateState, unavailable = false): string {
if (unavailable && (state === 'idle' || state === 'confirming')) {
return `${UPDATE_BUTTON_BASE_CLASS} bg-surface-alt text-muted cursor-not-allowed opacity-70`;
}
switch (state) {
case 'confirming':
return `${UPDATE_BUTTON_BASE_CLASS} bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-900`;
@@ -9,6 +9,7 @@ import {
updateStates,
} from '@/stores/containerUpdates';
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
import { getActionReadinessRefusal } from '@/utils/actionReadiness';
import type { ActionDetailResponse } from '@/types/actionAudit';
import {
getUpdateButtonLabel,
@@ -75,16 +76,32 @@ export function useContainerUpdateButtonState(props: UpdateButtonProps) {
hasContainerUpdateError(props.updateStatus) ||
hasContainerUpdateCurrent(props.updateStatus) ||
currentState() !== 'idle';
const isButtonDisabled = () => currentState() === 'updating' || !settingsLoaded();
const buttonTooltip = () =>
!settingsLoaded()
? 'Loading settings...'
: getUpdateButtonTooltip({
state: currentState(),
updateStatus: props.updateStatus,
storeState: storeState(),
errorMessage: errorMessage(),
});
// Server-evaluated refusal for the update capability (agent disconnected,
// agent too old, stale inventory). Mirrors the lifecycle buttons: render
// disabled with the reason instead of letting the click fail at plan time.
// Only gates the actionable states; in-flight and settled states keep their
// own presentation.
const updateUnavailableReason = (): string | undefined => {
const state = currentState();
if (state !== 'idle' && state !== 'confirming') return undefined;
return getActionReadinessRefusal(props.actionReadiness, 'update');
};
const isUpdateUnavailable = () => Boolean(updateUnavailableReason());
const isButtonDisabled = () =>
currentState() === 'updating' || !settingsLoaded() || isUpdateUnavailable();
const buttonTooltip = () => {
if (!settingsLoaded()) return 'Loading settings...';
const refusal = updateUnavailableReason();
if (refusal) return `Update unavailable: ${refusal}`;
return getUpdateButtonTooltip({
state: currentState(),
updateStatus: props.updateStatus,
storeState: storeState(),
errorMessage: errorMessage(),
});
};
const buttonLabel = () => getUpdateButtonLabel(currentState(), settingsLoaded());
// Updates run as audited actions: the confirming click plans an action for
@@ -127,6 +144,7 @@ export function useContainerUpdateButtonState(props: UpdateButtonProps) {
const state = currentState();
if (state === 'updating' || state === 'success' || state === 'error') return;
if (isUpdateUnavailable()) return;
if (state === 'idle') {
setLocalState('confirming');
@@ -178,6 +196,7 @@ export function useContainerUpdateButtonState(props: UpdateButtonProps) {
handleReviewClosed,
hasUpdate,
isButtonDisabled,
isUpdateUnavailable,
reviewDetail,
settingsLoaded,
shouldHideButton,
@@ -601,6 +601,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
containerName={updateAction().containerName}
updateStatus={updateAction().updateStatus}
resourceId={resource.id}
actionReadiness={resource.actionReadiness}
/>
)}
</Show>
@@ -1,4 +1,5 @@
import type { Resource, ResourceCapability } from '@/types/resource';
import { getActionReadinessRefusal } from '@/utils/actionReadiness';
import { asTrimmedString } from '@/utils/stringUtils';
export type DockerContainerLifecycleAction = 'start' | 'stop' | 'restart';
@@ -92,28 +93,7 @@ const stateDisabledReason = (
const actionReadinessDisabledReason = (
resource: Resource,
action: DockerContainerLifecycleAction,
): string | undefined => {
const readiness = resource.actionReadiness?.find(
(item) => normalizeToken(item.name) === action && item.available === false,
);
if (!readiness) return undefined;
const reason = asTrimmedString(readiness.reason);
if (reason) return reason;
switch (normalizeToken(readiness.reasonCode)) {
case 'command_agent_disconnected':
return 'Docker / Podman command agent is not connected.';
case 'command_agent_unavailable':
return 'Docker / Podman command execution is not available.';
case 'stale_inventory':
return 'Docker / Podman inventory is not fresh enough to run lifecycle actions.';
case 'host_policy_blocked':
return 'Docker / Podman host policy blocks mutating lifecycle actions.';
case 'unsupported_handler':
return 'This container action is not routed through the supported lifecycle executor.';
default:
return undefined;
}
};
): string | undefined => getActionReadinessRefusal(resource.actionReadiness, action);
export const getDockerContainerLifecycleDisabledReason = (
resource: Resource,
@@ -19,6 +19,7 @@ import {
} from '@/utils/resourceIdentity';
import type { WorkloadGuest } from '@/types/workloads';
import type {
ResourceActionReadiness,
ResourceAvailabilityMeta,
ResourceDiscoveryReadiness,
ResourceDiscoveryTarget,
@@ -154,6 +155,7 @@ type APIResource = {
};
discoveryReadiness?: ResourceDiscoveryReadiness;
availability?: ResourceAvailabilityMeta;
actionReadiness?: ResourceActionReadiness[];
};
type APIListResponse = {
@@ -547,6 +549,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
? (resource.docker?.runtime || '').trim() || undefined
: undefined,
updateStatus: resource.docker?.updateStatus as WorkloadGuest['updateStatus'] | undefined,
actionReadiness: resource.actionReadiness,
dockerHostId: dockerManagedAppContainer ? resource.docker?.hostSourceId : undefined,
dockerHostName:
workloadType === 'app-container'
+5
View File
@@ -1,5 +1,6 @@
import type { VM, Container, DockerContainerUpdateStatus } from './api';
import type {
ResourceActionReadiness,
ResourceAvailabilityMeta,
ResourceDiscoveryReadiness,
ResourceDiscoveryTarget,
@@ -28,6 +29,10 @@ export type WorkloadGuest = (VM | Container) & {
// ("docker", "podman", etc.), not the owning platform.
containerRuntime?: string;
updateStatus?: DockerContainerUpdateStatus;
// Server-evaluated capability refusals from the unified resource. The
// update button reads this to disable itself with the refusal reason
// (agent disconnected, agent too old, stale inventory) before the click.
actionReadiness?: ResourceActionReadiness[];
// Agent-managed Docker runtime host ID for Docker-specific update actions.
// API-backed platforms such as TrueNAS must not populate this field.
dockerHostId?: string;
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { getActionReadinessRefusal } from '@/utils/actionReadiness';
import type { ResourceActionReadiness } from '@/types/resource';
describe('getActionReadinessRefusal', () => {
it('returns undefined when no readiness list is present', () => {
expect(getActionReadinessRefusal(undefined, 'update')).toBeUndefined();
expect(getActionReadinessRefusal([], 'update')).toBeUndefined();
});
it('ignores entries for other capabilities and entries that are available', () => {
const readiness: ResourceActionReadiness[] = [
{ name: 'restart', available: false, reason: 'restart refused' },
{ name: 'update', available: true, reason: 'should not surface' },
];
expect(getActionReadinessRefusal(readiness, 'update')).toBeUndefined();
});
it('prefers the server-provided reason over the reason-code fallback', () => {
const readiness: ResourceActionReadiness[] = [
{
name: 'update',
available: false,
reasonCode: 'command_agent_disconnected',
reason: 'The Pulse agent on this host is still on an older version.',
},
];
expect(getActionReadinessRefusal(readiness, 'update')).toBe(
'The Pulse agent on this host is still on an older version.',
);
});
it('matches capability names case-insensitively with trimming', () => {
const readiness: ResourceActionReadiness[] = [
{ name: ' Update ', available: false, reason: 'refused' },
];
expect(getActionReadinessRefusal(readiness, 'update')).toBe('refused');
});
it('falls back to a canned message per reason code when reason is blank', () => {
const cases: Array<[string, string]> = [
['command_agent_disconnected', 'Docker / Podman command agent is not connected.'],
['command_agent_unavailable', 'Docker / Podman command execution is not available.'],
[
'stale_inventory',
'Docker / Podman inventory is not fresh enough to run lifecycle actions.',
],
['host_policy_blocked', 'Docker / Podman host policy blocks mutating lifecycle actions.'],
[
'unsupported_handler',
'This container action is not routed through the supported lifecycle executor.',
],
];
for (const [reasonCode, message] of cases) {
expect(
getActionReadinessRefusal(
[{ name: 'update', available: false, reasonCode, reason: ' ' }],
'update',
),
).toBe(message);
}
});
it('returns undefined for an unknown reason code with no reason text', () => {
expect(
getActionReadinessRefusal(
[{ name: 'update', available: false, reasonCode: 'mystery_code' }],
'update',
),
).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import type { ResourceActionReadiness } from '@/types/resource';
import { asTrimmedString } from '@/utils/stringUtils';
const normalizeToken = (value: unknown): string => (asTrimmedString(value) ?? '').toLowerCase();
/**
* Resolve the server-reported refusal for a capability from the unified
* resource's actionReadiness facet. The server strips capabilities it would
* refuse from `capabilities` and records the refusal here, so a match means
* POST /api/actions/plan would reject the action. Prefers the server-provided
* reason; falls back to a canned message per reason code.
*/
export const getActionReadinessRefusal = (
readinessList: ResourceActionReadiness[] | undefined,
capabilityName: string,
): string | undefined => {
const target = normalizeToken(capabilityName);
const readiness = readinessList?.find(
(item) => normalizeToken(item.name) === target && item.available === false,
);
if (!readiness) return undefined;
const reason = asTrimmedString(readiness.reason);
if (reason) return reason;
switch (normalizeToken(readiness.reasonCode)) {
case 'command_agent_disconnected':
return 'Docker / Podman command agent is not connected.';
case 'command_agent_unavailable':
return 'Docker / Podman command execution is not available.';
case 'stale_inventory':
return 'Docker / Podman inventory is not fresh enough to run lifecycle actions.';
case 'host_policy_blocked':
return 'Docker / Podman host policy blocks mutating lifecycle actions.';
case 'unsupported_handler':
return 'This container action is not routed through the supported lifecycle executor.';
default:
return undefined;
}
};