Show one chart panel in phone drawers and the rest on request

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: a guest drawer ran 1043px tall at 390px wide.

Below the sm breakpoint the shared chart component now shows the first
panel (Utilization) and any panel carrying an active alert, then one
full-width "Show Network I/O, Disk I/O, Thermals" button that reveals
the rest. The guest drawer drops to 657px. Wider layouts are unchanged.
It is a CSS class on panels that stay mounted, so it adds no read and
the hidden panels keep their data for when they are shown.
This commit is contained in:
rcourtman
2026-10-14 00:21:18 +01:00
parent 96d307dcbe
commit da74fb4209
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');
});
});