diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0f53c48901..9232278f46 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ae04260b9a..8353a0031c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification/1695cea4cd6443e2.json b/frontend-modern/browser-verification/1695cea4cd6443e2.json new file mode 100644 index 0000000000..7c8aea1d23 --- /dev/null +++ b/frontend-modern/browser-verification/1695cea4cd6443e2.json @@ -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" +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index fafa64a341..4175655c0e 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -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 = (props) => { )} + +

+ {CONTAINER_CPU_CAPACITY_DESCRIPTION} +

+
+ { + 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 = {}) { const event = Object.assign(new Event(type, { bubbles: true, cancelable: true }), { pointerId: 1, diff --git a/frontend-modern/src/components/shared/__tests__/ResponsiveMetricCell.cpu.test.tsx b/frontend-modern/src/components/shared/__tests__/ResponsiveMetricCell.cpu.test.tsx new file mode 100644 index 0000000000..c625dd6edf --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/ResponsiveMetricCell.cpu.test.tsx @@ -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(() => ( + + )); + 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(() => ( + + )); + 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(() => ( + <> + + + + + )); + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + expect(screen.getByLabelText('0%')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + expect(container.querySelectorAll('[data-animated-number]')).toHaveLength(1); + }); +}); diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts index bd7436171d..fbf3e3f578 100644 --- a/frontend-modern/src/components/shared/historyChartModel.ts +++ b/frontend-modern/src/components/shared/historyChartModel.ts @@ -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`; diff --git a/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx index 6002dbd55c..0834407c68 100644 --- a/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx +++ b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx @@ -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 = (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 = (props class={`md:hidden text-xs text-center ${colorClass()} whitespace-nowrap overflow-hidden text-ellipsis`} > - + @@ -144,6 +146,7 @@ export const ResponsiveMetricCell: Component = (props value={props.value} label={displayLabel()} animatedLabelValue={props.label ? undefined : props.value} + animatedLabelFormat={formatValue} sublabel={resolvedSublabel()} showLabel={showLabel()} type={props.type} diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 85e9c3c0bd..4109b5784f 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -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['networks']>[number]; type DockerMount = NonNullable['mounts']>[number]; @@ -783,6 +784,14 @@ export const DockerContainersTable: Component = (pro > + {CONTAINER_CPU_CAPACITY_DESCRIPTION} +

+ } tableClass={`${getDockerContainerTableMinWidthClass()} table-fixed text-xs`} colgroup={ diff --git a/frontend-modern/src/features/docker/__tests__/DockerContainersTable.cpu-presentation.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerContainersTable.cpu-presentation.test.tsx new file mode 100644 index 0000000000..3c3f31f1b5 --- /dev/null +++ b/frontend-modern/src/features/docker/__tests__/DockerContainersTable.cpu-presentation.test.tsx @@ -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()), + 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(() => ( + + ( + } + emptyTitle="No containers" + emptyDescription="No containers" + showToolbar={false} + /> + )} + /> + + )); + 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' }), + ); + }); +}); diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 15bb7087d2..5dcf308a46 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -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), diff --git a/frontend-modern/src/features/docker/dockerCpuPresentation.ts b/frontend-modern/src/features/docker/dockerCpuPresentation.ts new file mode 100644 index 0000000000..cd2bf0b453 --- /dev/null +++ b/frontend-modern/src/features/docker/dockerCpuPresentation.ts @@ -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.'; diff --git a/frontend-modern/src/utils/__tests__/format.test.ts b/frontend-modern/src/utils/__tests__/format.test.ts index 30522ac44b..417c38b56e 100644 --- a/frontend-modern/src/utils/__tests__/format.test.ts +++ b/frontend-modern/src/utils/__tests__/format.test.ts @@ -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%'); diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 49c35bbe2f..c8d180b13e 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -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();