mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Read platform alert Started as an age that keeps moving
The Docker, Kubernetes, TrueNAS and vSphere alert tables showed
Started as "Oct 6, 10:34 AM". It clipped on phones and at 768px. An
open alert's Started cell answers how long it has been going on, so
it now reads as an age ("2h ago"), as alert cards do. The exact time
is the cell's hover text and stays in the drawer. Docker's column
takes the numeric-value alignment the other three use.
Making it an age exposed a wider bug: platform tables keep rows
mounted across data refreshes, so an age computed at render froze
while its timestamp did not change, reading "2h ago" three hours
later. That already applied to every PlatformTableRelativeTimeValue
cell (Kubernetes events, Docker volumes, vSphere activity and others).
A shared clock, useRelativeTimeNow, now ticks every 30 seconds while
any cell reads it and stops when none do. The relative-time cell and
the alert tables pass it to formatRelativeTime as `now`, and the
guardrails pin that.
This commit is contained in:
@@ -5175,6 +5175,21 @@ Docker, Kubernetes, TrueNAS, and vSphere alert tables. Those tables must call
|
||||
`formatPlatformAlertEntityType`, `formatPlatformAlertStartedAt`, and
|
||||
`formatPlatformAlertDetailDateTime` instead of declaring local formatter
|
||||
helpers.
|
||||
`formatPlatformAlertStartedAt` reads as an age ("2h ago"), because an open
|
||||
alert's Started cell answers how long it has been going on, as alert cards do;
|
||||
the absolute "Oct 6, 10:34 AM" also clipped at phone and tablet widths. Each
|
||||
table's Started cell carries `formatPlatformAlertDetailDateTime` as its hover
|
||||
title, the drawer's Started row keeps that exact time, an unset (pre-2000) or
|
||||
invalid time stays "-", and the column uses the `numeric-value` alignment kind
|
||||
on all four tables.
|
||||
Relative ages read one shared clock. `useRelativeTimeNow`
|
||||
(`frontend-modern/src/utils/relativeTimeClock.ts`) ticks every 30 seconds while
|
||||
any mounted cell reads it and stops when none do; `PlatformTableRelativeTimeValue`
|
||||
and the platform alert tables pass it as `now` to `formatRelativeTime` /
|
||||
`formatPlatformAlertStartedAt`. Platform tables keep their rows mounted across
|
||||
data refreshes, so an age computed only at render froze ("2h ago" three hours
|
||||
later) whenever its timestamp did not change. A relative-age cell must read the
|
||||
shared clock rather than `Date.now()` at render or a table-local interval.
|
||||
Read-only metadata badges follow the same primitive-owned shell rule.
|
||||
`frontend-modern/src/components/shared/MetadataBadge.tsx` owns filled and
|
||||
outlined appearances, compact sizing, shape, typed tone vocabulary, fit
|
||||
|
||||
@@ -4293,7 +4293,8 @@ and platform alert detail fields must use
|
||||
`formatPlatformAlertDetailDateTime` from
|
||||
`frontend-modern/src/utils/alertDetailPresentation.ts` instead of restoring
|
||||
table-local provider code, resource-type, entity-type, or timestamp formatter
|
||||
helpers,
|
||||
helpers (the Started cell reads as an age with the exact time as its hover
|
||||
title, the drawer keeps the exact time),
|
||||
and local operator identity labels now split from governed detail summaries:
|
||||
infrastructure tables, selectors, links, and drawer headings must preserve the
|
||||
canonical local instance identity (`displayName`, canonical display name,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "13d604e1c71a4b8388ecaf04ea4dbdcd731f7e0b",
|
||||
"verified_at": "2026-10-06T13:10:03Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
|
||||
"frontend-modern/src/utils/alertDetailPresentation.ts",
|
||||
"frontend-modern/src/utils/format.ts",
|
||||
"frontend-modern/src/utils/relativeTimeClock.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/docker/DockerAlertsTable.tsx": "752554fff4c5091823aed3b5f12434fa45a8c0ca43d5ad4367280dea938c8b4d",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "7af91f77431771b4691a7b4b7e1ebd92656c38dc5aa4ecc72cd0e40f47df7664",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "6315591667690bdf7b588524b5c2506643a3a24d2269b8cf050d83e1e274fa97",
|
||||
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "7361527eedafc6a3e5ef9fbea8689fd802c4bcc487f4741e3c5a10649ea64d03",
|
||||
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "e8154ec75bcbb6daa6f3bf32fd8bc0749d6649bae8e31ac7e3c7be345feebcae",
|
||||
"frontend-modern/src/utils/alertDetailPresentation.ts": "5c3e6beb5df3d255c01b38e566163e630dd93ed1eebb82539c68110c3bf027ea",
|
||||
"frontend-modern/src/utils/format.ts": "7757da44a87231405223b7a88bbf820ed905b4823f7f2955b9c9f7f488672a57",
|
||||
"frontend-modern/src/utils/relativeTimeClock.ts": "877686e957292e07dc233be81b52d97bccfa1fb8aeddc63a82b0abf4d5c5e41f"
|
||||
},
|
||||
"routes": [
|
||||
"/vmware/overview",
|
||||
"/vmware/activity",
|
||||
"/truenas/overview",
|
||||
"/docker/overview",
|
||||
"/kubernetes/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 600,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1000
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack on main 92d946d057 plus this change (rebased onto main 13d604e1c7, whose node-temperature alert and release-preflight changes touch no frontend file here)",
|
||||
"Before: alert-table Started read 'Oct 6, 10:34 AM' and clipped on 2-3 rows at 390 and 768 on TrueNAS and vSphere (by 13-31px)",
|
||||
"After: Started reads as an age ('9m ago', '2h ago') and clips at no width from 390 to 1920 on TrueNAS and vSphere; the cell's hover title is the exact time ('Oct 6, 2026, 11:52 AM') and the drawer's Started row keeps it",
|
||||
"Docker and Kubernetes overviews have no provider incidents in the mock, so an incident started two hours earlier was injected into the API responses (websocket held): both read '2h ago' with the exact time on hover, right-aligned, unclipped at 390, 768 and 1440; Docker's Started column now uses the numeric-value alignment kind like the other three",
|
||||
"Shared clock: with Playwright's page clock installed and the websocket held so no data changed, vSphere Started ages went from '9m ago' to '3h ago' after a 3-hour fast-forward and vSphere Activity from '1d ago' to '2d ago' after 24 hours; before this change the ages stayed frozen (unit test fails without the clock)"
|
||||
],
|
||||
"interactions": [
|
||||
"Measured the Started column and every other column for clipping at six widths",
|
||||
"Read the Started cell's hover title",
|
||||
"Fast-forwarded the page clock with live data held and re-read the ages"
|
||||
]
|
||||
}
|
||||
@@ -2556,6 +2556,10 @@ describe('shared primitive guardrails', () => {
|
||||
expect(source).toContain('formatPlatformAlertResourceType');
|
||||
expect(source).toContain('formatPlatformAlertStartedAt');
|
||||
expect(source).toContain('formatPlatformAlertDetailDateTime');
|
||||
// Started reads as an age on the shared clock, the exact time on hover.
|
||||
expect(source).toContain('useRelativeTimeNow');
|
||||
expect(source).toContain('formatPlatformAlertStartedAt(incident.startedAt, now())');
|
||||
expect(source).toContain('title={formatPlatformAlertDetailDateTime(incident.startedAt)}');
|
||||
expect(source).not.toContain('severityVariant');
|
||||
expect(source).not.toContain('severityTextClass');
|
||||
expect(source).not.toContain('const alertTone');
|
||||
@@ -6448,6 +6452,9 @@ describe('shared primitive guardrails', () => {
|
||||
|
||||
expect(sharedPlatformPageSource).toContain('export function PlatformTableRelativeTimeValue');
|
||||
expect(sharedPlatformPageSource).toContain('formatPlatformTableRelativeTimeValue');
|
||||
// Ages read the shared clock: rows stay mounted across refreshes, so an age
|
||||
// computed only at render froze while its timestamp did not change.
|
||||
expect(sharedPlatformPageSource).toContain('useRelativeTimeNow');
|
||||
expect(sharedPlatformPageSource).toContain('formatRelativeTime(value');
|
||||
|
||||
for (const [path, source] of platformRelativeTimeConsumers) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
formatPlatformAlertResourceType,
|
||||
formatPlatformAlertStartedAt,
|
||||
} from '@/utils/alertDetailPresentation';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
|
||||
import {
|
||||
formatAlertSeverityLabel,
|
||||
@@ -112,6 +113,7 @@ export const DockerAlertsTable: Component<{
|
||||
emptyDescription: string;
|
||||
showToolbar?: boolean;
|
||||
}> = (props) => {
|
||||
const now = useRelativeTimeNow();
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.incidents,
|
||||
initialStatus: 'all' as DockerIncidentSeverityFilter,
|
||||
@@ -186,7 +188,7 @@ export const DockerAlertsTable: Component<{
|
||||
Host
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-10 w-[15%] md:w-[10%]`}
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-10 w-[15%] md:w-[10%]`}
|
||||
>
|
||||
Started
|
||||
</TableHead>
|
||||
@@ -276,9 +278,11 @@ export const DockerAlertsTable: Component<{
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt)}
|
||||
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt, now())}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-narrow-hidden hidden text-base-content xl:table-cell`}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
formatPlatformAlertResourceType,
|
||||
formatPlatformAlertStartedAt,
|
||||
} from '@/utils/alertDetailPresentation';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
|
||||
import {
|
||||
formatAlertSeverityLabel,
|
||||
@@ -116,6 +117,7 @@ export const KubernetesAlertsTable: Component<{
|
||||
emptyDescription: string;
|
||||
showToolbar?: boolean;
|
||||
}> = (props) => {
|
||||
const now = useRelativeTimeNow();
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.incidents,
|
||||
initialStatus: 'all' as KubernetesIncidentSeverityFilter,
|
||||
@@ -288,7 +290,9 @@ export const KubernetesAlertsTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt)}
|
||||
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt, now())}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content xl:table-cell`}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import {
|
||||
matchesSearchTermSplit,
|
||||
splitSearchExclusions,
|
||||
@@ -776,6 +777,7 @@ export type PlatformTableRelativeTimeValueInput = number | string | Date | null
|
||||
export type PlatformTableRelativeTimeValueOptions = {
|
||||
compact?: boolean;
|
||||
emptyText?: string;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export const formatPlatformTableRelativeTimeValue = (
|
||||
@@ -788,6 +790,7 @@ export const formatPlatformTableRelativeTimeValue = (
|
||||
formatRelativeTime(value, {
|
||||
compact: options.compact ?? true,
|
||||
emptyText,
|
||||
now: options.now,
|
||||
}) || emptyText
|
||||
);
|
||||
};
|
||||
@@ -797,8 +800,11 @@ export function PlatformTableRelativeTimeValue(props: {
|
||||
compact?: boolean;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
// Rows stay mounted across refreshes, so the age reads the shared clock to
|
||||
// keep moving while the timestamp itself does not change.
|
||||
const now = useRelativeTimeNow();
|
||||
const options = (): PlatformTableRelativeTimeValueOptions => {
|
||||
const resolved: PlatformTableRelativeTimeValueOptions = {};
|
||||
const resolved: PlatformTableRelativeTimeValueOptions = { now: now() };
|
||||
if (props.compact !== undefined) resolved.compact = props.compact;
|
||||
if (props.emptyText !== undefined) resolved.emptyText = props.emptyText;
|
||||
return resolved;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
formatPlatformAlertResourceType,
|
||||
formatPlatformAlertStartedAt,
|
||||
} from '@/utils/alertDetailPresentation';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
|
||||
import {
|
||||
formatAlertSeverityLabel,
|
||||
@@ -116,6 +117,7 @@ export const TrueNASAlertsTable: Component<{
|
||||
emptyDescription: string;
|
||||
showToolbar?: boolean;
|
||||
}> = (props) => {
|
||||
const now = useRelativeTimeNow();
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.incidents,
|
||||
initialStatus: 'all' as TrueNASIncidentSeverityFilter,
|
||||
@@ -280,7 +282,9 @@ export const TrueNASAlertsTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt)}
|
||||
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt, now())}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content xl:table-cell`}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
formatPlatformAlertResourceType,
|
||||
formatPlatformAlertStartedAt,
|
||||
} from '@/utils/alertDetailPresentation';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
|
||||
import {
|
||||
formatAlertSeverityLabel,
|
||||
@@ -120,6 +121,7 @@ export const VsphereAlertsTable: Component<{
|
||||
// column hides (the row drawer still names it).
|
||||
singleVcenter?: boolean;
|
||||
}> = (props) => {
|
||||
const now = useRelativeTimeNow();
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.incidents,
|
||||
initialStatus: 'all' as VmwareIncidentSeverityFilter,
|
||||
@@ -308,7 +310,9 @@ export const VsphereAlertsTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt)}
|
||||
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
|
||||
{formatPlatformAlertStartedAt(incident.startedAt, now())}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Show when={isExpanded()}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VsphereAlertsTable } from '@/features/vmware/VsphereAlertsTable';
|
||||
import { buildVmwareIncidentRows } from '@/features/vmware/vmwarePageModel';
|
||||
@@ -39,6 +39,7 @@ const makeHost = (overrides: Partial<Resource> = {}): Resource =>
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('VsphereAlertsTable', () => {
|
||||
@@ -171,4 +172,42 @@ describe('VsphereAlertsTable', () => {
|
||||
await fireEvent.click(row);
|
||||
expect(screen.getByText('lab-vcenter')).toBeInTheDocument();
|
||||
});
|
||||
it('reads Started as an age with the exact time on hover', () => {
|
||||
const startedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
const host = makeHost();
|
||||
host.incidents = [{ ...host.incidents![0]!, startedAt }];
|
||||
|
||||
render(() => (
|
||||
<VsphereAlertsTable
|
||||
incidents={buildVmwareIncidentRows([host])}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No signals"
|
||||
emptyDescription="No signals"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
const age = screen.getByText('2h ago');
|
||||
expect(age).toHaveAttribute('title', expect.stringMatching(/\d{4}/));
|
||||
});
|
||||
it('keeps the Started age moving while the row stays mounted', () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-06T12:00:00Z') });
|
||||
const host = makeHost();
|
||||
host.incidents = [{ ...host.incidents![0]!, startedAt: '2026-10-06T10:00:00Z' }];
|
||||
|
||||
render(() => (
|
||||
<VsphereAlertsTable
|
||||
incidents={buildVmwareIncidentRows([host])}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No signals"
|
||||
emptyDescription="No signals"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByText('2h ago')).toBeInTheDocument();
|
||||
|
||||
// No data change: only the clock moves.
|
||||
vi.advanceTimersByTime(3 * 60 * 60 * 1000);
|
||||
expect(screen.getByText('5h ago')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+8
-19
@@ -11,7 +11,7 @@
|
||||
* the provider's presentation table does not cover).
|
||||
* - formatPlatformAlertEntityType: the `normalized === 'host'` arm (line 92)
|
||||
* existing tests cover 'vm' / 'datastore' but never 'host'.
|
||||
* - formatPlatformAlertStartedAt: the valid-date fall-through to toLocaleString
|
||||
* - formatPlatformAlertStartedAt: the valid-date fall-through to a compact age
|
||||
* (line 102 -> 110); existing tests only feed absent / pre-2000 values.
|
||||
* - formatPlatformAlertDetailDateTime: the valid-date fall-through to
|
||||
* toLocaleString (line 117 -> 126); existing tests only feed absent /
|
||||
@@ -98,25 +98,14 @@ describe('platform alert date formatting - present, post-2000 timestamps', () =>
|
||||
else process.env.TZ = previousTz;
|
||||
});
|
||||
|
||||
it('formatPlatformAlertStartedAt renders a valid timestamp, omitting the year (table format)', () => {
|
||||
const value = '2024-03-15T14:30:00Z';
|
||||
const result = formatPlatformAlertStartedAt(value);
|
||||
it('formatPlatformAlertStartedAt renders a valid timestamp as an age (table format)', () => {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
const result = formatPlatformAlertStartedAt(twoHoursAgo);
|
||||
|
||||
// Happy path, not the placeholder and not a raw passthrough.
|
||||
expect(result).not.toBe('-');
|
||||
expect(result).not.toBe(value);
|
||||
|
||||
// The compact table format has no year — the structural difference
|
||||
// from the detail format and from the raw ISO string.
|
||||
expect(result).not.toMatch(/2024/);
|
||||
|
||||
// Under TZ=UTC the day-of-month renders as 15 in every locale.
|
||||
expect(result).toMatch(/15/);
|
||||
|
||||
// Mirrors the exact option set the function hardcodes; pinned to the
|
||||
// concrete en-US rendering under TZ=UTC (the structural regex checks
|
||||
// above carry the locale-independent verification).
|
||||
expect(result).toBe('Mar 15, 02:30 PM');
|
||||
// An open alert's table cell answers how long it has been going on; the
|
||||
// exact time is the hover text and the drawer row.
|
||||
expect(result).toBe('2h ago');
|
||||
expect(result).not.toBe(twoHoursAgo);
|
||||
});
|
||||
|
||||
it('formatPlatformAlertDetailDateTime renders a valid timestamp, including the year (detail format)', () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createRoot } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RELATIVE_TIME_TICK_MS, useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('useRelativeTimeNow', () => {
|
||||
it('ticks while read inside a component and stops when nothing reads it', () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-06T10:00:00Z') });
|
||||
|
||||
const { now, dispose } = createRoot((dispose) => ({ now: useRelativeTimeNow(), dispose }));
|
||||
expect(now()).toBe(Date.parse('2026-10-06T10:00:00Z'));
|
||||
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(now()).toBe(Date.parse('2026-10-06T10:00:30Z'));
|
||||
|
||||
dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('reads the time directly outside a reactive owner', () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-06T10:00:00Z') });
|
||||
const now = useRelativeTimeNow();
|
||||
vi.setSystemTime(new Date('2026-10-06T12:00:00Z'));
|
||||
expect(now()).toBe(Date.parse('2026-10-06T12:00:00Z'));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ResourceType } from '@/types/resource';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
export type PlatformAlertProvider = 'docker' | 'kubernetes' | 'truenas' | 'vmware';
|
||||
|
||||
@@ -95,18 +96,17 @@ export function formatPlatformAlertEntityType(value: string): string {
|
||||
return normalized ? titleCaseToken(normalized) : '-';
|
||||
}
|
||||
|
||||
export function formatPlatformAlertStartedAt(value: string | undefined): string {
|
||||
// An open alert's Started cell answers how long it has been going on, so it
|
||||
// reads as an age ("2h ago"), as alert cards do. The exact time is the cell's
|
||||
// hover text and the drawer's Started row (formatPlatformAlertDetailDateTime).
|
||||
// Tables pass the shared relative-time clock as `now` so the age keeps moving.
|
||||
export function formatPlatformAlertStartedAt(value: string | undefined, now?: number): string {
|
||||
if (!value) return '-';
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (isUnsetDate(parsed)) return '-';
|
||||
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return formatRelativeTime(parsed, { compact: true, emptyText: '-', now }) || '-';
|
||||
}
|
||||
|
||||
export function formatPlatformAlertDetailDateTime(value?: string): string {
|
||||
|
||||
@@ -148,10 +148,12 @@ export function formatAbsoluteTime(timestamp: number | undefined): string {
|
||||
* @param timestamp - Unix ms number, ISO date string, Date object, or undefined
|
||||
* @param options.compact - Use short format: "5m ago" instead of "5 mins ago"
|
||||
* @param options.emptyText - Text for falsy input (default: '')
|
||||
* @param options.now - The time to measure from (default: Date.now()). A cell
|
||||
* that must keep its age current passes the shared relative-time clock.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
timestamp: number | string | Date | undefined,
|
||||
options?: { compact?: boolean; emptyText?: string },
|
||||
options?: { compact?: boolean; emptyText?: string; now?: number },
|
||||
): string {
|
||||
if (!timestamp) return options?.emptyText ?? '';
|
||||
|
||||
@@ -164,7 +166,7 @@ export function formatRelativeTime(
|
||||
ms = timestamp.getTime();
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - ms;
|
||||
const diffMs = (options?.now ?? Date.now()) - ms;
|
||||
return formatTimeDiff(diffMs, options?.compact);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createSignal, getOwner, onCleanup } from 'solid-js';
|
||||
|
||||
// One clock for every relative time on screen ("2h ago"), so ages keep moving
|
||||
// while rows stay mounted. Tables keep their rows across data refreshes, and a
|
||||
// timestamp that does not change would otherwise freeze its age at whatever it
|
||||
// read when the row first rendered. It ticks while anything reads it and stops
|
||||
// when nothing does.
|
||||
export const RELATIVE_TIME_TICK_MS = 30_000;
|
||||
|
||||
const [now, setNow] = createSignal(Date.now());
|
||||
let readers = 0;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
// Inside a component or reactive root, the returned accessor is the shared
|
||||
// ticking signal. Outside one there is nothing to stop a timer, so it reads the
|
||||
// time directly.
|
||||
export function useRelativeTimeNow(): () => number {
|
||||
if (!getOwner()) return () => Date.now();
|
||||
readers += 1;
|
||||
if (timer === undefined) {
|
||||
setNow(Date.now());
|
||||
timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_TICK_MS);
|
||||
}
|
||||
onCleanup(() => {
|
||||
readers -= 1;
|
||||
if (readers === 0 && timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
});
|
||||
return now;
|
||||
}
|
||||
Reference in New Issue
Block a user