Show Alert History as cards when its card is too narrow

Alerts > History chose between its table and its phone cards by the
viewport: the table from 768px. With the Alerts rail open, a 1024px to
roughly 1240px window gives the table a 600px to 800px card, where its
own CSS stacks the three row actions and resource names and messages
break mid-word ("inventory-/api") or end after two lines.

The table section now measures its card with useObservedElementWidth and
shows the cards whenever the card is narrower than 800px, the width from
which the row actions share one line; before the card is measured, the
phone breakpoint decides as before. The cards take that decision as an
`active` signal instead of their own 768px media query, so an open
timeline or resource view still moves into the cards' drawer when they
take over and back to the inline expansion when the table returns. The
decision is a memo so a resize within one layout does not re-run that
move and reopen a drawer the operator closed.
This commit is contained in:
rcourtman
2026-10-15 13:59:11 +01:00
parent fbf5b0c8be
commit 90f819b309
9 changed files with 246 additions and 42 deletions
@@ -1635,6 +1635,21 @@ locale. Cards narrower than 600px, which a window of about 900px with
the rail open produces, are still cramped; a container-driven switch to the
card list is the remaining fix.
The history card's own width now chooses between the table and the cards,
not the viewport. `AlertHistoryTableSection` measures its wrapper with
`useObservedElementWidth` and shows the cards whenever the card is narrower
than `ALERT_HISTORY_TABLE_MIN_WIDTH` (800px, the width from which the three
row actions share one line); until the card is measured, the phone
breakpoint decides as before. A 1024px to roughly 1240px window with the
Alerts rail open, which gave the table a 600px to 800px card with stacked
actions and names broken mid-word, now shows the cards; a 1280px window
(an 837px card) keeps the table. The 600px density and the stacked-action
rule above therefore serve only a card that has not been measured. The cards
take `active` from that decision instead of a 768px media query, so an open
timeline or resource view moves into the cards' drawer when the cards take
over and back to the inline expansion when the table returns
(`AlertHistoryTableSection.test.tsx`, `AlertHistoryMobileList.test.tsx`).
Alert history row timestamps render clock time in the viewer's own locale and
must carry the absolute date and time as a title. The date otherwise lives
only in the day group header, which scrolls out of sight, and a hardcoded
@@ -8933,6 +8933,14 @@ placeholders for the row subject (`alerts.assistant.iconLabel`,
`alerts.assistant.iconLabelAt`); every locale keeps those placeholders
(`i18n.test.ts`).
### Layouts that swap a table for cards follow the container
A surface that swaps a table for a card list decides from the width of the
element that holds them (`useObservedElementWidth`), with the viewport
breakpoint only as the fallback before the first measurement, because side
rails and panels leave a table far less room than the screen width suggests.
Alerts > History switches below an 800px card (`ALERT_HISTORY_TABLE_MIN_WIDTH`).
### Mobile destinations are links and update evidence stays explicit
Fixed mobile destinations and menu destinations that navigate are anchors with
@@ -2519,7 +2519,9 @@
"frontend-modern/src/components/Alerts/WebhookConfig.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertHistoryFrequencyCard.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertHistoryMobileList.test.tsx",
"frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts",
"frontend-modern/src/features/alerts/__tests__/AlertHistoryTableSection.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx",
@@ -0,0 +1,45 @@
{
"version": 1,
"base_sha": "fbf5b0c8beb89ce114b468cceb7f606255f9b33d",
"verified_at": "2026-10-15T13:00:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx",
"frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx"
],
"content_sha256": {
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "e2bfcc34a8a5bb93c38c1cda0e3d7da67a5593d28f89730b9356f91e92cedb4a",
"frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx": "318f48211cf5fd1437299fa194e3a23becc64a788da143390f7b7cd473b15a5b"
},
"routes": [
"/alerts/history"
],
"viewports": [
{
"width": 1280,
"height": 800
},
{
"width": 1024,
"height": 768
},
{
"width": 900,
"height": 768
},
{
"width": 375,
"height": 812
}
],
"states": [
"Before (main): a 1024px window with the Alerts rail open showed the table in a 602px card, names broken mid-word (\"inventory-/api\"), messages cut after two lines and the three row actions stacked",
"After: 1280px window, 837px card, table unchanged; 1024px, 602px card, cards with full names and messages; 900px (rail collapses to tabs), 774px card, cards; 375px phone, cards, no horizontal overflow",
"A timeline opened inline in the table at 1280px reappears as the card drawer at 1024px and returns to the inline expansion at 1280px",
"A card timeline opens the shared drawer at 1024px"
],
"interactions": [
"Resized between 1280, 1024, 900 and 375 and read the layout, card width and visibility of each layout",
"Opened a table timeline, narrowed to cards, widened back; opened and closed a card timeline drawer"
]
}
@@ -1,4 +1,4 @@
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { Show, createEffect, createMemo, createSignal, on, type Accessor } from 'solid-js';
import { aiChatStore } from '@/stores/aiChat';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
@@ -20,6 +20,11 @@ import type { AlertHistoryState } from './useAlertHistoryState';
interface AlertHistoryMobileListProps {
state: AlertHistoryState;
/**
* Whether the cards are the history layout on show. The table section
* decides from its card's width; a list rendered on its own is active.
*/
active?: Accessor<boolean>;
}
type AlertHistoryGroup = ReturnType<AlertHistoryState['groupedAlerts']>[number];
@@ -47,13 +52,16 @@ export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) {
return items;
});
// Only a change of layout may move expansions into the drawer: an accessor
// that reads a width would otherwise rerun this on every resized pixel and
// reopen a drawer the operator just closed.
const active = createMemo(() => props.active?.() ?? true);
// The shared expansion state survives layout changes. Project it into the
// phone's single drawer instead of leaving a hidden desktop expansion open.
onMount(() => {
if (typeof window.matchMedia !== 'function') return;
const viewport = window.matchMedia('(min-width: 768px)');
const synchronizeLayout = () => {
if (viewport.matches || aiChatStore.isOpen) {
// cards' single drawer instead of leaving a hidden table expansion open.
createEffect(
on(active, (isActive) => {
if (!isActive || aiChatStore.isOpen) {
setInvestigation(null);
return;
}
@@ -76,11 +84,8 @@ export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) {
}
}
setInvestigation(null);
};
synchronizeLayout();
viewport.addEventListener('change', synchronizeLayout);
onCleanup(() => viewport.removeEventListener('change', synchronizeLayout));
});
}),
);
const getInvestigationReturnFocusTarget = (
current: MobileAlertHistoryInvestigation,
@@ -177,7 +182,12 @@ export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) {
return (
<>
<div class="space-y-3 md:hidden" data-testid="alert-history-mobile-list" tabindex="-1">
<div
class="space-y-3"
classList={{ hidden: !active() }}
data-testid="alert-history-mobile-list"
tabindex="-1"
>
<PlatformWindowedList
items={mobileItems}
estimatedItemHeight={260}
@@ -2,6 +2,8 @@ import { Show, createMemo } from 'solid-js';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
import { PlatformWindowedRows } from '@/features/platformPage/PlatformWindowedRows';
import {
@@ -19,6 +21,13 @@ interface AlertHistoryTableSectionProps {
state: AlertHistoryState;
}
/**
* The narrowest card the history table is sized for. Below it the three row
* actions stack, rows lose the single height the windowed rows need, and
* names and messages break mid-word, so the history shows as cards instead.
*/
export const ALERT_HISTORY_TABLE_MIN_WIDTH = 800;
type AlertHistoryGroup = ReturnType<AlertHistoryState['groupedAlerts']>[number];
type AlertHistoryAlert = AlertHistoryGroup['alerts'][number];
type AlertHistoryRenderItem =
@@ -26,6 +35,16 @@ type AlertHistoryRenderItem =
| { kind: 'alert'; key: string; alert: AlertHistoryAlert };
export function AlertHistoryTableSection(props: AlertHistoryTableSectionProps) {
// The card's own width decides, not the viewport: a laptop window with the
// Alerts rail open leaves the table far less room than the screen suggests.
// Until the card is measured, the phone breakpoint decides as before.
const cardWidth = useObservedElementWidth();
const { isMobile } = useBreakpoint();
// A memo, so readers hear about a change of layout, not every resized pixel.
const showCards = createMemo(() => {
const width = cardWidth.width();
return width === null ? isMobile() : width < ALERT_HISTORY_TABLE_MIN_WIDTH;
});
const renderItems = createMemo<AlertHistoryRenderItem[]>(() => {
const items: AlertHistoryRenderItem[] = [];
for (const [groupIndex, group] of props.state.groupedAlerts().entries()) {
@@ -52,9 +71,12 @@ export function AlertHistoryTableSection(props: AlertHistoryTableSectionProps) {
</Show>
}
>
<>
<AlertHistoryMobileList state={props.state} />
<TableCard class="mb-2 hidden md:block">
<div
ref={cardWidth.setElement}
data-alert-history-layout={showCards() ? 'cards' : 'table'}
>
<AlertHistoryMobileList state={props.state} active={showCards} />
<TableCard class={showCards() ? 'mb-2 hidden' : 'mb-2'}>
<Table class="alert-history-responsive-table table-fixed min-w-0 text-[11px] sm:text-sm">
{/* Each track pairs its density visibility with its own width (index.css). */}
<colgroup>
@@ -130,7 +152,7 @@ export function AlertHistoryTableSection(props: AlertHistoryTableSectionProps) {
</TableBody>
</Table>
</TableCard>
</>
</div>
</Show>
}
>
@@ -101,8 +101,8 @@ describe('AlertHistoryMobileList', () => {
const { container } = render(() => <AlertHistoryMobileList state={state} />);
expect(container.querySelector('[data-testid="alert-history-mobile-list"]')).toHaveClass(
'md:hidden',
expect(container.querySelector('[data-testid="alert-history-mobile-list"]')).not.toHaveClass(
'hidden',
);
expect(screen.getByRole('heading', { name: 'pve-production-01' })).toBeInTheDocument();
expect(screen.getByText('Backup failed after the target became unavailable.')).toBeVisible();
@@ -239,34 +239,25 @@ describe('AlertHistoryMobileList', () => {
describe('AlertHistoryMobileList changing history', () => {
it.each(['timeline', 'resource'] as const)(
'preserves an open %s across desktop and phone layouts',
'preserves an open %s across table and card layouts',
async (kind) => {
const { state } = createState();
let resize = () => {};
const viewport = {
matches: true,
addEventListener: vi.fn((_event: string, listener: () => void) => {
resize = listener;
}),
removeEventListener: vi.fn(),
};
vi.stubGlobal('matchMedia', () => viewport);
// The table section shows the cards when its card is too narrow for
// the table; `active` is that decision.
const [cardsShown, setCardsShown] = createSignal(false);
if (kind === 'timeline') await state.toggleIncidentTimeline('alert-1-row', 'alert-1');
else await state.openResourceIncidentPanel('node-1', 'pve-production-01', 'alert-1-row');
const mounted = render(() => <AlertHistoryMobileList state={state} />);
const mounted = render(() => <AlertHistoryMobileList state={state} active={cardsShown} />);
const assistantOpen = vi.spyOn(aiChatStore, 'isOpen', 'get').mockReturnValue(false);
try {
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.getByRole('button', { name: /^Timeline for / })).toBeVisible();
assistantOpen.mockReturnValue(true);
viewport.matches = false;
resize();
setCardsShown(true);
expect(screen.queryByRole('dialog')).toBeNull();
viewport.matches = true;
resize();
setCardsShown(false);
assistantOpen.mockReturnValue(false);
viewport.matches = false;
resize();
setCardsShown(true);
expect(
screen.getByRole('dialog', {
name:
@@ -275,16 +266,14 @@ describe('AlertHistoryMobileList changing history', () => {
: 'Resource incidents for pve-production-01',
}),
).toBeVisible();
viewport.matches = true;
resize();
setCardsShown(false);
expect(screen.queryByRole('dialog')).toBeNull();
expect(
kind === 'timeline'
? state.expandedIncidents().has('alert-1-row')
: state.resourceIncidentPanel()?.rowKey === 'alert-1-row',
).toBe(true);
viewport.matches = false;
resize();
setCardsShown(true);
expect(screen.getByRole('dialog')).toBeVisible();
await fireEvent.click(
screen.getByRole('button', {
@@ -300,12 +289,48 @@ describe('AlertHistoryMobileList changing history', () => {
} finally {
assistantOpen.mockRestore();
mounted.unmount();
vi.unstubAllGlobals();
}
expect(viewport.removeEventListener).toHaveBeenCalledWith('change', resize);
},
);
// The table section derives `active` from the card's width, so the accessor
// changes on every resized pixel; only a change of layout may move the open
// expansions into the drawer, or a drawer the operator closed reopens.
it('moves expansions into the drawer only when the layout changes', async () => {
const { state, setGroupedAlerts } = createState();
setGroupedAlerts((groups) => {
const [group] = groups;
const first = group.alerts[0];
return [
{
...group,
alerts: [
first,
{ ...first, id: 'alert-2', resourceId: 'node-2', resourceName: 'pve-production-02' },
],
},
];
});
const keyed = {
...state,
getIncidentRowKey: (alert: { id: string }) => `${alert.id}-row`,
} as AlertHistoryState;
await keyed.toggleIncidentTimeline('alert-1-row', 'alert-1');
await keyed.toggleIncidentTimeline('alert-2-row', 'alert-2');
const [cardWidth, setCardWidth] = createSignal(1000);
render(() => <AlertHistoryMobileList state={keyed} active={() => cardWidth() < 800} />);
setCardWidth(602);
expect(
screen.getByRole('dialog', { name: 'Incident timeline for pve-production-02' }),
).toBeVisible();
await fireEvent.click(screen.getByRole('button', { name: 'Close incident timeline' }));
expect(screen.queryByRole('dialog')).toBeNull();
setCardWidth(603);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('returns focus to the list when the investigated row disappears', async () => {
const { closeResourceIncidentPanel, setGroupedAlerts, state } = createState();
render(() => <AlertHistoryMobileList state={state} />);
@@ -0,0 +1,75 @@
import { render } from '@solidjs/testing-library';
import { describe, expect, it, vi } from 'vitest';
import * as observedWidth from '@/hooks/useObservedElementWidth';
import * as breakpoint from '@/hooks/useBreakpoint';
import { AlertHistoryTableSection } from '../AlertHistoryTableSection';
import type { AlertHistoryState } from '../useAlertHistoryState';
vi.mock('@/hooks/useObservedElementWidth', async () => {
const { createSignal } = await import('solid-js');
const [width, setWidth] = createSignal<number | null>(null);
return {
useObservedElementWidth: () => ({ setElement: () => undefined, width }),
setObservedWidth: setWidth,
};
});
vi.mock('@/hooks/useBreakpoint', async () => {
const { createSignal } = await import('solid-js');
const [isMobile, setIsMobile] = createSignal(false);
return { useBreakpoint: () => ({ isMobile }), setIsMobile };
});
vi.mock('../AlertHistoryMobileList', () => ({
AlertHistoryMobileList: (props: { active: () => boolean }) => (
<div data-testid="history-cards" data-active={String(props.active())} />
),
}));
vi.mock('@/features/platformPage/PlatformWindowedRows', () => ({
PlatformWindowedRows: () => null,
}));
const setObservedWidth = (
observedWidth as unknown as { setObservedWidth: (w: number | null) => void }
).setObservedWidth;
const setIsMobile = (breakpoint as unknown as { setIsMobile: (value: boolean) => void })
.setIsMobile;
const state = {
loading: () => false,
alertData: () => [{ id: 'a-1' }],
historyLoadError: () => null,
groupedAlerts: () => [],
} as unknown as AlertHistoryState;
// A laptop window with the Alerts rail open gave the table a card under
// 800px: names broke mid-word and the three row actions stacked, while the
// cards stayed hidden because they only showed below a 768px viewport.
describe('AlertHistoryTableSection layout', () => {
it('shows cards whenever the card is too narrow for the table', () => {
const { container, getByTestId } = render(() => <AlertHistoryTableSection state={state} />);
const layout = () => container.querySelector('[data-alert-history-layout]');
const tableHidden = () => container.querySelector('table')?.closest('.hidden') !== null;
// Before the card is measured, the phone breakpoint decides.
expect(layout()).toHaveAttribute('data-alert-history-layout', 'table');
expect(getByTestId('history-cards')).toHaveAttribute('data-active', 'false');
expect(tableHidden()).toBe(false);
setObservedWidth(640);
expect(layout()).toHaveAttribute('data-alert-history-layout', 'cards');
expect(getByTestId('history-cards')).toHaveAttribute('data-active', 'true');
expect(tableHidden()).toBe(true);
setObservedWidth(799);
expect(layout()).toHaveAttribute('data-alert-history-layout', 'cards');
setObservedWidth(800);
expect(layout()).toHaveAttribute('data-alert-history-layout', 'table');
expect(getByTestId('history-cards')).toHaveAttribute('data-active', 'false');
expect(tableHidden()).toBe(false);
setObservedWidth(null);
setIsMobile(true);
expect(layout()).toHaveAttribute('data-alert-history-layout', 'cards');
});
});
@@ -2487,6 +2487,8 @@ None yet.
"frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertHistoryFrequencyCard.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertHistoryMobileList.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertHistoryTableSection.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx",
"frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx",