mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
Withdraw failed backup protection assessments (#3177)
A failed posture poll retained the previous green rows, health counts and verification explanation beside an unavailable notice. Withdraw all retained assessments and policy after any batch fails, preserving independently readable restore points and the last accepted result only while a refresh is pending. Add a keyboard-operable, pending-disabled posture-only retry. Cover failed batches, cache remounts and consuming failure/recovery transitions, with desktop and narrow production-browser proof. --- Fleet candidate `20261024T041512Z-web-product` from the `web-product` lane. Reviewed tip `a414f588f6d1b656636d89d248446ad9fc37c31b` on `main`. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
@@ -20,6 +20,17 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Backup posture failure and retry stay source-local (24 October 2026)
|
||||
|
||||
The Backups posture notice offers a keyboard-operable retry with the existing
|
||||
inventory-notice styling, wrapping action and disabled pending state. It
|
||||
refreshes only posture, not independently readable PVE/PBS inventories. Rows,
|
||||
health counts and expanded explanations withdraw failed retained assessments
|
||||
until a complete successful response arrives; independently observed restore
|
||||
points remain usable. Consuming table tests cover the notice and projection
|
||||
together. This changes no shared query cache, route, server policy or execution
|
||||
authority.
|
||||
|
||||
### SSO editor feedback and exact group names (24 October 2026)
|
||||
|
||||
The provider editor scrolls its fields independently of its header and recovery
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Failed posture refreshes withdraw retained assessments (24 October 2026)
|
||||
|
||||
Protection posture remains visible while a refresh is pending, but a settled
|
||||
failure in any bounded batch withdraws all retained assessments and their
|
||||
policy from the active Coverage projection. Rows and health counts become
|
||||
Unknown; old protection explanations and provider-quality claims disappear.
|
||||
Independently readable backup artifacts remain available. A source-local,
|
||||
disabled-while-pending retry refreshes posture only, and restores assessments
|
||||
only after the complete joined read succeeds. Failed cached results cannot
|
||||
resurrect assessments on remount. The hook and consuming Backups table tests
|
||||
cover these transitions without changing server policy, access scope, backup
|
||||
execution or native guest recovery.
|
||||
|
||||
### Patrol timer checkpoints preserve run and recovery history
|
||||
|
||||
The existing Patrol history envelope retains the next scheduled deadline and
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "9182e7b206f889d29688f41ee2c4a484b21d348f",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
|
||||
"frontend-modern/src/hooks/useProtectionPostures.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "63344ef1136ea314f80c846f5855a2fa5401d527c137f1d0a7c8ac21528a1802",
|
||||
"frontend-modern/src/hooks/useProtectionPostures.ts": "3a99f8501848ba7ecf076708f77b8b42e557d556e7eb7dd232cbfd2851a4e8eb"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/backups/coverage"
|
||||
],
|
||||
"states": [
|
||||
"Accepted protected posture with provider evidence and an independent PVE restore point",
|
||||
"Settled HTTP503 posture poll: Unknown rows and counts, no retained policy/explanation/provider claims, restore point still visible",
|
||||
"Pending posture-only retry: disabled button and Unknown retained",
|
||||
"Successful retry: current attention assessment, explanation and policy restored"
|
||||
],
|
||||
"interactions": [
|
||||
"Expand actual Coverage details by mouse at1440px and row tap at320px",
|
||||
"Advance the browser clock through the existing30second posture poll and return synthetic HTTP503",
|
||||
"Focus retry and press Enter; hold response to inspect disabled pending state, then release a new assessment",
|
||||
"Check that PVE/PBS inventory requests remain one each, posture requests are three, no horizontal overflow or page errors"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"height": 1000
|
||||
}
|
||||
],
|
||||
"verified_at": "2026-10-24T04:27:45Z",
|
||||
"browsers": [
|
||||
"Chromium via Playwright1.56.1",
|
||||
"WebKit via Playwright1.56.1"
|
||||
],
|
||||
"evidence": "Lane RECEIPTS.md links raw browser results, scripts and nine natively inspected captures. Full production app with synthetic local read-only HTTP fixtures; not native sign-in, guest recovery or original hosted-run replay."
|
||||
}
|
||||
@@ -726,9 +726,23 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<div class="space-y-3">
|
||||
<PlatformSectionTabs tabs={backupViewTabs()} active={view()} ariaLabel="Backup views" />
|
||||
<Show when={protectionPostures.response.error}>
|
||||
<div role="status" class={BACKUP_READ_NOTICE_CLASS}>
|
||||
Protection posture is unavailable. Pulse is showing restore evidence without
|
||||
guessing whether workloads are protected.
|
||||
<div
|
||||
role="status"
|
||||
class={`${BACKUP_READ_NOTICE_CLASS} flex flex-wrap items-center justify-between gap-2`}
|
||||
>
|
||||
<span>
|
||||
Protection posture is unavailable. Pulse is showing restore evidence without
|
||||
guessing whether workloads are protected.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-10 shrink-0 rounded-md border border-current px-3 font-medium hover:underline focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50"
|
||||
aria-label="Retry protection posture"
|
||||
disabled={protectionPostures.response.loading}
|
||||
onClick={() => void protectionPostures.refetch()}
|
||||
>
|
||||
{protectionPostures.response.loading ? 'Retrying…' : 'Retry protection posture'}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import { TABLE_CARD_FRAME_CLASS } from '@/components/shared/TableCard';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { ProtectionPosturesResponse } from '@/types/recovery';
|
||||
import { getRecoveryFullDateLabel } from '@/utils/recoveryDatePresentation';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
@@ -1545,3 +1546,66 @@ describe('Coverage qualifies missing restore evidence through the consuming API
|
||||
expect(detail).not.toHaveTextContent('No restore points');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Coverage withdraws failed protection assessments', () => {
|
||||
it('replaces retained healthy rows, counts and explanations with Unknown, then retries only posture', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||
try {
|
||||
mockBackupAPIs();
|
||||
window.history.replaceState({}, '', '/proxmox/backups/coverage');
|
||||
renderInRouter(() => (
|
||||
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[workloadResource]} />
|
||||
));
|
||||
const row = () =>
|
||||
document.querySelector('[data-proxmox-backup-row="coverage"]') as HTMLElement;
|
||||
await waitFor(() => expect(row()).toHaveTextContent('Protected'));
|
||||
const accepted = (await apiFetchJSONMock.mock.results[0].value) as ProtectionPosturesResponse;
|
||||
expect(screen.getByRole('button', { name: '1 protected' })).toBeInTheDocument();
|
||||
fireEvent.click(within(row()).getByRole('button', { name: /expand details/i }));
|
||||
const detail = await screen.findByTestId('proxmox-coverage-detail');
|
||||
expect(detail).toHaveTextContent('A current verified backup is available.');
|
||||
|
||||
apiFetchJSONMock.mockRejectedValue(new Error('Synthetic posture refresh failed'));
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await screen.findByText(/Protection posture is unavailable/);
|
||||
expect(row()).toHaveTextContent('Unknown');
|
||||
expect(row()).not.toHaveTextContent('Protected');
|
||||
expect(screen.getByText('protected', { exact: true }).closest('li')).toHaveTextContent(
|
||||
/0\s*protected/,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '1 unknown' })).toBeInTheDocument();
|
||||
expect(detail).not.toHaveTextContent('A current verified backup is available.');
|
||||
expect(detail).not.toHaveTextContent('History Complete');
|
||||
expect(detail).toHaveTextContent('Restore evidence');
|
||||
expect(within(detail).getByText('PVE file', { exact: true })).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-coverage-strip-footnote]')).toBeNull();
|
||||
|
||||
const pending = deferred<typeof accepted>();
|
||||
apiFetchJSONMock.mockReturnValue(pending.promise);
|
||||
const retry = screen.getByRole('button', { name: 'Retry protection posture' });
|
||||
fireEvent.click(retry);
|
||||
expect(retry).toBeDisabled();
|
||||
expect(retry).toHaveTextContent('Retrying');
|
||||
expect(row()).toHaveTextContent('Unknown');
|
||||
fireEvent.click(retry);
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledTimes(3);
|
||||
pending.resolve({
|
||||
...accepted,
|
||||
data: accepted.data.map((posture) => ({
|
||||
...posture,
|
||||
state: 'attention',
|
||||
explanation: 'The current backup verification needs attention.',
|
||||
})),
|
||||
});
|
||||
await waitFor(() => expect(row()).toHaveTextContent(/attention/i));
|
||||
expect(detail).toHaveTextContent('The current backup verification needs attention.');
|
||||
expect(screen.queryByText(/Protection posture is unavailable/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '1 attention' })).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-coverage-strip-footnote]')).not.toBeNull();
|
||||
expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pve')).toHaveLength(1);
|
||||
expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pbs')).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
normalizeProtectionPostureResourceIDs,
|
||||
useProtectionPostures,
|
||||
} from '@/hooks/useProtectionPostures';
|
||||
import type { ProtectionPosturesResponse } from '@/types/recovery';
|
||||
import type { ProtectionPosturesResponse, ProtectionState } from '@/types/recovery';
|
||||
|
||||
const apiFetchJSONMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: apiFetchJSONMock }));
|
||||
@@ -97,4 +97,106 @@ describe('useProtectionPostures transport', () => {
|
||||
// count or limit would only be invented client-side with no reader.
|
||||
expectTypeOf<keyof ProtectionPosturesResponse>().toEqualTypeOf<'data' | 'policy'>();
|
||||
});
|
||||
|
||||
it.each<ProtectionState>(['protected', 'attention', 'unprotected', 'unknown'])(
|
||||
'withdraws a retained %s assessment if any batch fails, until a complete retry succeeds',
|
||||
async (state) => {
|
||||
const resourceIDs = Array.from({ length: 201 }, (_, index) => `vm:${index}`);
|
||||
const response = (url: string): ProtectionPosturesResponse => ({
|
||||
data: new URL(url, 'https://pulse.invalid').searchParams
|
||||
.getAll('resourceId')
|
||||
.map((subjectResourceId) => ({
|
||||
subjectResourceId,
|
||||
state,
|
||||
freshness: 'current',
|
||||
verification: 'verified',
|
||||
coverage: 'complete',
|
||||
providerStates: [],
|
||||
repositoryResourceIds: [],
|
||||
evidenceIds: [],
|
||||
explanation: 'Previous assessment',
|
||||
evaluatedAt: '2026-10-24T04:15:12Z',
|
||||
})),
|
||||
policy: {
|
||||
freshnessWindowSeconds: 604800,
|
||||
verificationWindowSeconds: 3600,
|
||||
requireVerification: true,
|
||||
},
|
||||
});
|
||||
apiFetchJSONMock.mockImplementation(async (url: string) => response(url));
|
||||
let dispose = () => {};
|
||||
const hook = createRoot((rootDispose) => {
|
||||
dispose = rootDispose;
|
||||
return useProtectionPostures(() => resourceIDs);
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(hook.postures()).toHaveLength(201));
|
||||
const failure = new Error('Second batch failed');
|
||||
apiFetchJSONMock.mockImplementation(async (url: string) => {
|
||||
if (new URL(url, 'https://pulse.invalid').searchParams.getAll('resourceId').length === 1)
|
||||
throw failure;
|
||||
return response(url);
|
||||
});
|
||||
const failedRead = hook.refetch();
|
||||
expect(hook.postures()).toHaveLength(201);
|
||||
await failedRead;
|
||||
expect(hook.response.error).toBe(failure);
|
||||
expect(hook.resolvedOnce()).toBe(true);
|
||||
expect(hook.postures()).toEqual([]);
|
||||
expect(hook.postureByResourceID().size).toBe(0);
|
||||
expect(hook.policy().freshnessWindowSeconds).toBe(0);
|
||||
|
||||
const pending: (() => void)[] = [];
|
||||
apiFetchJSONMock.mockImplementation(
|
||||
(url: string) =>
|
||||
new Promise<ProtectionPosturesResponse>((resolve) => {
|
||||
pending.push(() => resolve(response(url)));
|
||||
}),
|
||||
);
|
||||
const retry = hook.refetch();
|
||||
expect(hook.response.loading).toBe(true);
|
||||
expect(hook.postures()).toEqual([]);
|
||||
expect(pending).toHaveLength(2);
|
||||
pending[0]();
|
||||
await Promise.resolve();
|
||||
expect(hook.postures()).toEqual([]);
|
||||
pending[1]();
|
||||
await retry;
|
||||
expect(hook.response.error).toBeNull();
|
||||
expect(hook.postures()).toHaveLength(201);
|
||||
expect(hook.policy().freshnessWindowSeconds).toBe(604800);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('does not restore a failed cached assessment when Coverage remounts', async () => {
|
||||
apiFetchJSONMock.mockResolvedValue({
|
||||
data: [{ subjectResourceId: 'vm:1', state: 'protected' }],
|
||||
policy: { freshnessWindowSeconds: 604800 },
|
||||
});
|
||||
let dispose = () => {};
|
||||
const mount = () =>
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose;
|
||||
return useProtectionPostures(() => ['vm:1']);
|
||||
});
|
||||
const original = mount();
|
||||
await vi.waitFor(() => expect(original.postures()).toHaveLength(1));
|
||||
apiFetchJSONMock.mockRejectedValue(new Error('Refresh failed'));
|
||||
await original.refetch();
|
||||
dispose();
|
||||
|
||||
apiFetchJSONMock.mockImplementation(() => new Promise(() => {}));
|
||||
const remounted = mount();
|
||||
try {
|
||||
expect(remounted.response.error).toBeTruthy();
|
||||
expect(remounted.postures()).toEqual([]);
|
||||
expect(remounted.postureByResourceID().size).toBe(0);
|
||||
expect(remounted.policy().freshnessWindowSeconds).toBe(0);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,11 +83,16 @@ export function useProtectionPostures(resourceIDs: Accessor<readonly string[] |
|
||||
pollMs: REFRESH_MS,
|
||||
});
|
||||
|
||||
const postures = createMemo<ProtectionPosture[]>(() => state.value().data ?? []);
|
||||
// A retained assessment is useful while a refresh is pending, not after
|
||||
// that read fails. Withdraw the entire joined result (including its policy)
|
||||
// so rows, health counts and explanations cannot still claim protection
|
||||
// beside the unavailable notice. A successful full retry restores it.
|
||||
const acceptedResponse = createMemo(() => (state.error() ? EMPTY_RESPONSE : state.value()));
|
||||
const postures = createMemo<ProtectionPosture[]>(() => acceptedResponse().data ?? []);
|
||||
const postureByResourceID = createMemo<ReadonlyMap<string, ProtectionPosture>>(
|
||||
() => new Map(postures().map((posture) => [posture.subjectResourceId, posture] as const)),
|
||||
);
|
||||
const policy = createMemo(() => state.value().policy ?? EMPTY_POLICY);
|
||||
const policy = createMemo(() => acceptedResponse().policy ?? EMPTY_POLICY);
|
||||
|
||||
return {
|
||||
response: {
|
||||
|
||||
Reference in New Issue
Block a user