Files
pulse/frontend-modern/src/components/shared/useContainerUpdateButtonState.ts
T
rcourtman c4433edadf 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.
2026-07-17 14:14:38 +01:00

205 lines
6.8 KiB
TypeScript

import { createEffect, createMemo, createSignal } from 'solid-js';
import { ResourceActionsAPI } from '@/api/resourceActions';
import {
clearContainerUpdateState,
getContainerUpdateState,
markContainerQueued,
markContainerUpdateError,
markContainerUpdateSuccess,
updateStates,
} from '@/stores/containerUpdates';
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
import { getActionReadinessRefusal } from '@/utils/actionReadiness';
import type { ActionDetailResponse } from '@/types/actionAudit';
import {
getUpdateButtonLabel,
getUpdateButtonTooltip,
getUpdatePlanErrorMessage,
hasContainerUpdate,
hasContainerUpdateCurrent,
hasContainerUpdateError,
type UpdateButtonProps,
type UpdateState,
} from './containerUpdateBadgeModel';
const newUpdateRequestId = (): string =>
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `container-update-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const ACTION_TERMINAL_STATES = ['completed', 'failed', 'rejected', 'expired'];
export function useContainerUpdateButtonState(props: UpdateButtonProps) {
const [localState, setLocalState] = createSignal<'idle' | 'confirming' | 'planning'>('idle');
const [errorMessage, setErrorMessage] = createSignal('');
const [reviewDetail, setReviewDetail] = createSignal<ActionDetailResponse | null>(null);
const settingsLoaded = () => areSystemSettingsLoaded();
const shouldHideButton = () => shouldHideDockerUpdateActions();
const storeState = createMemo(() => {
updateStates();
return getContainerUpdateState(props.agentId, props.containerId);
});
const currentState = (): UpdateState => {
const stored = storeState();
if (stored) {
switch (stored.state) {
case 'queued':
case 'updating':
return 'updating';
case 'success':
return 'success';
case 'error':
return 'error';
}
}
if (props.externalState === 'updating' || props.externalState === 'queued') return 'updating';
if (props.externalState === 'error') return 'error';
const local = localState();
return local === 'planning' ? 'updating' : local;
};
createEffect(() => {
const stored = storeState();
if (stored && (stored.state === 'queued' || stored.state === 'updating')) {
if (props.updateStatus?.updateAvailable === false) {
markContainerUpdateSuccess(props.agentId, props.containerId);
}
}
});
const hasUpdate = () =>
hasContainerUpdate(props.updateStatus) ||
hasContainerUpdateError(props.updateStatus) ||
hasContainerUpdateCurrent(props.updateStatus) ||
currentState() !== 'idle';
// 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
// the container's update capability and opens the review dialog, which owns
// approval and execution. The legacy direct-update endpoint is retired.
const planUpdateReview = async () => {
const resourceId = (props.resourceId ?? '').trim();
if (!resourceId) {
const message = 'Container update action is unavailable for this row.';
setErrorMessage(message);
markContainerUpdateError(props.agentId, props.containerId, message);
return;
}
setLocalState('planning');
try {
const plan = await ResourceActionsAPI.planAction({
requestId: newUpdateRequestId(),
resourceId,
capabilityName: 'update',
params: {},
reason: `Update container ${props.containerName} to its latest image.`,
requestedBy: 'ui:container-update',
});
if (!plan.allowed) {
throw new Error(plan.message || 'Pulse refused the update plan.');
}
setReviewDetail(await ResourceActionsAPI.getAction(plan.actionId));
setLocalState('idle');
} catch (error) {
const message = getUpdatePlanErrorMessage(error);
setErrorMessage(message);
setLocalState('idle');
markContainerUpdateError(props.agentId, props.containerId, message);
}
};
const handleClick = async (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
const state = currentState();
if (state === 'updating' || state === 'success' || state === 'error') return;
if (isUpdateUnavailable()) return;
if (state === 'idle') {
setLocalState('confirming');
return;
}
if (state === 'confirming') {
await planUpdateReview();
}
};
const handleCancel = (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
setLocalState('idle');
clearContainerUpdateState(props.agentId, props.containerId);
};
const handleReviewClosed = () => {
setReviewDetail(null);
};
const handleReviewChanged = (detail: ActionDetailResponse) => {
setReviewDetail(detail);
const state = detail.audit.state;
if (state === 'executing') {
markContainerQueued(props.agentId, props.containerId);
props.onUpdateTriggered?.();
return;
}
if (!ACTION_TERMINAL_STATES.includes(state)) return;
if (state === 'completed') {
markContainerQueued(props.agentId, props.containerId);
props.onUpdateTriggered?.();
} else if (state === 'failed') {
const message = 'The update action failed; open Actions for the audit trail.';
setErrorMessage(message);
markContainerUpdateError(props.agentId, props.containerId, message);
}
};
return {
buttonLabel,
buttonTooltip,
currentState,
handleCancel,
handleClick,
handleReviewChanged,
handleReviewClosed,
hasUpdate,
isButtonDisabled,
isUpdateUnavailable,
reviewDetail,
settingsLoaded,
shouldHideButton,
};
}