Chart history on Overview in Docker host and resource drawers (#2918)

rcourtman/Pulse#2913 moved the Proxmox guest and node drawers' charts
onto Overview and removed their History tab. This carries the same
change to the Docker host drawer and to the shared resource-detail
drawer (agent machines, containers, PBS, TrueNAS, Kubernetes and other
platform rows), so every platform drawer reads the same way: Overview
leads with the attention list and then the charts, and Manage holds the
controls.

- History charts render on Overview at 24 hours, with the range picker
beside them. The History tab is gone.
- Charts poll only while Overview shows. An active alert on a charted
metric draws its trigger (same `getDrawerChartAlerts` mapping, including
the MiB/s to B/s conversion for I/O).
- A snapshot that briefly drops the metrics target withdraws the charts
until it returns; the selected tab is kept.
- Storage page disk and pool details keep their own History tab. They
are separate components whose Overview has no charts to repeat.

Tests: the resource-detail, Docker and guardrail suites were updated to
the new layout, plus a new Docker host test for Overview charts paused
behind Manage. E2E specs 27 and 63 no longer click History.

Verification: typecheck and the Workloads, shared, Proxmox, Docker and
Infrastructure suites (305 files) on pulse-dev. I took full-resolution
screenshots of the built bytes at 1440 and 390px for a Docker host, a
container and an agent machine.

Not run: the manual `scripts/check-drawer-tab-retention.cjs` proof, now
adapted to select Manage, fails on main too because its fixture page
mounts the drawer without a WebSocket provider. The contract notes say
so.
This commit is contained in:
rcourtman
2026-10-13 23:48:59 +01:00
committed by GitHub
22 changed files with 247 additions and 153 deletions
@@ -1243,7 +1243,7 @@ display identity. The presence of discovery routing does not enable Agent-only
disk-throughput charts. Explicit Agent linkage owns that capability distinction.
`nodeDrawerModel.branchcov0713.test.ts` pins target precedence and unsupported
target fallback, while `NodeDrawer.test.tsx` verifies the chart request and
API-only chart groups through the rendered History tab.
API-only chart groups through the rendered Overview charts.
Platform inventory presentation has one structural owner across provider pages,
drawers, and inline detail rows. `PlatformTableShell` owns framed page tables;
@@ -5457,7 +5457,10 @@ The Proxmox guest and node drawers (`GuestDrawerOverview.tsx`,
a named bet): a grid of equal-weight inventory cards buried the drawer's job.
Their Overview leads with active alerts (or one "No active alerts" line), then
the resource's history charts (`GuestDrawerHistory`, lazy-loaded) opening on
24 hours with the range picker beside them. These drawers have no History tab:
24 hours with the range picker beside them. These drawers have no History tab
(nor, since the same change was carried to them, do the Docker host drawer and
the shared resource-detail drawer for agents, containers and other platform
rows):
by maintainer decision on 2026-10-13 the tab was removed because it repeated
Overview's charts at another range in another style. The charts poll only
while Overview shows, and before any history is stored they show the live
@@ -2900,8 +2900,11 @@ and pass `enabled={false}` while Manage shows, so one query from
`guestDrawerHistoryQuery.ts` polls only while the charts are visible. These
drawers have no History tab, so there is no second read of the same resource
at another range (the deleted `DrawerTrendCards.tsx` and the History tab each
held one). Docker host and resource-detail drawers keep their History tab on
the same 24h default.
held one). The Docker host drawer and the shared resource-detail drawer
(agents, containers, PBS, TrueNAS, Kubernetes and other platform rows) follow
the same rule: `DockerHostDrawerOverview.tsx` and
`ResourceDetailDrawerOverviewTab.tsx` render `GuestDrawerHistory` on Overview
on the same 24h default and pass `enabled={false}` while another tab shows.
Panel width alone does not make mount paths readable. Filesystem disk rows must
opt into the shared `layout: 'stacked'` presentation so their full mount path
wraps above usage and progress at desktop and phone widths. Shared-prefix paths
@@ -3745,7 +3748,7 @@ internal/mock/platform_fixtures_test.go pin the generation behavior.
The workload adapter copies resource.parentId into parentResourceId. vSphere
workload groups use that server parent identity while cluster, connection and
host display labels are enriched. A metadata update therefore keeps the row
and its drawer mounted, including its selected History tab. The workload
and its drawer mounted, including its selected tab. The workload
instance fallback uses an explicit cluster and never the workload's own name.
Legacy rows without a canonical parent retain the existing scoped fallback.
@@ -704,7 +704,7 @@ API-only Proxmox hosts remain `agent` resources but their metrics target is
linked Agent source retains priority and its `agent` store ID. Consumers must
use that target rather than reconstructing storage coordinates from display
identity or discovery routing. The node view carries the canonical target into
its History tab. Discovery can route by a host name without an installed Agent,
its Overview charts. Discovery can route by a host name without an installed Agent,
so it does not establish a `linkedAgentId` or Agent-only disk-throughput coverage.
The explicit Agent facet/link owns that evidence.
@@ -7696,14 +7696,19 @@ live snapshot is being replaced. `useResourceDetailDrawerState` falls back to
Overview only when the drawer starts showing a different resource; it must not
reset merely because the current tab is momentarily absent from the derived tab
list. A merged host's metrics target can be omitted for one snapshot during a
refresh, and treating that as a permanent loss of the History tab made the
Proxmox -> Backups drawer silently jump back to Overview. Each tab body already
renders its own availability notice while its tab is unavailable, so the
selection can be retained and recovered without manufacturing a target. The
refresh, and treating that as a permanent loss of the then History tab made
the Proxmox -> Backups drawer silently jump back to Overview. History charts
now sit on Overview and simply withdraw while the target is absent; the other
tab bodies render their own availability notice while their tab is
unavailable, so the selection can be retained and recovered without
manufacturing a target. The
`ResourceDetailDrawer.history.test.tsx` guard pins the single-resource reset
boundary, and `scripts/check-drawer-tab-retention.cjs` exercises the rendered
drawer at desktop and narrow widths: selecting History, dropping the merged
metrics target, and restoring it must keep the selection and recover the chart.
drawer at desktop and narrow widths: selecting Manage, dropping the merged
metrics target, and restoring it must keep the selection and bring the Overview
charts back. Its fixture page currently fails before the drawer renders
(no WebSocket provider), so that proof does not run until the fixture is
repaired.
### Canonical reference resolution uses a derived alias index
@@ -0,0 +1,48 @@
{
"version": 1,
"base_sha": "170f726c9330684c6240203071e106dad63ce1e7",
"verified_at": "2026-10-13T22:28:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "a9f6116d18eeb473aa86dd91c4b24b98fb1a2e59dda35347718bfb5dbb770f2b",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "8df81a27afadf825285b5142c3e4ef7a48a7379b43a6bae1ee46c83b0e921984",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "864681ed7644f33777d40b0019b405d28c1a0e023f3eb7b8c64a66d984a4f610",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "ddb5c9e4506ca9cd81034a6759edd2bcf1869f3b4997646e9d4ba15e92ef2e64",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx": "770745fc8b6346218b294957525d35adece0a7076b4c5ea3d90574ed15a1a252",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "6d7137c5c6c4fbb868a1d6b87aaa6c79ebce298540df9b8d6d957f9fdc22c989"
},
"routes": [
"/docker/overview",
"/docker/containers",
"/standalone/machines"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"final built bytes of this tree (origin/main 170f726c93 plus this change) embedded in a darwin binary, mock mode with 7 days of seeded metrics, headless Chromium on pulse-dev through a reverse tunnel, full-resolution element screenshots read by eye",
"Docker host drawer (App Platform 01, /docker/overview): tabs read Overview, Manage; Needs attention with 4 active alerts, then the range picker and Refresh history, then Utilization, Network I/O, Disk I/O and Thermals charts at 24 hours, then the operator context, runtime, memory, storage and telemetry cards",
"container drawer (loki, /docker/containers, the shared resource-detail drawer): tabs read Overview, Manage; the CPU-scale note, then three charts (Utilization, Network I/O, Disk I/O) at 24 hours, then runtime, container and identity cards and the relationship map",
"agent machine drawer (Apollo-114, /standalone/machines, the shared resource-detail drawer): tabs read Overview, Manage; four charts including Thermals at 24 hours above the operator, runtime and identity cards and the closed Platform details",
"390px phone: each drawer stacks its charts in one column under the toolbar with no horizontal overflow; 1440px: charts in a row of three or four"
],
"interactions": [
"expanded the first Docker host row, the first container row and the first agent machine row by their expand buttons at 1440 and 390px",
"read each drawer tab list (Overview, Manage) and counted the chart panels (4, 3 and 4)"
]
}
@@ -9,10 +9,6 @@ import { ProxmoxMailGatewayDrawer } from '@/features/proxmox/ProxmoxMailGatewayD
import { K8sDeploymentsDrawer } from '@/components/Kubernetes/K8sDeploymentsDrawer';
import { K8sNamespacesDrawer } from '@/components/Kubernetes/K8sNamespacesDrawer';
import { SwarmServicesDrawer } from '@/components/Docker/SwarmServicesDrawer';
import {
GuestDrawerHistory,
GuestDrawerHistoryRangeSelect,
} from '@/components/Workloads/GuestDrawerHistory';
import { ResourceDetailDrawerDebugTab } from './ResourceDetailDrawerDebugTab';
import {
ResourceAccessDisclosure,
@@ -193,34 +189,6 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
/>
</div>
{/* Agent Machine Metrics History Tab */}
<div class={`[overflow-anchor:none] ${drawer.activeTab() === 'history' ? '' : 'hidden'}`}>
<Show when={drawer.activeTab() === 'history'}>
<Show
when={drawer.metricsHistoryTarget()}
fallback={<TabAvailabilityNotice message="Metrics history is unavailable." />}
>
{(target) => (
<div class="space-y-3" data-testid="resource-metrics-history-tab">
<div class="flex items-center justify-end">
<GuestDrawerHistoryRangeSelect
range={drawer.metricsHistoryRange()}
onRangeChange={drawer.setMetricsHistoryRange}
/>
</div>
<GuestDrawerHistory
target={target()}
range={drawer.metricsHistoryRange()}
currentMetrics={drawer.metricsHistoryCurrentMetrics()}
deferredMetrics={drawer.metricsHistoryDeferredMetrics()}
groups={drawer.metricsHistoryGroups()}
/>
</div>
)}
</Show>
</Show>
</div>
<div class={`[overflow-anchor:none] ${drawer.activeTab() === 'manage' ? '' : 'hidden'}`}>
<Show when={drawer.activeTab() === 'manage'}>
<div class="space-y-3" data-testid="resource-manage-tab">
@@ -11,6 +11,8 @@ import { useWebSocket } from '@/contexts/appRuntime';
import { alertTypeDisplayLabel } from '@/features/alerts/helpers';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { getAlertsForUnifiedResource } from '@/utils/alerts';
import { GuestDrawerHistory } from '@/components/Workloads/GuestDrawerHistory';
import { getDrawerChartAlerts } from '@/components/Workloads/drawerOverviewModel';
import { formatUptime, formatRelativeTime } from '@/utils/format';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard';
@@ -328,12 +330,15 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
Boolean(drawer.facetBundleError());
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const attentionItems = () => {
const alerts = getAlertsForUnifiedResource(
resource(),
activeAlerts,
alertsActivation.detectionEnabled(),
const resourceAlerts = () =>
getAlertsForUnifiedResource(resource(), activeAlerts, alertsActivation.detectionEnabled());
const chartAlerts = () =>
getDrawerChartAlerts(
resourceAlerts(),
drawer.metricsHistoryGroups().flatMap((group) => group.series.map((series) => series.metric)),
);
const attentionItems = () => {
const alerts = resourceAlerts();
const items: DrawerAttentionItem[] = alerts.map((alert) => ({
id: alert.id,
message: alert.message,
@@ -379,6 +384,24 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
return (
<div class="space-y-3">
<DrawerAttentionSection items={attentionItems()} />
{/* Stored history leads the reading path: the charts answer "is this
normal or getting worse?" before the inventory below. */}
<Show when={drawer.metricsHistoryTarget()}>
{(target) => (
<div data-testid="resource-metrics-history">
<GuestDrawerHistory
target={target()}
range={drawer.metricsHistoryRange()}
onRangeChange={drawer.setMetricsHistoryRange}
enabled={drawer.activeTab() === 'overview'}
currentMetrics={drawer.metricsHistoryCurrentMetrics()}
deferredMetrics={drawer.metricsHistoryDeferredMetrics()}
groups={drawer.metricsHistoryGroups()}
alerts={chartAlerts()}
/>
</div>
)}
</Show>
<TechnicalDetailsSection dataTestId="resource-technical-details">
<InlineResourceSummaryTables
resource={resource()}
@@ -186,7 +186,7 @@ describe('ResourceDetailDrawer for Docker containers', () => {
expect(onResourceActionSettled).not.toHaveBeenCalled();
});
it('adds a metrics history tab for app-containers with a metrics target', async () => {
it('charts metrics history on Overview for app-containers with a metrics target', async () => {
render(() => (
<ResourceDetailDrawer
resource={resource({
@@ -198,15 +198,14 @@ describe('ResourceDetailDrawer for Docker containers', () => {
/>
));
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument();
const history = screen.getByTestId('container-history');
expect(history).toHaveAttribute('data-resource-type', 'app-container');
expect(history).toHaveAttribute('data-resource-id', 'abc123def456');
expect(history).toHaveAttribute('data-cpu', '75');
});
it('omits the history tab when no metrics target resolves', () => {
it('omits history charts when no metrics target resolves', () => {
render(() => (
<ResourceDetailDrawer
resource={resource({
@@ -217,7 +216,7 @@ describe('ResourceDetailDrawer for Docker containers', () => {
/>
));
expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument();
expect(screen.queryByTestId('container-history')).not.toBeInTheDocument();
});
it('renders container runtime facts in the table-row summary', () => {
@@ -162,7 +162,7 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
});
describe('ResourceDetailDrawer change history section', () => {
it('keeps retained guest memory and paused filesystems out of current History fallbacks', async () => {
it('keeps retained guest memory and paused filesystems out of current chart fallbacks', async () => {
resetCreateNonSuspendingQueryCacheForTest();
const source = baseResource({
id: 'provenance-vm',
@@ -194,7 +194,6 @@ describe('ResourceDetailDrawer change history section', () => {
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
));
try {
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
const chart = view.container.querySelector('[data-history-group="utilization"]')!;
expect(chart.querySelector('[data-history-current="memory"]')).toBeNull();
@@ -249,7 +248,7 @@ describe('ResourceDetailDrawer change history section', () => {
}
});
it('reactively withdraws stopped VM disk carriers without discarding History or independent readings', async () => {
it('reactively withdraws stopped VM disk carriers without discarding the Overview charts or independent readings', async () => {
resetCreateNonSuspendingQueryCacheForTest();
const source = baseResource({
id: 'native-power-vm',
@@ -278,7 +277,6 @@ describe('ResourceDetailDrawer change history section', () => {
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
));
try {
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
const chart = view.container.querySelector('[data-history-group="utilization"]')!;
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent(
@@ -308,7 +306,10 @@ describe('ResourceDetailDrawer change history section', () => {
expect(view.container.querySelector('[data-history-group="utilization"]')).toBe(chart);
expect(chart.querySelector('path[data-history-series]')).toBeNull();
expect(read).toHaveBeenCalledTimes(1);
expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
);
} finally {
view.unmount();
read.mockRestore();
@@ -317,7 +318,7 @@ describe('ResourceDetailDrawer change history section', () => {
});
it.each(['vm', 'system-container'] as const)(
'keeps the mounted %s History on the selected filesystem producer',
'keeps the mounted %s Overview charts on the selected filesystem producer',
async (type) => {
resetCreateNonSuspendingQueryCacheForTest();
const disks = [{ mountpoint: '/', total: 100, used: 62, free: 38 }];
@@ -350,7 +351,6 @@ describe('ResourceDetailDrawer change history section', () => {
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
));
try {
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
const chart = view.container.querySelector('[data-history-group="utilization"]')!;
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent(
@@ -78,7 +78,7 @@ afterEach(() => {
});
describe('ResourceDetailDrawer machine metrics history', () => {
it('adds a metrics history tab for Pulse Agent machines', async () => {
it('charts metrics history on Overview for Pulse Agent machines, with no History tab', async () => {
render(() => (
<ResourceDetailDrawer
resource={resource({
@@ -90,8 +90,7 @@ describe('ResourceDetailDrawer machine metrics history', () => {
/>
));
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument();
const history = screen.getByTestId('machine-history');
expect(history).toHaveAttribute('data-resource-type', 'agent');
expect(history).toHaveAttribute('data-resource-id', 'agent-mac-mini');
@@ -113,6 +112,7 @@ describe('ResourceDetailDrawer machine metrics history', () => {
));
expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument();
expect(screen.queryByTestId('machine-history')).not.toBeInTheDocument();
});
it('renders storage history with the capacity catalog instead of workload metrics', async () => {
@@ -127,8 +127,6 @@ describe('ResourceDetailDrawer machine metrics history', () => {
/>
));
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const history = screen.getByTestId('machine-history');
expect(history).toHaveAttribute('data-resource-type', 'storage');
expect(history).toHaveAttribute('data-groups', 'capacity');
@@ -148,8 +146,6 @@ describe('ResourceDetailDrawer machine metrics history', () => {
/>
));
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const history = screen.getByTestId('machine-history');
expect(history).toHaveAttribute('data-resource-type', 'disk');
expect(history).toHaveAttribute('data-groups', 'disk-activity,disk-io,disk-thermal');
@@ -168,8 +164,6 @@ describe('ResourceDetailDrawer machine metrics history', () => {
/>
));
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const history = screen.getByTestId('machine-history');
expect(history).toHaveAttribute('data-resource-type', 'docker-host');
expect(history).toHaveAttribute('data-groups', 'utilization,network,disk-io,thermals');
@@ -270,7 +264,7 @@ describe('ResourceDetailDrawer machine metrics history', () => {
expect(within(hostSection).queryByText('Hardware')).toBeNull();
});
it('keeps the selected History tab across a snapshot refresh that briefly drops the metrics target', async () => {
it('withdraws and restores the Overview charts when a snapshot briefly drops the metrics target', async () => {
const initial = resource({
id: 'pbs-1',
type: 'pbs',
@@ -279,18 +273,16 @@ describe('ResourceDetailDrawer machine metrics history', () => {
});
const [pbs, setPbs] = createStore<Resource>(initial);
render(() => <ResourceDetailDrawer resource={pbs} />);
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
expect(screen.getByTestId('machine-history')).toHaveAttribute(
'data-resource-id',
'agent-pbs-1',
);
// A snapshot refresh arrives without the merged metrics target. The drawer
// must not silently discard the user's History selection: it should show
// the in-tab notice and recover when the target returns on the next poll.
// A snapshot refresh arrives without the merged metrics target. Overview
// stays selected without charts, and they return when the target does.
setPbs(reconcile({ ...initial, metricsTarget: undefined }, { merge: false }));
expect(screen.getByText('Metrics history is unavailable.')).toBeInTheDocument();
expect(screen.queryByTestId('machine-history')).not.toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
setPbs(
reconcile(
@@ -21,13 +21,23 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
target: { resourceType: string; resourceId: string };
currentMetrics?: Record<string, number | undefined>;
range: string;
onRangeChange?: (range: string) => void;
}) => (
<div
data-testid="snapshot-history"
data-target={props.target.resourceId}
data-cpu={props.currentMetrics?.cpu}
data-range={props.range}
/>
>
<select
aria-label="History range"
value={props.range}
onChange={(event) => props.onRangeChange?.(event.currentTarget.value)}
>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
</select>
</div>
),
GuestDrawerHistoryRangeSelect: (props: {
range: string;
@@ -126,7 +136,6 @@ describe('ResourceDetailDrawer snapshot ownership', () => {
it('updates derived identity, current metrics and the History target without remounting a same-ID drawer', () => {
const [resource, setResource] = createSignal(snapshot());
render(() => <ResourceDetailDrawer resource={resource()} presentation="table-row" />);
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
fireEvent.change(screen.getByRole('combobox', { name: 'History range' }), {
target: { value: '7d' },
});
@@ -145,16 +154,16 @@ describe('ResourceDetailDrawer snapshot ownership', () => {
expect(history).toHaveAttribute('data-range', '7d');
});
it('withdraws a lost metrics target on an immutable snapshot and recovers the selected tab when it returns', () => {
it('withdraws Overview charts for a lost metrics target on an immutable snapshot and restores them when it returns', () => {
const [resource, setResource] = createSignal(snapshot());
render(() => <ResourceDetailDrawer resource={resource()} presentation="table-row" />);
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
expect(screen.getByTestId('snapshot-history')).toHaveAttribute('data-target', 'history-a');
setResource(snapshot({ metricsTarget: undefined }));
expect(screen.queryByTestId('snapshot-history')).not.toBeInTheDocument();
expect(screen.getByText('Metrics history is unavailable.')).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument();
setResource(snapshot());
expect(screen.getByTestId('snapshot-history')).toHaveAttribute('data-target', 'history-a');
expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
});
it('updates availability evidence in Overview instead of retaining the initial snapshot', () => {
@@ -188,7 +197,7 @@ describe('ResourceDetailDrawer snapshot ownership', () => {
requests.facets.mockReturnValueOnce(new Promise((resolve) => (completeOld = resolve)));
const [resource, setResource] = createSignal(snapshot());
const { container } = render(() => <ResourceDetailDrawer resource={resource()} />);
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
setResource(
snapshot({
id: 'pbs-b',
@@ -199,7 +208,7 @@ describe('ResourceDetailDrawer snapshot ownership', () => {
);
expect(screen.getByRole('heading', { name: 'PBS B' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
expect(screen.queryByTestId('snapshot-history')).not.toBeInTheDocument();
expect(screen.getByTestId('snapshot-history')).toHaveAttribute('data-target', 'history-b');
await waitFor(() => expect(requests.facets).toHaveBeenCalledWith('pbs-b', { limit: 25 }));
completeOld({
capabilities: [],
@@ -211,14 +220,11 @@ describe('ResourceDetailDrawer snapshot ownership', () => {
await Promise.resolve();
expect(container).not.toHaveTextContent('Former resource change');
expect(container).not.toHaveTextContent('PBS A');
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
expect(screen.getByTestId('snapshot-history')).toHaveAttribute('data-target', 'history-b');
});
it('keeps the reconciled-store client and same-ID tab continuity working', () => {
const [resource, setResource] = createStore(snapshot());
render(() => <ResourceDetailDrawer resource={resource} presentation="table-row" />);
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const history = screen.getByTestId('snapshot-history');
setResource(reconcile(snapshot({ cpu: { current: 35 } })));
expect(screen.getByTestId('snapshot-history')).toBe(history);
@@ -89,8 +89,7 @@ import {
} from './resourceDetailDrawerKubernetesModel';
import { getResourceHealthIssuePresentation } from './resourceHealthPresentation';
type DrawerTab =
'overview' | 'history' | 'manage' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
type DrawerTab = 'overview' | 'manage' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
interface UseResourceDetailDrawerDerivedStateOptions {
resource: Resource;
@@ -376,8 +375,9 @@ export const useResourceDetailDrawerDerivedState = (
const tabs = createMemo(() => {
const base = [
// A resource with stored history charts it on Overview; there is no
// separate History tab repeating those charts at another range.
{ id: 'overview' as DrawerTab, label: 'Overview' },
...(hasMetricsHistory() ? [{ id: 'history' as DrawerTab, label: 'History' }] : []),
{ id: 'manage' as DrawerTab, label: 'Manage' },
...(resource().type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []),
...(resource().type === 'k8s-cluster'
@@ -8,8 +8,7 @@ import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHi
import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState';
import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
type DrawerTab =
'overview' | 'history' | 'manage' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
type DrawerTab = 'overview' | 'manage' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
export interface UseResourceDetailDrawerStateOptions {
resource: Resource;
@@ -1309,14 +1309,15 @@ describe('shared primitive guardrails', () => {
expect(source).toContain("value: 'overview', label: 'Overview'");
expect(source).toContain("value: 'history', label: 'History'");
}
// Guest and node drawers chart their history on Overview itself, with the
// range picker beside the charts. A History tab there repeated Overview's
// charts at another range.
for (const source of [guestDrawerSource, nodeDrawerSource]) {
// Guest, node, Docker host and resource-detail drawers chart their history
// on Overview itself, with the range picker beside the charts. A History
// tab there repeated Overview's charts, or hid them a tab away.
for (const source of [guestDrawerSource, nodeDrawerSource, dockerHostDrawerSource]) {
expect(source).toContain('@/components/shared/Subtabs');
expect(source).toContain("value: 'overview', label: 'Overview'");
expect(source).not.toContain("value: 'history', label: 'History'");
}
expect(resourceDetailDrawerSource).not.toContain("activeTab() === 'history'");
expect(resourceDetailDrawerSource).toContain('@/components/shared/Subtabs');
expect(resourceDetailDrawerSource).toContain('tabs={drawer.tabs().map');
@@ -13,7 +13,20 @@ vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
}));
vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
GuestDrawerHistory: () => <div data-testid="docker-host-history" />,
GuestDrawerHistory: (props: {
target: { resourceType: string; resourceId: string };
range: string;
enabled?: boolean;
onRangeChange?: (range: string) => void;
}) => (
<div
data-testid="docker-host-history"
data-target={props.target.resourceId}
data-range={props.range}
data-enabled={String(props.enabled)}
data-range-picker={String(Boolean(props.onRangeChange))}
/>
),
GuestDrawerHistoryRangeSelect: () => <select aria-label="History range" />,
}));
@@ -96,6 +109,19 @@ describe('DockerHostDrawer open alerts', () => {
});
});
describe('DockerHostDrawer history', () => {
it('charts history on Overview with its range picker, and pauses it behind Manage', () => {
render(() => <DockerHostDrawer host={host()} />);
expect(screen.queryByRole('tab', { name: 'History' })).toBeNull();
const history = screen.getByTestId('docker-host-history');
expect(history).toHaveAttribute('data-range', '24h');
expect(history).toHaveAttribute('data-range-picker', 'true');
expect(history).toHaveAttribute('data-enabled', 'true');
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
expect(screen.getByTestId('docker-host-history')).toHaveAttribute('data-enabled', 'false');
});
});
describe('DockerHostDrawer typed-helper summary mode', () => {
it('warns about reduced coverage and hides container update controls', async () => {
const summaryHost = host();
@@ -5,10 +5,6 @@ import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
import { Subtabs } from '@/components/shared/Subtabs';
import { getSimpleStatusIndicator } from '@/utils/status';
import {
GuestDrawerHistory,
GuestDrawerHistoryRangeSelect,
} from '@/components/Workloads/GuestDrawerHistory';
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
import { ResourceOperatorStateSection } from '@/components/Infrastructure/ResourceOperatorStateSection';
import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
@@ -29,7 +25,7 @@ interface DockerHostDrawerProps {
onClose?: () => void;
}
type DockerHostDrawerTab = 'overview' | 'history' | 'manage';
type DockerHostDrawerTab = 'overview' | 'manage';
export const DockerHostDrawer: Component<DockerHostDrawerProps> = (props) => {
const [activeTab, setActiveTab] = createSignal<DockerHostDrawerTab>('overview');
@@ -81,20 +77,22 @@ export const DockerHostDrawer: Component<DockerHostDrawerProps> = (props) => {
onChange={(value) => setActiveTab(value as DockerHostDrawerTab)}
tabs={[
{ value: 'overview', label: 'Overview' },
{ value: 'history', label: 'History' },
{ value: 'manage', label: 'Manage' },
]}
trailing={
<Show when={activeTab() === 'history'}>
<GuestDrawerHistoryRangeSelect range={historyRange()} onRangeChange={setHistoryRange} />
</Show>
}
/>
{/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps.
overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */}
<div class={`[overflow-anchor:none] ${activeTab() === 'overview' ? '' : 'hidden'}`}>
<DockerHostDrawerOverview host={props.host} />
<DockerHostDrawerOverview
host={props.host}
historyTarget={historyTarget()}
historyRange={historyRange()}
onHistoryRangeChange={setHistoryRange}
historyGroups={DOCKER_HOST_DRAWER_HISTORY_GROUPS}
currentMetrics={currentMetrics()}
historyEnabled={activeTab() === 'overview'}
/>
</div>
<div class={`[overflow-anchor:none] ${activeTab() === 'manage' ? '' : 'hidden'}`}>
@@ -118,15 +116,6 @@ export const DockerHostDrawer: Component<DockerHostDrawerProps> = (props) => {
</div>
</Show>
</div>
<div class={`[overflow-anchor:none] ${activeTab() === 'history' ? '' : 'hidden'}`}>
<GuestDrawerHistory
currentMetrics={currentMetrics()}
groups={DOCKER_HOST_DRAWER_HISTORY_GROUPS}
range={historyRange()}
target={historyTarget()}
/>
</div>
</section>
);
};
@@ -32,9 +32,24 @@ import {
} from '@/utils/format';
import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import type { HistoryTimeRange } from '@/api/charts';
import { GuestDrawerHistory } from '@/components/Workloads/GuestDrawerHistory';
import { getDrawerChartAlerts } from '@/components/Workloads/drawerOverviewModel';
import type {
GuestDrawerHistoryGroupConfig,
GuestDrawerHistoryTarget,
} from '@/components/Workloads/guestDrawerModel';
interface DockerHostDrawerOverviewProps {
host: Resource;
historyTarget?: GuestDrawerHistoryTarget | null;
historyRange?: HistoryTimeRange;
onHistoryRangeChange?: (range: HistoryTimeRange) => void;
historyGroups?: GuestDrawerHistoryGroupConfig[];
/** Live readings the charts show before any history is stored. */
currentMetrics?: Record<string, number | undefined>;
/** True while Overview is the visible tab; hidden charts do not poll. */
historyEnabled?: boolean;
}
interface DockerOverviewRow {
@@ -183,6 +198,13 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
const agent = () => props.host.agent;
const linkedAgentId = () => cleanText(agent()?.agentId);
const summaryOnly = () => cleanText(docker()?.collectionMode) === 'typed-helper-summary';
const hostAlerts = () =>
getAlertsForUnifiedResource(props.host, activeAlerts, alertsActivation.detectionEnabled());
const chartAlerts = () =>
getDrawerChartAlerts(
hostAlerts(),
(props.historyGroups ?? []).flatMap((group) => group.series.map((series) => series.metric)),
);
const temperatureThresholds = createMemo(() =>
alertsActivation.getMetricThresholds(
'node',
@@ -372,11 +394,7 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
},
]
: []),
...getAlertsForUnifiedResource(
props.host,
activeAlerts,
alertsActivation.detectionEnabled(),
).map((alert) => ({
...hostAlerts().map((alert) => ({
id: alert.id,
message: alert.message,
subject: cleanText(alert.resourceName) || props.host.displayName || props.host.name,
@@ -394,6 +412,19 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
/>
</div>
</Show>
<Show when={props.historyTarget}>
{(target) => (
<GuestDrawerHistory
target={target()}
range={props.historyRange ?? '24h'}
onRangeChange={props.onHistoryRangeChange}
enabled={props.historyEnabled ?? true}
groups={props.historyGroups}
currentMetrics={props.currentMetrics}
alerts={chartAlerts()}
/>
)}
</Show>
<TechnicalDetailsSection
dataTestId="docker-host-technical-details"
sections={[...overviewSections(), ...technicalSections()]}
@@ -133,7 +133,6 @@ describe('Docker container CPU presentation through the real row and drawer', ()
expect(screen.getByTestId('container-drawer-cpu-scale')).toHaveTextContent(
CONTAINER_CPU_CAPACITY_DESCRIPTION,
);
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const slider = await screen.findByRole('slider', { name: 'Inspect Utilization history' });
await waitFor(() => expect(slider).toHaveAttribute('max', '2'));
fireEvent.input(slider, { target: { value: '1' } });
@@ -375,7 +375,8 @@ describe('DockerHostsTable', () => {
expect(window.location.pathname).toBe('/docker/overview');
expect(window.location.search).toBe('');
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('type', 'button');
expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('type', 'button');
expect(screen.getByRole('tab', { name: 'Manage' })).toHaveAttribute('type', 'button');
expect(screen.queryByRole('tab', { name: 'History' })).toBeNull();
});
it('opens a saved Docker host web interface without toggling the row', () => {
@@ -708,18 +708,19 @@ describe('Docker native tables', () => {
));
fireEvent.click(screen.getByRole('button', { name: 'Expand details for nginx' }));
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const historyRange = screen.getByRole('combobox', { name: 'History range' });
const historyRange = await screen.findByRole('combobox', { name: 'History range' });
fireEvent.change(historyRange, { target: { value: '1h' } });
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Manage' })).toHaveAttribute('aria-selected', 'true');
expect(historyRange).toHaveValue('1h');
setResources(makeContainers(1));
await waitFor(() =>
expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('aria-selected', 'true'),
expect(screen.getByRole('tab', { name: 'Manage' })).toHaveAttribute('aria-selected', 'true'),
);
fireEvent.click(screen.getByRole('tab', { name: 'Overview' }));
expect(screen.getByRole('combobox', { name: 'History range' })).toHaveValue('1h');
expect(screen.getByRole('button', { name: 'Collapse details for nginx' })).toBeInTheDocument();
});
+19 -21
View File
@@ -1,12 +1,11 @@
// Offline real-browser proof for the #1723 drawer tab-retention repair.
//
// Opens the production Proxmox Backups server drawer, selects History, then
// drives a snapshot that transiently removes the merged host metrics target
// (the field that gates the History tab). The drawer must keep the user's
// History selection and show the in-tab availability notice instead of
// silently falling back to Overview; restoring the target must bring the chart
// back on the same selection. Synthetic API responses prove UI behaviour, not
// backend persistence or installed release behaviour.
// Opens the production Proxmox Backups server drawer, whose history charts sit
// on Overview, selects Manage, then drives a snapshot that transiently removes
// the merged host metrics target (the field that gates those charts). The
// drawer must keep the user's Manage selection without remounting; restoring
// the target must bring the charts back on Overview. Synthetic API responses
// prove UI behaviour, not backend persistence or installed release behaviour.
// Run: pulse-worker-browser scripts/check-drawer-tab-retention.cjs
const path = require('node:path');
const assert = require('node:assert/strict');
@@ -110,9 +109,6 @@ const launchOptions = {
await serverRow.waitFor({ timeout: 20000 });
await serverRow.getByRole('button').focus();
await page.keyboard.press('Enter');
await page
.getByRole('tab', { name: 'History', exact: true })
.click();
await page
.locator('[data-testid="guest-history-plot"] path')
.first()
@@ -127,31 +123,33 @@ const launchOptions = {
.locator('[data-testid="guest-history-plot"] path')
.count();
assert.ok(pathsBefore > 0, 'history plot did not render before the drop');
await page.getByRole('tab', { name: 'Manage', exact: true }).click();
// Snapshot arrives without the merged host metrics target.
await page.getByRole('button', { name: 'Drop metrics target' }).click();
await page
.getByText('Metrics history is unavailable.', { exact: true })
.waitFor({ timeout: 20000 });
await detail
.locator('[data-testid="resource-metrics-history"]')
.waitFor({ state: 'detached', timeout: 20000 });
assert.equal(
await page
.getByRole('tab', { name: 'Manage', exact: true })
.getAttribute('aria-selected'),
'true',
'tab selection was discarded across the transient target loss',
);
assert.equal(
await detail.getAttribute('data-proof-identity'),
'retained',
'drawer remounted on the transient target loss',
);
// Next snapshot restores it; the same History selection must recover.
// Next snapshot restores it; Overview shows the same charts again.
await page.getByRole('button', { name: 'Restore metrics target' }).click();
await page.getByRole('tab', { name: 'Overview', exact: true }).click();
await page
.locator('[data-testid="guest-history-plot"] path')
.first()
.waitFor({ timeout: 20000 });
assert.equal(
await page
.getByRole('tab', { name: 'History', exact: true })
.getAttribute('aria-selected'),
'true',
'history selection was discarded across the transient target loss',
);
assert.equal(
await detail
.locator('[data-testid="guest-history-plot"] path')
@@ -134,7 +134,9 @@ test.describe("TrueNAS storage disk history", () => {
drawer.getByText(/Device \/dev\/sdc has SMART test/).first(),
).toBeVisible();
await drawer.getByRole("tab", { name: "History" }).click();
// The disk's history charts sit on the drawer's Overview, so opening the
// drawer is what starts the history read; there is no History tab.
await expect(drawer.getByRole("tab", { name: "History" })).toHaveCount(0);
await expect
.poll(() =>
@@ -134,7 +134,7 @@ for (const width of [1280, 390]) {
const expand = page.getByRole('button', { name: `Expand details for backup-${type}`, exact: true });
await expand.focus();
await page.keyboard.press('Enter');
await page.getByRole('tab', { name: 'History', exact: true }).click();
// History charts sit on the drawer's Overview; opening it starts the read.
await expect.poll(() => targets).toContain(`${type}/history-${type}`);
expect(targets.some(t => t.includes('wrong-'))).toBe(false);
const utilization = page.getByTestId('guest-history-group-chart').first();