Integrate reviewed fractional container CPU presentation

Accept exact Web candidate 902bba393f with retained connected/static/build and parent-bound browser proof. Keep raw metrics, sorting and alerts unchanged; source acceptance is not native or reporter resolution.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 01:43:13 +01:00
14 changed files with 363 additions and 3 deletions
@@ -20,6 +20,21 @@
## Purpose
### Observed CPU labels preserve small positives
Shared responsive CPU cells distinguish actual zero from small positive readings:
positive values below 0.1% display `<0.1%`, other values below 10% retain one
decimal without a trailing zero, and larger values keep whole-percent labels.
Both compact text and animated bars use the same formatter. Explicit labels,
non-CPU table formatting, unavailable/offline fallbacks, raw progress widths and
threshold colouring retain their existing contracts.
Shared percentage History legends, inspection and accessible descriptions never
round positive values below 0.1% to zero. Actual zero remains `0.0%`; all other
History units and precision stay unchanged. This changes displayed text only,
not readings, coordinates, stored points, selection, requests or alert policy.
Mounted responsive-cell and container row/drawer tests exercise these boundaries.
### Guest drawers consume selected memory provenance
Existing guest drawers consume optional server-owned `memory.observation` and
@@ -23,6 +23,20 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Container CPU presentation explains its existing scale
The existing Docker/Podman container table and drawer visibly explain that CPU
is a percentage of total host capacity: 100% means all host CPUs. Runtime stats
may use a per-core scale; the client never guesses core count or renormalises
readings to match a screenshot. The explanation remains visible in Overview and
History, including narrow screens, without relying on hover-only help.
Small positive CPU labels stay distinct from true zero in the real responsive
row and stored History inspection. Numeric sorting, metric identity, requests,
collector/ingest units, stored values and alert thresholds remain unchanged.
Connected container row/drawer controls establish presentation, not accurate
native collection, every-container continuity, alert delivery or reporter relief.
### Guest drawers consume selected memory provenance
Existing guest drawers consume optional server-owned `memory.observation` and
@@ -0,0 +1,49 @@
{
"version": 1,
"result": "passed",
"base_sha": "b01dad904c3436e70bc01588ea1546fffaa05dc6",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/shared/historyChartModel.ts",
"frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/dockerCpuPresentation.ts",
"frontend-modern/src/utils/format.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "53c9e59281392d26cbdbba2d1153d321bebe8b3a49ed06c7ba3b7a48504f3977",
"frontend-modern/src/components/shared/historyChartModel.ts": "3229e5c4d3aa1e8645a70a1a29c018e1b90361c2453b66336fc0d9321ceb7bd2",
"frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx": "4c3eea14a621354fa5ece0c05451db2426b37af272c984f829ba8833dfbabe96",
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "2a9793922a95864e96950a76754bfb6acf8a7f152b4085d39d567f358d5bf26c",
"frontend-modern/src/features/docker/dockerCpuPresentation.ts": "d3eee94c693339e3318c1b70c6324a221d1c6d1162c4cda47ae10495eeef8393",
"frontend-modern/src/utils/format.ts": "0e012dae598fdc14f22bfed07af0e7e43d41568fe4ce7e1025f2a42e3c44d175"
},
"routes": [
"/docker: existing production container table and inline ResourceDetailDrawer; isolated offline fixture, synthetic API readings"
],
"states": [
"Docker/Podman mixed runtime; true zero, tiny positive, other fractional CPU, unavailable and stopped readings",
"Overview and stored History; actual zero versus tiny positive and 9.4%; light/dark"
],
"interactions": [
"Numeric CPU sorting, keyboard drawer open/collapse, History tab selection",
"Native Home/ArrowRight and pointer/touch stored-point inspection without extra requests",
"Positive-to-zero live replacement; raw progress widths, visible capacity units and narrow label fit",
"All 18 desktop and phone screenshots visually inspected"
],
"viewports": [
{
"width": 1280,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 740
}
],
"verified_at": "2026-10-04T23:56:44.161Z"
}
@@ -30,6 +30,7 @@ import { isDockerContainerLifecycleResource } from '@/features/docker/dockerCont
import { ResourceOperatorStateSection } from './ResourceOperatorStateSection';
import { MaintenanceVerificationSection } from './MaintenanceVerificationSection';
import { ResourceActionHistory } from './ResourceActionHistory';
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from '@/features/docker/dockerCpuPresentation';
interface ResourceDetailDrawerProps {
resource: Resource;
@@ -150,6 +151,14 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
)}
</Show>
<Show
when={props.resource.type === 'app-container' && props.resource.platformType === 'docker'}
>
<p class="text-xs leading-relaxed text-muted" data-testid="container-drawer-cpu-scale">
{CONTAINER_CPU_CAPACITY_DESCRIPTION}
</p>
</Show>
<Subtabs
class="mb-1"
ariaLabel="Resource detail sections"
@@ -12,10 +12,21 @@ import historyChartStateSource from '@/components/shared/useHistoryChartState.ts
import historyChartTooltipSource from '@/components/shared/HistoryChartTooltip.tsx?raw';
import { HistoryChart, HistoryChartHoverGroup } from '@/components/shared/HistoryChart';
import {
formatHistoryChartTooltipValue,
getHistoryChartTooltipLayout,
HISTORY_CHART_RANGES,
} from '@/components/shared/historyChartModel';
it('distinguishes tiny positive percentage observations from zero in History labels', () => {
expect(formatHistoryChartTooltipValue(0, '%')).toBe('0.0%');
expect(formatHistoryChartTooltipValue(Number.MIN_VALUE, '%')).toBe('<0.1%');
expect(formatHistoryChartTooltipValue(0.026, '%')).toBe('<0.1%');
expect(formatHistoryChartTooltipValue(0.09999, '%')).toBe('<0.1%');
expect(formatHistoryChartTooltipValue(0.1, '%')).toBe('0.1%');
expect(formatHistoryChartTooltipValue(9.4, '%')).toBe('9.4%');
expect(formatHistoryChartTooltipValue(0.026, 'C')).toBe('0°C');
});
function touchPointer(canvas: Element, type: string, fields: Partial<PointerEvent> = {}) {
const event = Object.assign(new Event(type, { bubbles: true, cancelable: true }), {
pointerId: 1,
@@ -0,0 +1,67 @@
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ResponsiveMetricCell } from '../responsive/ResponsiveMetricCell';
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('ResponsiveMetricCell observed CPU labels', () => {
it.each([false, true])('keeps fractions in desktop/mobile mode %s', (showMobile) => {
const { container } = render(() => (
<ResponsiveMetricCell value={0.026} type="cpu" showMobile={showMobile} />
));
const numbers = container.querySelectorAll('[data-animated-number]');
expect(numbers.length).toBeGreaterThan(0);
for (const label of numbers) {
expect(label).toHaveTextContent('<0.1%');
expect(label).toHaveAttribute('aria-label', '<0.1%');
}
expect(container.querySelector('[data-progress-fill]')).toHaveAttribute('width', '0.026');
});
it('updates the formatted target while retaining raw width and severity', async () => {
const [cpu, setCpu] = createSignal(0);
const { container } = render(() => (
<ResponsiveMetricCell
value={cpu()}
type="cpu"
thresholds={{ warning: 0.18, critical: 0.3 }}
/>
));
expect(screen.getByText('0%')).toBeInTheDocument();
setCpu(0.17);
await waitFor(() => expect(screen.getByLabelText('0.2%')).toHaveTextContent('0.2%'));
const fill = container.querySelector('[data-progress-fill]');
expect(fill).toHaveAttribute('width', '0.17');
expect(fill?.firstElementChild?.className).not.toMatch(/orange|red/);
setCpu(0);
await waitFor(() => expect(screen.getByLabelText('0%')).toHaveTextContent('0%'));
});
it('preserves custom labels, non-CPU formatting and offline fallback', () => {
const { container } = render(() => (
<>
<ResponsiveMetricCell value={0.17} type="cpu" label="Unavailable" />
<ResponsiveMetricCell value={0.17} type="memory" />
<ResponsiveMetricCell value={0.17} type="cpu" isRunning={false} />
</>
));
expect(screen.getByText('Unavailable')).toBeInTheDocument();
expect(screen.getByLabelText('0%')).toBeInTheDocument();
expect(screen.getByText('—')).toBeInTheDocument();
expect(container.querySelectorAll('[data-animated-number]')).toHaveLength(1);
});
});
@@ -66,6 +66,7 @@ export interface HistoryChartAccessibleDescriptionInput {
}
export function formatHistoryChartTooltipValue(value: number, unit?: string): string {
if (unit === '%' && value > 0 && value < 0.1) return '<0.1%';
if (unit === '%') return `${value.toFixed(1)}%`;
if (unit === 'B/s') return `${formatBytes(value)}/s`;
if (unit === 'C') return `${Math.round(value)}°C`;
@@ -2,7 +2,7 @@ import { Component, Show, createMemo, JSX } from 'solid-js';
import { AnimatedNumber } from '@/components/shared/AnimatedNumber';
import { MetricBar } from '@/components/Workloads/MetricBar';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { formatPercent } from '@/utils/format';
import { formatCpuPercent, formatPercent } from '@/utils/format';
import { getMetricSeverity } from '@/utils/metricThresholds';
import type { MetricDisplayThresholds, MetricSeverity } from '@/utils/metricThresholds';
@@ -98,7 +98,9 @@ function compactCapacityLabel(sublabel?: string): string | undefined {
*/
export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props) => {
const { isAtLeast, isBelow } = useBreakpoint();
const displayLabel = createMemo(() => props.label ?? formatPercent(props.value));
const formatValue = (value: number) =>
props.type === 'cpu' ? formatCpuPercent(value) : formatPercent(value);
const displayLabel = createMemo(() => props.label ?? formatValue(props.value));
const colorClass = createMemo(() => metricTextClass(props.value, props.type, props.thresholds));
const isRunning = () => props.isRunning !== false; // Default to true if not specified
@@ -133,7 +135,7 @@ export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props
class={`md:hidden text-xs text-center ${colorClass()} whitespace-nowrap overflow-hidden text-ellipsis`}
>
<Show when={!props.label} fallback={displayLabel()}>
<AnimatedNumber value={props.value} format={formatPercent} />
<AnimatedNumber value={props.value} format={formatValue} />
</Show>
</div>
</Show>
@@ -144,6 +146,7 @@ export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props
value={props.value}
label={displayLabel()}
animatedLabelValue={props.label ? undefined : props.value}
animatedLabelFormat={formatValue}
sublabel={resolvedSublabel()}
showLabel={showLabel()}
type={props.type}
@@ -80,6 +80,7 @@ import {
import type { DockerContainerUpdateStatus } from '@/types/api';
import type { Resource } from '@/types/resource';
import { DockerContainerLifecycleControls } from './DockerContainerLifecycleControls';
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from './dockerCpuPresentation';
type DockerNetwork = NonNullable<NonNullable<Resource['docker']>['networks']>[number];
type DockerMount = NonNullable<NonNullable<Resource['docker']>['mounts']>[number];
@@ -783,6 +784,14 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
>
<PlatformTableShell
title={props.title ?? 'Containers'}
footer={
<p
class="px-3 py-2 text-xs leading-relaxed text-muted"
data-testid="container-table-cpu-scale"
>
{CONTAINER_CPU_CAPACITY_DESCRIPTION}
</p>
}
tableClass={`${getDockerContainerTableMinWidthClass()} table-fixed text-xs`}
colgroup={
<colgroup>
@@ -0,0 +1,143 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChartsAPI } from '@/api/charts';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import type { Resource } from '@/types/resource';
import { DockerContainersTable } from '../DockerContainersTable';
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from '../dockerCpuPresentation';
vi.mock('@/stores/license', async (importOriginal) => ({
...(await importOriginal<typeof import('@/stores/license')>()),
loadRuntimeCapabilities: vi.fn(async () => undefined),
maxHistoryDays: () => 7,
isRangeLocked: () => false,
}));
const containerResource = (id: string, cpu: number | undefined, running = true): Resource => ({
id,
name: id,
displayName: id,
platformId: 'docker',
platformType: 'docker',
sourceType: 'agent',
sources: ['docker'],
type: 'app-container',
status: running ? 'running' : 'stopped',
lastSeen: 1_700_000_000_000,
cpu: cpu === undefined ? undefined : { current: cpu },
memory: { current: 23.9 },
metricsTarget: { resourceType: 'app-container', resourceId: id },
docker: {
hostname: 'lab-host',
containerId: id,
containerState: running ? 'running' : 'exited',
runtime: 'podman',
},
});
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
window.history.pushState({}, '', '/');
window.localStorage.clear();
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('Docker container CPU presentation through the real row and drawer', () => {
it('separates zero, fractional, unavailable and stopped readings without changing sort values', async () => {
const { container } = render(() => (
<Router>
<Route
path="/"
component={() => (
<DockerContainersTable
resources={[
containerResource('zero', 0),
containerResource('tiny', 0.026),
containerResource('small', 0.17),
containerResource('rounded', 0.49),
containerResource('unavailable', undefined),
containerResource('stopped', 3, false),
]}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
)}
/>
</Router>
));
const heads = screen.getAllByRole('columnheader');
const cpuIndex = heads.findIndex((head) => head.textContent?.trim() === 'CPU');
expect(cpuIndex).toBeGreaterThan(0);
const cell = (id: string) =>
container.querySelector(`[data-docker-container-row="${id}"]`)?.children[cpuIndex];
for (const [id, label] of [
['zero', '0%'],
['tiny', '<0.1%'],
['small', '0.2%'],
['rounded', '0.5%'],
['unavailable', '—'],
['stopped', '—'],
]) {
expect(cell(id)?.textContent?.trim()).toBe(label);
}
expect(screen.getByTestId('container-table-cpu-scale')).toHaveTextContent(
CONTAINER_CPU_CAPACITY_DESCRIPTION,
);
fireEvent.click(heads[cpuIndex]);
const order = [...container.querySelectorAll('[data-docker-container-row]')].map((row) =>
row.getAttribute('data-docker-container-row'),
);
// The stopped container still has its raw reading. Presentation never
// edits that datum, so the existing numeric sort continues to see it.
expect(order.slice(0, 5)).toEqual(['stopped', 'rounded', 'small', 'tiny', 'zero']);
expect(order[5]).toBe('unavailable');
const time = 1_700_000_000_000;
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue({
resourceType: 'app-container',
resourceId: 'tiny',
range: '24h',
start: time,
end: time + 120_000,
source: 'store',
metrics: {
cpu: [0, 0.026, 9.4].map((value, index) => ({
timestamp: time + index * 60_000,
value,
min: value,
max: value,
})),
},
});
fireEvent.click(screen.getByRole('button', { name: 'Expand details for tiny' }));
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' } });
expect(slider).toHaveAttribute('aria-valuetext', expect.stringContaining('CPU <0.1%'));
fireEvent.input(slider, { target: { value: '0' } });
expect(slider).toHaveAttribute('aria-valuetext', expect.stringContaining('CPU 0.0%'));
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({ resourceType: 'app-container', resourceId: 'tiny' }),
);
});
});
@@ -275,6 +275,12 @@ describe('Docker native tables', () => {
// Both rows get a CPU cell; only the row with memory data gets a bar.
expect(screen.getAllByTestId('responsive-cpu-metric').length).toBe(2);
expect(screen.getByTestId('container-table-cpu-scale')).toHaveTextContent(
'CPU is a percentage of total host capacity: 100% means all host CPUs.',
);
expect(screen.getByTestId('container-table-cpu-scale')).toHaveTextContent(
'Docker and Podman stats may use a per-core scale.',
);
expect(screen.getByTestId('stacked-memory-bar')).toHaveAttribute(
'data-used',
String(512 * 1024 * 1024),
@@ -0,0 +1,2 @@
export const CONTAINER_CPU_CAPACITY_DESCRIPTION =
'CPU is a percentage of total host capacity: 100% means all host CPUs. Docker and Podman stats may use a per-core scale.';
@@ -7,6 +7,7 @@ import {
formatSpeed,
formatObservedSpeed,
formatPercent,
formatCpuPercent,
formatNumber,
formatUptime,
formatAbsoluteTime,
@@ -68,6 +69,28 @@ describe('formatSpeed', () => {
});
});
describe('formatCpuPercent', () => {
it.each([
[0, '0%'],
[Number.MIN_VALUE, '<0.1%'],
[0.026, '<0.1%'],
[0.09999, '<0.1%'],
[0.1, '0.1%'],
[0.17, '0.2%'],
[0.49, '0.5%'],
[1.04, '1%'],
[3.04, '3%'],
[9.4, '9.4%'],
[10, '10%'],
[50.5, '51%'],
[100, '100%'],
[NaN, '—'],
[Infinity, '—'],
])('formats %s as %s without inventing a zero', (value, label) => {
expect(formatCpuPercent(value)).toBe(label);
});
});
describe('formatPercent', () => {
it('formats percentages correctly', () => {
expect(formatPercent(0)).toBe('0%');
+8
View File
@@ -85,6 +85,14 @@ export function formatPercent(value: number): string {
return `${Math.round(value)}%`;
}
/** Keep small observed CPU readings distinct from an actual zero. */
export function formatCpuPercent(value: number): string {
if (!Number.isFinite(value)) return '—';
if (value > 0 && value < 0.1) return '<0.1%';
if (value > 0 && value < 10) return `${value.toFixed(1).replace(/\.0$/, '')}%`;
return formatPercent(value);
}
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) return '0';
return value.toLocaleString();