mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
Keep open guest drawers truthful about completed backups
Age the existing completed-backup readout and policy tone on the shared passive clock. Show elapsed age and the validated UTC completion time without clipping; keep activity, absence and invalid times separate. Cover default and custom policy boundaries, clock changes, tab round trips and retained completion with connected controls and desktop/phone browser proof. Change-source: pulse-maintainer
This commit is contained in:
@@ -9416,3 +9416,21 @@ provider precedence, stopping/recovery and zero. The local
|
||||
Workloads snapshot adapter/drawer, History and CSS on desktop and phone, with
|
||||
synthetic GET responses. These controls do not establish native restart, thaw,
|
||||
covered-filesystem writes, installed recovery or containing software.
|
||||
|
||||
### Guest backup completion readout (12 October 2026)
|
||||
|
||||
The existing guest Overview's Last completed backup row shows elapsed age
|
||||
(hours/minutes for recent points, days for older ones), never a rolling-day
|
||||
Today/Yesterday claim. A validated completion also has visible UTC date/time
|
||||
in a semantic `time` element with its original ISO timestamp. Invalid, future
|
||||
or absent times produce no completion element. The row still distinguishes
|
||||
running activity from completed protection and preserves the policy's tone;
|
||||
the passive clock may rejudge a previously future timestamp when it becomes
|
||||
valid, but cannot supply new completion evidence.
|
||||
|
||||
`GuestDrawer.backup-clock.test.tsx` and the connected drawer/model controls
|
||||
cover these states and update on time alone. Desktop Chromium and touch-phone
|
||||
WebKit exercise the real policy producer, consuming drawer, shared clock and
|
||||
existing table evidence. No route, guest check, backup, threshold, collector or
|
||||
permission changes. This presentation result does not establish thaw, covered
|
||||
filesystem writes, native restart recovery or containing released software.
|
||||
|
||||
@@ -4116,3 +4116,21 @@ notification is introduced. Current snapshot/delta fast paths retain their
|
||||
existing scope; only availability complete-record replacement changes, never
|
||||
other partial provider facets or independent metric updates. This is truthful
|
||||
presentation and scoped invalidation, not a measured whole-fleet CPU saving.
|
||||
|
||||
### Open guest drawers age completed backups passively (12 October 2026)
|
||||
|
||||
`useGuestDrawerState` derives completed-backup age and policy tone from the
|
||||
existing shared 30-second clock, not just a new guest snapshot. This adds a
|
||||
clock subscription for the mounted drawer, not a timer per row, network read,
|
||||
refresh, guest probe or alert evaluation. Disposal releases the subscription.
|
||||
The supplied completed timestamp is immutable under ticks and backup activity;
|
||||
only replacement completion evidence changes it. Configured fresh/stale hours
|
||||
retain their existing inclusive boundaries and unknown/absent distinctions.
|
||||
|
||||
`GuestDrawer.backup-clock.test.tsx` covers passive default/custom policy
|
||||
crossings, unchanged completion time, tab round trips, later completion,
|
||||
future/backwards-clock uncertainty and timer disposal. Connected
|
||||
`GuestDrawer.test.tsx` and `guestDrawerModel.branchcov2.test.ts` retain existing
|
||||
backup-activity, configured policy and caution/absence controls. Browser proof
|
||||
uses the real read-only AlertsAPI/store and existing drawer/table badge with
|
||||
synthetic timestamps, not native backup safety, guest recovery or availability.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "0d8ade2b3b8cf73a60eb9efe07534e45f1d7072a",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "801aa50aec4c0ec7b801f4f14da60ef9c9a56a886f6df3f509f3fc844ecf4dca",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "66ada43fb44fcdbdbbde5640f21ed92d84115f27011a4d49bdcc8b711e0dc088",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f3a446f40d39212d00dcbef049918297f211fdf5b8ee3b96094bf6438cbe17a9"
|
||||
},
|
||||
"routes": [
|
||||
"/tmp/backup-clock/index.html (isolated existing production guest drawer/table fixture, no product route added)"
|
||||
],
|
||||
"states": [
|
||||
"unchanged VM/LXC completion across a saved one-hour freshness boundary",
|
||||
"default 24-hour policy covered by connected source controls",
|
||||
"running/ended activity separate from prior completion",
|
||||
"new completion then passive minute ageing",
|
||||
"absent/invalid/future timestamps",
|
||||
"previous-calendar-day completion"
|
||||
],
|
||||
"interactions": [
|
||||
"read synthetic saved policy through actual AlertsAPI/store",
|
||||
"advance passive browser clock without API requests; both drawer and table reclassify",
|
||||
"Manage/Overview round trip retains original completed time",
|
||||
"replace completion evidence without remounting the workload",
|
||||
"keyboard and touch open existing table evidence; Escape closes and restores focus",
|
||||
"inspect six final PNG captures with native view_image at original detail"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"verified_at": "2026-10-12T16:49:58.639554Z",
|
||||
"proof_scope": "Read-only synthetic alert policy through real AlertsAPI/store, guest drawer state/model/Overview, existing backup table badge and production CSS. Passive Playwright clocks, no native backup/recovery or availability claim.",
|
||||
"browser_proof": {
|
||||
"checks": 32,
|
||||
"loaded_production_hashes": 115,
|
||||
"captures": 6,
|
||||
"client_exit": 0,
|
||||
"playwright": "1.56.1",
|
||||
"browsers": [
|
||||
{
|
||||
"name": "desktop-light",
|
||||
"version": "141.0.7390.37",
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "phone-dark",
|
||||
"version": "26.0",
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"desktop-light-context": "closed",
|
||||
"desktop-light-browser": "closed",
|
||||
"phone-dark-context": "closed",
|
||||
"phone-dark-browser": "closed",
|
||||
"server": "closed"
|
||||
},
|
||||
"dossier": "RECEIPTS.md / browser/result.json and native-image-review.json"
|
||||
}
|
||||
}
|
||||
@@ -319,12 +319,12 @@ describe('GuestDrawer', () => {
|
||||
const details = technicalDetails();
|
||||
expect(details.getByText('Backup time unavailable: invalid timestamp.')).toBeVisible();
|
||||
expect(details.queryByText('No completed backup found')).not.toBeInTheDocument();
|
||||
expect(details.queryByText('Today')).not.toBeInTheDocument();
|
||||
expect(details.queryByText('1 hour ago')).not.toBeInTheDocument();
|
||||
setGuest({ ...guest(), lastBackup: Date.now() + 86_400_000, backupInProgress: true });
|
||||
expect(details.getByText(/timestamp is in the future/)).toBeVisible();
|
||||
expect(details.getByText('Running · not completed yet')).toBeVisible();
|
||||
setGuest({ ...guest(), lastBackup: Date.now() - 3600_000, backupInProgress: false });
|
||||
expect(details.getByText('Today')).toBeVisible();
|
||||
expect(details.getByText('1 hour ago')).toBeVisible();
|
||||
expect(details.queryByText(/unavailable/)).not.toBeInTheDocument();
|
||||
setGuest({ ...guest(), lastBackup: 0 });
|
||||
expect(details.getByText('No completed backup found')).toBeVisible();
|
||||
@@ -1011,17 +1011,17 @@ describe('GuestDrawer', () => {
|
||||
vi.setSystemTime(new Date('2026-03-02T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('shows "Today" for a backup from today', () => {
|
||||
it('shows elapsed hours for a recent backup', () => {
|
||||
const now = new Date('2026-03-02T10:00:00Z').getTime();
|
||||
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Last completed backup')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('Today')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('2 hours ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Yesterday" for a 1-day-old backup', () => {
|
||||
it('shows elapsed days for a 1-day-old backup', () => {
|
||||
const yesterday = new Date('2026-03-01T12:00:00Z').getTime();
|
||||
render(() => <GuestDrawer guest={makeGuest({ lastBackup: yesterday })} onClose={vi.fn()} />);
|
||||
expect(technicalDetails().getByText('Yesterday')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('1d ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Xd ago" for older backups', () => {
|
||||
@@ -1054,7 +1054,7 @@ describe('GuestDrawer', () => {
|
||||
it('applies green color for recent backups', () => {
|
||||
const now = new Date('2026-03-02T10:00:00Z').getTime();
|
||||
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
|
||||
const ageCell = technicalDetails().getByText('Today').closest('td');
|
||||
const ageCell = technicalDetails().getByText('2 hours ago').closest('td');
|
||||
expect(ageCell).toHaveClass('text-emerald-700');
|
||||
});
|
||||
|
||||
@@ -1080,7 +1080,7 @@ describe('GuestDrawer', () => {
|
||||
|
||||
it.each([
|
||||
['2026-02-20T12:00:00Z', '10d ago', 'text-amber-700'],
|
||||
['2026-03-02T10:00:00Z', 'Today', 'text-emerald-700'],
|
||||
['2026-03-02T10:00:00Z', '2 hours ago', 'text-emerald-700'],
|
||||
])('preserves completed backup age and tone during a new backup (%s)', (date, age, tone) => {
|
||||
solidRender(() => (
|
||||
<GuestDrawer
|
||||
@@ -1108,7 +1108,9 @@ describe('GuestDrawer', () => {
|
||||
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
|
||||
setGuest({ ...guest(), lastBackup: new Date('2026-03-02T10:00:00Z').getTime() });
|
||||
expect(technicalDetails().queryByText('No completed backup found')).not.toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('Today').closest('td')).toHaveClass('text-emerald-700');
|
||||
expect(technicalDetails().getByText('2 hours ago').closest('td')).toHaveClass(
|
||||
'text-emerald-700',
|
||||
);
|
||||
expect(container.querySelector('[data-testid="guest-technical-details"]')).toBe(drawer);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getGuestDrawerMemoryReading,
|
||||
getGuestDrawerGuestReadPresentation,
|
||||
isGuestDrawerVM,
|
||||
type GuestDrawerBackupPresentation,
|
||||
} from './guestDrawerModel';
|
||||
import type { NestedWorkloadContext } from './nestedWorkloadContext';
|
||||
import { WORKLOAD_ACTION_AGENT_LABEL } from './workloadAgentReadiness';
|
||||
@@ -59,11 +60,7 @@ interface GuestDrawerOverviewProps {
|
||||
networkInterfaces: NonNullable<GuestDrawerProps['guest']['networkInterfaces']>;
|
||||
nestedWorkloadContext?: NestedWorkloadContext;
|
||||
normalizedTags: string[];
|
||||
backupPresentation: {
|
||||
ageClass: string;
|
||||
ageLabel: string;
|
||||
dateLabel: string;
|
||||
} | null;
|
||||
backupPresentation: GuestDrawerBackupPresentation | null;
|
||||
diskThresholds?: MetricDisplayThresholds | null;
|
||||
workloadActionAgentTitle: string;
|
||||
parentMemoryTotal?: GuestDrawerProps['parentMemoryTotal'];
|
||||
@@ -143,7 +140,23 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
// backup. Keep its activity separate from the last completed age.
|
||||
makeDetailRow('Last completed backup', protectionPresentation().label, {
|
||||
tone: protectionPresentation().tone,
|
||||
layout: 'stacked',
|
||||
wrap: true,
|
||||
valueContent: (
|
||||
<>
|
||||
<span class="block whitespace-normal wrap-break-word text-left leading-snug">
|
||||
{protectionPresentation().label}
|
||||
</span>
|
||||
<Show when={props.backupPresentation?.completedAt}>
|
||||
<time
|
||||
class="mt-1 block whitespace-normal wrap-break-word text-left font-normal leading-snug text-muted"
|
||||
dateTime={props.backupPresentation?.completedAt}
|
||||
>
|
||||
{props.backupPresentation?.dateLabel}
|
||||
</time>
|
||||
</Show>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
makeDetailRow(
|
||||
'Backup activity',
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
|
||||
import { GuestDrawer } from '../GuestDrawer';
|
||||
import { useGuestDrawerState } from '../useGuestDrawerState';
|
||||
|
||||
const policy = vi.hoisted(() => ({ freshHours: 24, staleHours: 72 }));
|
||||
const calls = vi.hoisted(() => ({ history: vi.fn() }));
|
||||
vi.mock('@/stores/alertsActivation', () => ({
|
||||
useAlertsActivation: () => ({
|
||||
getBackupThresholds: () => policy,
|
||||
getMetricThresholds: () => null,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/api/charts', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/charts')>('@/api/charts');
|
||||
return { ...actual, ChartsAPI: { ...actual.ChartsAPI, getMetricsHistory: calls.history } };
|
||||
});
|
||||
vi.mock('@/api/resourceOperatorState', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/resourceOperatorState')>(
|
||||
'@/api/resourceOperatorState',
|
||||
);
|
||||
return { ...actual, getResourceOperatorState: vi.fn().mockResolvedValue(null) };
|
||||
});
|
||||
vi.mock('@/stores/license', () => ({
|
||||
isRangeLocked: () => false,
|
||||
loadRuntimeCapabilities: vi.fn(),
|
||||
maxHistoryDays: () => 90,
|
||||
}));
|
||||
|
||||
const start = Date.parse('2026-10-12T12:00:00Z');
|
||||
const hour = 3600_000;
|
||||
const guest = (type: 'qemu' | 'lxc', lastBackup: number | string): WorkloadGuest => ({
|
||||
id: 'lab:pve-a:101',
|
||||
name: 'backup-clock-fixture',
|
||||
vmid: 101,
|
||||
instance: 'lab',
|
||||
node: 'pve-a',
|
||||
type,
|
||||
status: 'running',
|
||||
cpu: 0.1,
|
||||
cpus: 1,
|
||||
memory: { total: 1024, used: 256, free: 768, usage: 25 },
|
||||
disk: { total: 1024, used: 256, free: 768, usage: 25 },
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
tags: [],
|
||||
lastBackup,
|
||||
});
|
||||
const details = () => within(screen.getByTestId('guest-technical-details'));
|
||||
const backupRow = () => details().getByText('Last completed backup').closest('tr')!;
|
||||
const completion = () => backupRow().querySelector('time');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(start);
|
||||
policy.freshHours = 24;
|
||||
policy.staleHours = 72;
|
||||
calls.history.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('mounted completed-backup evidence', () => {
|
||||
it('invalidates the saved age policy on time alone, independently of new copy or markup', () => {
|
||||
function StateProbe() {
|
||||
const state = useGuestDrawerState({
|
||||
guest: guest('qemu', start - 24 * hour + 5_000),
|
||||
onClose: () => {},
|
||||
});
|
||||
return <span data-testid="age-policy">{state.backupPresentation().ageClass}</span>;
|
||||
}
|
||||
render(() => <StateProbe />);
|
||||
expect(screen.getByTestId('age-policy')).toHaveTextContent('text-green-600');
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(screen.getByTestId('age-policy')).toHaveTextContent('text-yellow-600');
|
||||
});
|
||||
it.each(['qemu', 'lxc'] as const)(
|
||||
'ages an unchanged %s backup across the default policy boundary',
|
||||
(type) => {
|
||||
const timestamp = start - 24 * hour + 5_000;
|
||||
render(() => <GuestDrawer guest={guest(type, timestamp)} onClose={() => {}} />);
|
||||
const drawer = screen.getByTestId('guest-technical-details');
|
||||
const row = backupRow();
|
||||
expect(within(row).getByText('23 hours ago').closest('td')).toHaveClass('text-emerald-700');
|
||||
const originalTime = completion();
|
||||
expect(originalTime).toHaveAttribute('datetime', new Date(timestamp).toISOString());
|
||||
expect(originalTime).toHaveTextContent('2026-10-11 12:00:05 UTC');
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(within(backupRow()).getByText('1d ago').closest('td')).toHaveClass('text-amber-700');
|
||||
expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
|
||||
expect(screen.getByTestId('guest-technical-details')).toBe(drawer);
|
||||
expect(calls.history).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['qemu', 'lxc'] as const)(
|
||||
'ages an unchanged %s backup under a one-hour policy',
|
||||
(type) => {
|
||||
policy.freshHours = 1;
|
||||
policy.staleHours = 3;
|
||||
render(() => <GuestDrawer guest={guest(type, start - hour + 5_000)} onClose={() => {}} />);
|
||||
expect(details().getByText('59 mins ago').closest('td')).toHaveClass('text-emerald-700');
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(details().getByText('1 hour ago').closest('td')).toHaveClass('text-amber-700');
|
||||
expect(calls.history).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not label a previous-calendar-day completion Today', () => {
|
||||
vi.setSystemTime(Date.parse('2026-10-12T00:30:00Z'));
|
||||
render(() => <GuestDrawer guest={guest('qemu', '2026-10-11T23:30:00Z')} onClose={() => {}} />);
|
||||
expect(details().getByText('1 hour ago')).toBeVisible();
|
||||
expect(completion()).toHaveTextContent('2026-10-11 23:30:00 UTC');
|
||||
expect(details().queryByText('Today')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps activity separate and ages while another drawer tab is open', () => {
|
||||
const timestamp = start - 24 * hour + 5_000;
|
||||
const [current, setCurrent] = createSignal({
|
||||
...guest('qemu', timestamp),
|
||||
backupInProgress: true,
|
||||
});
|
||||
render(() => <GuestDrawer guest={current()} onClose={() => {}} />);
|
||||
expect(details().getByText('Running · not completed yet')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
setCurrent({ ...current(), backupInProgress: false });
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Overview' }));
|
||||
expect(details().getByText('1d ago').closest('td')).toHaveClass('text-amber-700');
|
||||
expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
|
||||
expect(details().queryByText('Running · not completed yet')).not.toBeInTheDocument();
|
||||
setCurrent({ ...current(), lastBackup: start + RELATIVE_TIME_TICK_MS });
|
||||
expect(details().getByText('0s ago').closest('td')).toHaveClass('text-emerald-700');
|
||||
expect(completion()).toHaveAttribute('datetime', '2026-10-12T12:00:30.000Z');
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(details().getByText('1 min ago')).toBeVisible();
|
||||
expect(calls.history).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejudges future and backwards-clock times without manufacturing completion', () => {
|
||||
const timestamp = start + 45_000;
|
||||
render(() => <GuestDrawer guest={guest('qemu', timestamp)} onClose={() => {}} />);
|
||||
expect(
|
||||
details()
|
||||
.getByText(/timestamp is in the future/)
|
||||
.closest('td'),
|
||||
).toHaveClass('text-amber-700');
|
||||
expect(completion()).toBeNull();
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(completion()).toBeNull();
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(details().getByText('15s ago').closest('td')).toHaveClass('text-emerald-700');
|
||||
expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
|
||||
vi.setSystemTime(start - hour);
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(
|
||||
details()
|
||||
.getByText(/timestamp is in the future/)
|
||||
.closest('td'),
|
||||
).toHaveClass('text-amber-700');
|
||||
expect(completion()).toBeNull();
|
||||
expect(details().queryByText('No completed backup found')).not.toBeInTheDocument();
|
||||
expect(calls.history).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, NaN, 'invalid-time'])(
|
||||
'does not turn absent or invalid evidence %s into a dated completion',
|
||||
(timestamp) => {
|
||||
render(() => <GuestDrawer guest={guest('lxc', timestamp)} onClose={() => {}} />);
|
||||
const initial = backupRow().textContent;
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(backupRow()).toHaveTextContent(initial!);
|
||||
expect(completion()).toBeNull();
|
||||
expect(calls.history).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
+14
-7
@@ -686,21 +686,25 @@ describe('guestDrawerModel (branch coverage)', () => {
|
||||
const expectPresentation = (days: number) => {
|
||||
const lastBackup = backupNDaysAgo(days);
|
||||
const result = getGuestDrawerBackupPresentation(lastBackup, undefined, now);
|
||||
expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
|
||||
expect(result.dateLabel).toBe(
|
||||
new Date(lastBackup).toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
|
||||
);
|
||||
expect(result.completedAt).toBe(new Date(lastBackup).toISOString());
|
||||
return result;
|
||||
};
|
||||
|
||||
it('labels day 0 as Today and uses the green (fresh) class', () => {
|
||||
it('labels a just-completed backup with elapsed time and the green (fresh) class', () => {
|
||||
expect(expectPresentation(0)).toStrictEqual({
|
||||
ageClass: 'text-green-600 dark:text-green-400',
|
||||
ageLabel: 'Today',
|
||||
dateLabel: new Date(backupNDaysAgo(0)).toLocaleDateString(),
|
||||
ageLabel: '0s ago',
|
||||
dateLabel: now.toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
|
||||
completedAt: now.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('labels day 1 as Yesterday', () => {
|
||||
it('labels day 1 as elapsed days', () => {
|
||||
const result = expectPresentation(1);
|
||||
expect(result.ageLabel).toBe('Yesterday');
|
||||
expect(result.ageLabel).toBe('1d ago');
|
||||
expect(result.ageClass).toBe('text-green-600 dark:text-green-400');
|
||||
});
|
||||
|
||||
@@ -733,7 +737,10 @@ describe('guestDrawerModel (branch coverage)', () => {
|
||||
const result = getGuestDrawerBackupPresentation(lastBackup, undefined, now);
|
||||
expect(result.ageLabel).toBe('10d ago');
|
||||
expect(result.ageClass).toBe('text-yellow-600 dark:text-yellow-400');
|
||||
expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
|
||||
expect(result.dateLabel).toBe(
|
||||
new Date(lastBackup).toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
|
||||
);
|
||||
expect(result.completedAt).toBe(new Date(lastBackup).toISOString());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface GuestDrawerBackupPresentation {
|
||||
ageClass: string;
|
||||
ageLabel: string;
|
||||
dateLabel: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export const isGuestDrawerVM = (guest: Guest): guest is VM => resolveWorkloadType(guest) === 'vm';
|
||||
@@ -632,7 +633,7 @@ export const normalizeGuestDrawerTags = (tags: Guest['tags']): string[] => {
|
||||
export const getGuestDrawerBackupPresentation = (
|
||||
lastBackup: string | number | Date | null | undefined,
|
||||
thresholds?: BackupThresholds,
|
||||
now: Date = new Date(),
|
||||
now: number | Date = Date.now(),
|
||||
): GuestDrawerBackupPresentation => {
|
||||
const timestamp = lastBackup instanceof Date ? lastBackup.getTime() : lastBackup;
|
||||
const info = getBackupInfo(timestamp, thresholds, now);
|
||||
@@ -644,12 +645,15 @@ export const getGuestDrawerBackupPresentation = (
|
||||
dateLabel: 'Unknown',
|
||||
};
|
||||
}
|
||||
const backupDate = new Date(timestamp!);
|
||||
const completedAt = new Date(timestamp!).toISOString();
|
||||
const daysSince = Math.floor(info.ageMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
ageClass: statusPresentation.color,
|
||||
ageLabel: daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`,
|
||||
dateLabel: backupDate.toLocaleDateString(),
|
||||
// Elapsed age, not a rolling-day calendar claim: a 23-hour-old backup
|
||||
// need not have completed today. Keep older ages in days, like the table.
|
||||
ageLabel: daysSince === 0 ? info.ageFormatted : `${daysSince}d ago`,
|
||||
dateLabel: completedAt.replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'),
|
||||
completedAt,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import type { DisplayMetricType } from '@/utils/metricThresholds';
|
||||
import {
|
||||
getCanonicalWorkloadId,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
|
||||
export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const now = useRelativeTimeNow();
|
||||
const [activeTab, setActiveTab] = createSignal<GuestDrawerTab>('overview');
|
||||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
@@ -84,6 +86,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
getGuestDrawerBackupPresentation(
|
||||
props.guest.lastBackup,
|
||||
alertsActivation.getBackupThresholds(),
|
||||
now(),
|
||||
),
|
||||
);
|
||||
const historyTarget = createMemo(() => getGuestDrawerHistoryTarget(props.guest));
|
||||
|
||||
Reference in New Issue
Block a user