diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 308d1470e2..4772ac4a62 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index a22df85cf1..3804b3df91 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -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,
diff --git a/frontend-modern/browser-verification/a33fb3954747f9d2.json b/frontend-modern/browser-verification/a33fb3954747f9d2.json
new file mode 100644
index 0000000000..dcad5c670f
--- /dev/null
+++ b/frontend-modern/browser-verification/a33fb3954747f9d2.json
@@ -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"
+ ]
+}
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index 7973df8bb9..527c07f433 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -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) {
diff --git a/frontend-modern/src/features/docker/DockerAlertsTable.tsx b/frontend-modern/src/features/docker/DockerAlertsTable.tsx
index 95168b2761..295ab82f03 100644
--- a/frontend-modern/src/features/docker/DockerAlertsTable.tsx
+++ b/frontend-modern/src/features/docker/DockerAlertsTable.tsx
@@ -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
Started
@@ -276,9 +278,11 @@ export const DockerAlertsTable: Component<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
= (props) => {
+ const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as KubernetesIncidentSeverityFilter,
@@ -288,7 +290,9 @@ export const KubernetesAlertsTable: Component<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
{
- 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;
diff --git a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
index 6fce7bebc7..30b1cd1476 100644
--- a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
@@ -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<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
= (props) => {
+ const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as VmwareIncidentSeverityFilter,
@@ -308,7 +310,9 @@ export const VsphereAlertsTable: Component<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
index d62aa6f3f7..1ef9afea0d 100644
--- a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
+++ b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
@@ -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 =>
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(() => (
+ }
+ 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(() => (
+ }
+ 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();
+ });
});
diff --git a/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts b/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts
index e0c568abae..4fa2af944e 100644
--- a/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts
+++ b/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts
@@ -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)', () => {
diff --git a/frontend-modern/src/utils/__tests__/relativeTimeClock.test.ts b/frontend-modern/src/utils/__tests__/relativeTimeClock.test.ts
new file mode 100644
index 0000000000..5a92bbd0a3
--- /dev/null
+++ b/frontend-modern/src/utils/__tests__/relativeTimeClock.test.ts
@@ -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);
+ });
+});
diff --git a/frontend-modern/src/utils/alertDetailPresentation.ts b/frontend-modern/src/utils/alertDetailPresentation.ts
index 4dcd883bd0..ed57208313 100644
--- a/frontend-modern/src/utils/alertDetailPresentation.ts
+++ b/frontend-modern/src/utils/alertDetailPresentation.ts
@@ -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 {
diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts
index c8d180b13e..b6f3ba94ec 100644
--- a/frontend-modern/src/utils/format.ts
+++ b/frontend-modern/src/utils/format.ts
@@ -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);
}
diff --git a/frontend-modern/src/utils/relativeTimeClock.ts b/frontend-modern/src/utils/relativeTimeClock.ts
new file mode 100644
index 0000000000..43cd334e7e
--- /dev/null
+++ b/frontend-modern/src/utils/relativeTimeClock.ts
@@ -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 | 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;
+}