mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Keep fractional container CPU labels truthful
Distinguish small positive CPU and History percentages from actual zero, and explain the existing host-capacity scale in container tables and drawers. Preserve raw values, sorting, collection and alert thresholds; cover desktop and phone rendering with a parent-bound browser receipt and connected tests. Change-source: pulse-maintainer
This commit is contained in:
@@ -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>
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
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,
|
||||
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%');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user