Show one chart panel in phone drawers and the rest on request (#2923)

Stacked on rcourtman/Pulse#2921, which sits on rcourtman/Pulse#2920;
this branch contains both. It lands after them or together with them.

With history charts on every drawer's Overview, a phone stacked three or
four chart panels before a guest's facts, a node's hardware or an
agent's details. At 390px a guest drawer was 1043px tall.

Below the `sm` breakpoint, the shared drawer chart component
(`GuestDrawerHistory`, used by the guest, node, Docker host and
resource-detail drawers) now shows:
- the first panel (Utilization), plus any panel carrying an active
alert;
- one full-width "Show Network I/O, Disk I/O, Thermals" button
(`aria-expanded`) that reveals the rest and then reads "Show fewer
charts".

Wider layouts are unchanged. It is a CSS class on panels that stay
mounted, so it adds no read.

Measured at 390px on the built preview:

| Drawer | Height | Notes |
| --- | --- | --- |
| Guest | 657px (was 1043px) | |
| Node | 597px | 3 panels collapsed |
| Agent | 715px | 3 panels collapsed |

At 1440px every panel shows and there is no toggle.

Tests: the new cases in `GuestDrawerHistory.inspection.test.tsx` cover
the collapse, the toggle, and an alerting panel staying visible.
Typecheck plus the Workloads, Infrastructure, Docker and Storage suites
pass on pulse-dev.
This commit is contained in:
pulse-triage[bot]
2026-10-14 01:43:11 +01:00
committed by GitHub
5 changed files with 124 additions and 5 deletions
@@ -5478,7 +5478,10 @@ accessible value states each reading's average and range. Every history range
picker (these drawers, storage disk and pool details) names its options through
`formatHistoryRangeOptionLabel` in `historyChartModel.ts` ("1 hour", "24 hours",
"7 days"), so one range is never shown as "24h", "24 hours" and "Last 24 hours"
in three places. Any
in three places. Below the `sm` breakpoint the chart panels stack, so a phone
shows the first panel (Utilization) plus any panel carrying an active alert,
and one full-width "Show <panel names>" button (`aria-expanded`, hidden from
`sm` up) reveals the rest; wider layouts always show every panel. Any
warning- or danger-toned detail row
is lifted into a "Worth checking" section in front, whichever section owns it,
so demotion never hides a reason a reading cannot be trusted. Guest
@@ -4019,8 +4019,10 @@ the compact "Worth checking" list reuses rows already derived, and an alert's
dashed trigger line reuses the alert the drawer already holds. The min-max band
reads the `min`/`max` each stored bucket already carries: one extra path per
series with any spread (none for flat data). Range picker option labels come
from one lookup table (`formatHistoryRangeOptionLabel`). None adds a read or
timer.
from one lookup table (`formatHistoryRangeOptionLabel`). The phone collapse of
later chart panels is a CSS class below `sm` on panels that stay mounted: it
hides nothing from the shared history read and adds no read or timer. None
adds a read or timer.
The default stored-history legend resolves one latest timestamp from that
same bounded, normalized panel index, then looks up each configured series at
@@ -0,0 +1,36 @@
{
"version": 1,
"base_sha": "96d307dcbe2042d70d981fe7ebc3dd2b6f35162f",
"verified_at": "2026-10-13T23:18:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "11ffac0be9469fc1107e63c4e8c99fcd7df26fd401b76ac29664c29cde8a680a"
},
"routes": [
"/proxmox/overview",
"/standalone/machines"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"final built bytes of this tree (current sidecar head with PRs 2920/2921 and this change) embedded in a darwin binary, mock mode, headless Chromium on pulse-dev through a reverse tunnel, full-resolution element screenshots read by eye",
"390px: guest drawer (artifact-cache-310) shows only the Utilization panel, then a full-width \"Show Network I/O, Disk I/O\" button, then the facts strip; drawer height 657px against 1043px with every panel shown",
"390px: node drawer (Analytics A) and agent machine drawer each show Utilization and \"Show Network I/O, Disk I/O, Thermals\" (597px and 715px tall)",
"1440px: guest, node and agent drawers show every panel (3, 4 and 4) and no toggle; layout matches the previous pass"
],
"interactions": [
"clicked \"Show Network I/O, Disk I/O\" in the phone guest drawer: Network I/O and Disk I/O panels appeared, the button read \"Show fewer charts\" and the drawer grew to 1043px",
"measured visible panel boxes per drawer at both widths"
]
}
@@ -71,6 +71,8 @@ interface GuestDrawerHistoryRangeSelectProps {
interface GuestDrawerHistoryGroupChartProps {
alerts?: Record<string, GuestDrawerHistoryAlert | undefined>;
/** Collapsed below the sm breakpoint until the reader asks for more charts. */
hiddenOnPhone?: boolean;
currentMetrics?: Record<string, number | null | undefined>;
deferredMetrics?: Record<string, GuestDrawerHistoryDeferredMetric | undefined>;
group: GuestDrawerHistoryGroupConfig;
@@ -472,8 +474,9 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
return (
<section
class="flex min-h-[154px] flex-col rounded-xs border border-border bg-surface p-2.5"
class={`flex min-h-[154px] flex-col rounded-xs border border-border bg-surface p-2.5 ${props.hiddenOnPhone ? 'max-sm:hidden' : ''}`}
data-testid="guest-history-group-chart"
data-phone-collapsed={props.hiddenOnPhone ? 'true' : undefined}
data-history-group={props.group.id}
>
<div class="mb-2 flex flex-wrap items-start justify-between gap-x-4 gap-y-1">
@@ -806,6 +809,12 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) => {
const [touchInspection, setTouchInspection] = createSignal(false);
// On a phone every panel stacks, so a drawer's Overview ran four charts
// deep before its disks and details. Phones show the first panel, plus any
// panel with an active alert, and the rest on request. Wider layouts show
// every panel; the class only applies below the sm breakpoint.
const [showAllOnPhone, setShowAllOnPhone] = createSignal(false);
const gridId = `history-groups-${createUniqueId()}`;
onMount(() => {
void loadRuntimeCapabilities();
});
@@ -830,6 +839,13 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
historyQuery.value(),
),
);
const groupHasAlert = (group: GuestDrawerHistoryGroupConfig) =>
group.series.some((series) => Boolean(props.alerts?.[series.metric]));
const isPhoneCollapsed = (group: GuestDrawerHistoryGroupConfig, index: number) =>
!showAllOnPhone() && index > 0 && !groupHasAlert(group);
const phoneCollapsedGroups = createMemo(() =>
groups().filter((group, index) => index > 0 && !groupHasAlert(group)),
);
const hasHistoryPoints = createMemo(() =>
groups().some((group) =>
group.series.some(
@@ -903,12 +919,14 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
>
<Show when={!historyQuery.error() || hasHistoryPoints()}>
<div
id={gridId}
class={`grid gap-3 md:grid-cols-2 ${groups().length > 3 ? 'xl:grid-cols-4' : 'xl:grid-cols-3'}`}
>
<For each={groups()}>
{(group) => (
{(group, index) => (
<GuestDrawerHistoryGroupChart
alerts={props.alerts}
hiddenOnPhone={isPhoneCollapsed(group, index())}
group={group}
loading={historyQuery.loading() && !historyQuery.resolvedOnce()}
metrics={metrics()}
@@ -922,6 +940,22 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
)}
</For>
</div>
<Show when={phoneCollapsedGroups().length > 0 || showAllOnPhone()}>
<button
type="button"
class="flex min-h-11 w-full items-center justify-center gap-1 rounded-xs border border-border bg-surface px-3 text-xs font-medium text-base-content sm:hidden"
aria-controls={gridId}
aria-expanded={showAllOnPhone()}
data-testid="history-phone-toggle"
onClick={() => setShowAllOnPhone((open) => !open)}
>
{showAllOnPhone()
? 'Show fewer charts'
: `Show ${phoneCollapsedGroups()
.map((group) => group.label)
.join(', ')}`}
</button>
</Show>
</Show>
</Show>
</div>
@@ -408,3 +408,47 @@ describe('drawer History range picker', () => {
]);
});
});
describe('drawer History on a phone', () => {
const groupIds = () =>
screen
.getAllByTestId('guest-history-group-chart')
.filter((chart) => chart.dataset.phoneCollapsed !== 'true')
.map((chart) => chart.dataset.historyGroup);
it('keeps the first panel and collapses the rest behind one toggle', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
render(() => <GuestDrawerHistory target={target} range="24h" />);
await screen.findAllByTestId('guest-history-group-chart');
expect(groupIds()).toEqual(['utilization']);
for (const chart of screen.getAllByTestId('guest-history-group-chart').slice(1)) {
expect(chart).toHaveClass('max-sm:hidden');
}
const toggle = screen.getByTestId('history-phone-toggle');
expect(toggle).toHaveClass('sm:hidden');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(toggle).toHaveTextContent('Show Network I/O, Disk I/O');
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(toggle).toHaveTextContent('Show fewer charts');
expect(groupIds()).toEqual(['utilization', 'network', 'disk-io']);
for (const chart of screen.getAllByTestId('guest-history-group-chart')) {
expect(chart).not.toHaveClass('max-sm:hidden');
}
});
it('keeps a panel with an active alert visible on a phone', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
render(() => (
<GuestDrawerHistory
target={target}
range="24h"
alerts={{ diskread: { severity: 'warning', value: 1024 } }}
/>
));
await screen.findAllByTestId('guest-history-group-chart');
expect(groupIds()).toEqual(['utilization', 'disk-io']);
expect(screen.getByTestId('history-phone-toggle')).toHaveTextContent('Show Network I/O');
});
});