fix(frontend): surface the refusal reason when a container update plan fails

A refused update plan showed only 'Action execution is unavailable'; the
actionable explanation (agent disconnected, agent too old, stale inventory)
arrives in the API error's details.reason and was dropped. Prefer it in the
failure badge and tooltip. Seen in #1564 where the toast gave the reporter
nothing to act on and Actions showed no record (refusal is pre-audit).
This commit is contained in:
rcourtman
2026-07-17 12:07:19 +01:00
parent 562417f847
commit b3058d47ea
3 changed files with 43 additions and 1 deletions
@@ -7,6 +7,7 @@ import {
ContainerUpdateBadge,
UpdateButton,
} from '@/components/shared/ContainerUpdateBadge';
import { getUpdatePlanErrorMessage } from '@/components/shared/containerUpdateBadgeModel';
vi.mock('@/api/monitoring', () => ({
MonitoringAPI: {
@@ -123,3 +124,29 @@ describe('ContainerUpdateBadge', () => {
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
describe('getUpdatePlanErrorMessage', () => {
it('prefers the availability refusal reason over the generic message', () => {
const error = Object.assign(new Error('Action execution is unavailable'), {
details: {
reasonCode: 'operation_receipt_unsupported',
reason: 'The Pulse agent on this host is still on an older version.',
},
});
expect(getUpdatePlanErrorMessage(error)).toBe(
'The Pulse agent on this host is still on an older version.',
);
});
it('falls back to the error message when no reason detail exists', () => {
expect(getUpdatePlanErrorMessage(new Error('Pulse refused the update plan.'))).toBe(
'Pulse refused the update plan.',
);
});
it('falls back to a default when the error is empty', () => {
expect(getUpdatePlanErrorMessage(new Error(''))).toBe('Failed to plan the update');
expect(getUpdatePlanErrorMessage(null)).toBe('Failed to plan the update');
});
});
@@ -146,3 +146,17 @@ export function getUpdateButtonTooltip(options: {
return `Click to update\nCurrent: ${current}...\nLatest: ${latest}...`;
}
}
/**
* Pick the user-facing message for a failed update plan request.
*
* Availability refusals carry the actionable explanation (agent disconnected,
* agent too old, stale inventory) in the API error's details.reason; the
* top-level message is just "Action execution is unavailable".
*/
export function getUpdatePlanErrorMessage(error: unknown): string {
const apiError = error as (Error & { details?: Record<string, string> }) | null;
return (
apiError?.details?.reason?.trim() || apiError?.message?.trim() || 'Failed to plan the update'
);
}
@@ -13,6 +13,7 @@ import type { ActionDetailResponse } from '@/types/actionAudit';
import {
getUpdateButtonLabel,
getUpdateButtonTooltip,
getUpdatePlanErrorMessage,
hasContainerUpdate,
hasContainerUpdateCurrent,
hasContainerUpdateError,
@@ -113,7 +114,7 @@ export function useContainerUpdateButtonState(props: UpdateButtonProps) {
setReviewDetail(await ResourceActionsAPI.getAction(plan.actionId));
setLocalState('idle');
} catch (error) {
const message = (error as Error).message || 'Failed to plan the update';
const message = getUpdatePlanErrorMessage(error);
setErrorMessage(message);
setLocalState('idle');
markContainerUpdateError(props.agentId, props.containerId, message);