mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Compose exact upstream storage and workload presentation
Preserve reviewed backup-reader and canonical deferred-read status guidance with upstream measured-width disk labels. Resolve only additive test assertions, keeping lifecycle refusal, PMG replacement, phone projection and word-wrapping controls. The combined disk model matches the previously diagnosed 9f50f630 content and requires fresh owning-lane browser proof. Change-source: pulse-maintainer
This commit is contained in:
@@ -1607,6 +1607,13 @@ service-history reads plus denial/recovery without fabricated samples.
|
||||
failing job's last good sync falls behind by its failure count, and a
|
||||
long-running demo never reports healthy jobs as overdue. A mock job never
|
||||
targets its own source node, and an estate without a second node has none.
|
||||
The mock estate carries shared storage in the shape the poller produces,
|
||||
not the per-node shape the PVE API returns: a shared PBS storage is one
|
||||
cluster-scoped row listing its reporting nodes (issue 1049), never one row
|
||||
per node with a different usage figure on each.
|
||||
Demo Docker container names are unique per host, as Docker itself enforces:
|
||||
a container profile reused for a second round on one host takes a numbered
|
||||
suffix instead of producing two rows with one name.
|
||||
Mock metrics history must also stay bounded independently of estate size:
|
||||
eager multi-day PVE guest history is limited to a deterministic sample spread
|
||||
across the estate, while every omitted guest continues to receive the same
|
||||
|
||||
@@ -402,6 +402,12 @@ every tick.
|
||||
Mock replication timing is advanced in that same full-rotation refresh, and
|
||||
only a job whose schedule phase has passed is rewritten, so keeping demo
|
||||
replication on schedule adds no per-tick change for jobs that did not run.
|
||||
Shared storage in the mock estate stays one row per cluster per store, so the
|
||||
Storage surface grows with the number of storages rather than with the node
|
||||
count times the number of shared storages.
|
||||
Demo Docker container names stay unique per host (numbered suffixes on reused
|
||||
profiles), so image "used by" lists and container rows never collapse two
|
||||
distinct containers into what reads as a duplicated row.
|
||||
|
||||
The browser applies resource deltas to its connection-scoped raw baseline, but
|
||||
canonicalizes and reconciles only changed resources plus the host-merge groups
|
||||
@@ -2563,6 +2569,13 @@ resize-observer plus tooltip lifecycle live in
|
||||
`frontend-modern/src/components/Workloads/useStackedDiskBarState.ts`.
|
||||
Future disk-bar runtime changes must extend through those owners instead of
|
||||
reintroducing mixed resize state and presentation branching into the shell.
|
||||
The multi-disk `vertical-bars` mode (Proxmox nodes, Machines, and Docker
|
||||
hosts with more than one agent-reported disk) draws one equal-weight micro-bar
|
||||
per disk and labels the cell with the fullest disk's usage percentage, derived
|
||||
in the model from the existing max-disk summary, so a multi-disk host answers
|
||||
"how close is this host to running out of space?" at a glance in Bars mode
|
||||
instead of only on hover or in Trends mode. The label names no disk in the
|
||||
row; the mount and the per-disk breakdown stay in the title and tooltip.
|
||||
The dashboard stacked memory bar now follows that same pattern: the shell
|
||||
stays in `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx`,
|
||||
while memory-capacity math, balloon/swap tooltip derivation, anomaly label
|
||||
@@ -2589,6 +2602,22 @@ resize-observer lifecycle lives in
|
||||
`frontend-modern/src/components/Workloads/useMetricBarState.ts`. Future
|
||||
metric-bar runtime changes must extend through those owners instead of
|
||||
reintroducing mixed resize state and label-fit logic into the shell.
|
||||
Label fit in the metric, stacked memory, and stacked disk bar models goes
|
||||
through `estimateTextWidth` in `frontend-modern/src/utils/format.ts`, which
|
||||
sums per-glyph advances rather than multiplying a character count. The
|
||||
advances come from one hidden probe of about 200 nodes carrying the label's
|
||||
type classes, read once per page load and again only if the device pixel
|
||||
ratio changes; that is the estimator's only layout read. It is never per bar,
|
||||
the bars still take their width from the resize observer alone, and each
|
||||
estimate after the probe is arithmetic over the label's characters. Each model
|
||||
adds the padding its own label sits in, the stacked bars count the anomaly
|
||||
marker that shares the label's line, and inline disk slots are sized after the
|
||||
gap between them. Measured in Chrome on the 429-bar Proxmox overview on
|
||||
2026-10-03: 1.7 ms for the probe and about 110 ns per estimate. Where layout
|
||||
is unavailable the estimator uses the advances measured for the macOS system
|
||||
font. `formatExtra.test.ts` verifies the single probe read and the fallback;
|
||||
`useMetricBarState.test.tsx` and `useStackedMemoryBarState.test.tsx` verify
|
||||
that the long label appears only at an observed width that fits it.
|
||||
The dashboard enhanced CPU bar now follows that same pattern: the shell stays
|
||||
in `frontend-modern/src/components/Workloads/EnhancedCPUBar.tsx`, while usage
|
||||
formatting, anomaly presentation, tooltip load-average formatting, and
|
||||
|
||||
@@ -2761,6 +2761,14 @@ capability over storage or recovery data.
|
||||
discriminator` and `falls back to the topology discriminator when no
|
||||
vdev layout is reported` in
|
||||
`frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts`.
|
||||
When neither is reported, a Proxmox storage plugin type resolves to the
|
||||
name the Proxmox UI gives it (`lvmthin` is LVM-Thin, `nfs` is NFS,
|
||||
`cifs` is SMB/CIFS, `iscsi` is iSCSI, `zfs`, `btrfs`, `glusterfs`,
|
||||
`esxi` likewise); title-casing the raw plugin name is not a label.
|
||||
A storage the poller merged across the cluster carries the synthetic
|
||||
`cluster` node (the mock fixture's `shared`), and its host label reads
|
||||
"Shared" with the reporting node count when the storage lists its
|
||||
nodes, never the raw marker.
|
||||
|
||||
27. A rejected legacy RBAC import must not destroy the store. The import is
|
||||
transactional and leaves the legacy files in place, so a failure leaves the
|
||||
|
||||
@@ -917,6 +917,19 @@ last success and last schedule) are relative-age values under that rule:
|
||||
`formatPlatformTableDateTimeValue` time as hover title, never as the raw RFC
|
||||
3339 string the agent reports, and the Detail column carries enough weight
|
||||
for those labelled ages to fit whole on a full-width desktop table.
|
||||
The controller row expansion carries those timestamps in full: the
|
||||
always-visible summary tables of the resource drawer render a Kubernetes
|
||||
workload-controller section (`buildKubernetesControllerSection`, in the same
|
||||
slot as the Docker container section) with the Job start and completion time,
|
||||
the CronJob schedule and last run and last success time, each as the absolute
|
||||
`formatPlatformTableDateTimeValue` time followed by its age in parentheses and
|
||||
allowed to wrap in a phone-width detail cell, plus the target,
|
||||
count, service, and scope fields the narrower layouts drop, so a phone or a
|
||||
half-width pane reaches them in one tap rather than through a collapsed
|
||||
platform-details disclosure. The `KubernetesControllersTable` phone projection
|
||||
keeps controller, kind, ready, and issues and demotes Target with
|
||||
`platform-table-phone-hidden`, so kind labels such as `DaemonSet` and
|
||||
`StatefulSet` fit whole instead of clipping in a 15 percent track.
|
||||
Duration and interval cells keep the same split: unified-resource or
|
||||
source-specific consumers own which elapsed duration, human fallback, or poll
|
||||
interval field is meaningful, while dense platform table rendering must use
|
||||
@@ -2150,6 +2163,14 @@ application resource-provider or WebSocket lifecycle.
|
||||
populates only the canonical field on the REST contract, so without
|
||||
landing on `v2.uptime` ESXi hosts and VMware-backed VMs lose uptime on
|
||||
the unified-resources side even though the API payload carries it.
|
||||
The same mapping must carry every facet the frontend reads at the top
|
||||
level of `Resource`: the provider facets (`pmg` included) and the
|
||||
per-source `sourceStatus`, `actionReadiness`, and `technology` fields the
|
||||
API returns. The REST and websocket transports race on a fresh route
|
||||
load, and a facet present only under `platformData` on the REST row left
|
||||
the Mail Gateway table rendering every PMG column as a dash, and Docker
|
||||
lifecycle refusals and storage freshness reading undefined, whenever the
|
||||
REST snapshot landed last.
|
||||
That same shared cache boundary must normalize route/query type filters
|
||||
through the canonical frontend-to-`ResourceType` resolver before slicing
|
||||
the snapshot, so compatibility values such as `disk` / `physical_disk`
|
||||
@@ -2673,6 +2694,11 @@ facet. Its value is owned by the backup recovery model, not by canonical
|
||||
resource identity: PBS instance and datastore form the repository scope, and
|
||||
the same value must survive the date/coverage route handoff so both views
|
||||
apply one repository selection without inventing a top-level recovery route.
|
||||
A PBS datastore resource takes its status from the storage vocabulary that PVE
|
||||
storage already uses (`available`/`unavailable` alongside online, degraded, and
|
||||
offline), so a healthy datastore is online and one PBS reports an error for is
|
||||
offline; the generic string mapper that left every datastore unknown is not a
|
||||
valid datastore status source.
|
||||
Unified-resource drawers and Kubernetes drill-down controls may own resource
|
||||
timeline filter semantics, namespace choices, and destination routes, but their
|
||||
native select chrome must compose the frontend-primitives-owned `FormSelect`.
|
||||
@@ -5528,6 +5554,16 @@ read-only status and governed UpdateButton content. The boundary must preserve
|
||||
full action names, review semantics and native agent/container identity; it must
|
||||
not hide update labels to satisfy width constraints. DockerNativeTables.test.ts
|
||||
checks that the governed update action remains inside that boundary.
|
||||
A phone row (under 440px) keeps container, state, CPU, memory and the update
|
||||
control and defers the restart count to the row expansion, so the state word
|
||||
and the Update control fit whole instead of clipping to "runni…" beside an
|
||||
unreadable Restarts header. At compact width (a 1280-1536px laptop window) the
|
||||
Host, Engine and Updates columns take enough share that "docker 26.1.3", a
|
||||
host name and the Update control render whole; the percentage-only CPU and
|
||||
memory bars yield the difference. Swarm services show the rollout state as
|
||||
words ("Rollback started") with the raw token, message and completion time in
|
||||
the title, and Swarm tasks show Started as an age with the RFC3339 stamp in
|
||||
the title, matching every other platform table's time cells.
|
||||
|
||||
ResourceDetailDrawer headings wrap long display names rather than ellipsising
|
||||
them. Overview rows may still truncate identity; keyboard expansion exposes the
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "bb4a1372583e28204f9ac825d54855e5e00557ca",
|
||||
"verified_at": "2026-10-03T20:35:27Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx",
|
||||
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
|
||||
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
|
||||
"frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/storageAdapters.ts",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "100cc005912a2bef11c55fa8b6961d473d245e8a3fb71ab8a640186c294b26eb",
|
||||
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613",
|
||||
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "0a84ec6834e2aa8f6603c57a3bad168911b9f06bef80c9c7ccbad07d1f04075c",
|
||||
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "6cc99405c7bdb2b2edfd992f6b17b2636bb9285a53e949e41609dfa8b339cacf",
|
||||
"frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72",
|
||||
"frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2",
|
||||
"frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/proxmox/storage",
|
||||
"/proxmox/mail",
|
||||
"/docker/overview",
|
||||
"/docker/images",
|
||||
"/docker/storage",
|
||||
"/docker/networks",
|
||||
"/docker/swarm",
|
||||
"/machines"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"PR #2432 rebuilt as one commit on origin/main 1f140a5d38 (after Tailwind 4, #2443/#2444), on a private mock stack whose backend is built from this tree (the mock generator and REST mapper changes are live), Vite serving this tree, light and dark",
|
||||
"Proxmox Storage at 1440 and 390: PBS datastores read Online instead of Unknown, types read NFS, Pool, Filesystem, Datastore and Backup Target, cluster-merged storage reads Shared \u00b7 6 nodes, shared PBS storage is one row; the 390 storage table still clips host and usage values exactly as main does, which open PR #2433 addresses",
|
||||
"Proxmox Overview nodes table at 1440 light and dark in Bars mode: seven multi-disk hosts show their fullest disk percentage beside equal-weight micro-bars with a Highest usage title, none clipped; bar and fill corners compute 2px, matching main's rounded-xs after the Tailwind 4 rename",
|
||||
"Mail Gateway on six fresh loads: both instances fill Version, Nodes, Uptime and mail counters every time, with no dash-only rows",
|
||||
"Docker at 1440, 1280 and 390: Engine reads docker 26.1.3 or podman 4.9.3 whole with the Update control in view; phone containers keep Container, State, CPU, Mem and Update; phone images keep Image, Size and Update; Swarm services read Rollback started and tasks show Started as ages with no raw RFC3339 stamps; storage, networks and swarm phone clipping is limited to identity strings",
|
||||
"The mock estate has no multi-disk Docker host or machine, so Bars mode was seen on Proxmox nodes only; DockerHostsTable and AgentsMachinesTable share the same StackedDiskBar path and its unit tests"
|
||||
],
|
||||
"interactions": [
|
||||
"scrollWidth-over-clientWidth sweep of every visible table cell (sr-only excluded) on each route and width; read column value distributions for Storage State, Type and Host and Docker Engine",
|
||||
"Opened Mail Gateway from the Proxmox tab strip on six independent page loads and counted dash-only cells per row",
|
||||
"Read computed border radius of the vertical disk bars and the max-label text and title on every multi-disk node row",
|
||||
"go test ./internal/mock ./internal/unifiedresources passed; vitest for src/components/Workloads, src/features/docker, src/features/storageBackups, src/features/platformPage and useUnifiedResources: 174 files, 3384 tests passed"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "ebda81d2be79d2ee7e7ff4f665b41e1d330d87a6",
|
||||
"verified_at": "2026-10-03T20:21:04Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "ddad24a010a2a194f37977f3c3aa3fa647b431bd65d1d75e9f5aea6d42f72692",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "5d7362905632cf4e69c4a878f972ac40544bda2959db2822a431bcc74ba35089"
|
||||
},
|
||||
"routes": [
|
||||
"/kubernetes"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 900,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Merge of origin/main 1f140a5d38 into #2434: both files carry the PR's controller-expansion changes plus main's Tailwind 4 renames (max-w-48/max-w-52 truncation spans, rounded-sm badges, dark:bg-blue-900/25 identity IP badge), checked on a private mock stack built from the merged tree",
|
||||
"Workload Controllers table with DaemonSet, CronJob, Job and StatefulSet rows across three mock clusters: 1440 shows Scope and Detail, 1280 and 900 hide them, 390 keeps Controller, Kind, Ready and Issues; table width equals its container at every width, 1440 clips only one long StatefulSet Service detail, 1280 and 900 clip nothing, 390 clips only long controller names",
|
||||
"CronJob, Job and StatefulSet rows expanded at every width in light (and dark at 1440 and 390): the CronJob section shows Schedule, Last run and Last success as absolute time with age, Active, Namespace and Cluster; the Job section shows Started, Completed, Duration 5m, Target 1 completion and Succeeded; the StatefulSet shows Service, Target, Current, Ready, Available and Updated; no value in those sections clips, and only the pre-existing Identity IDs clip at 390"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /kubernetes per viewport and colour scheme, clicked the cron-nightly-backfill, nightly-backfill-28918234 and platform-observability rows to open and close their inline drawers",
|
||||
"Ran a scrollWidth-over-clientWidth sweep over every controllers th/td leaf and every leaf inside each open drawer",
|
||||
"Ran vitest for src/features/kubernetes, src/components/Infrastructure and src/features/platformPage on the merged tree: 90 files, 1504 tests passed"
|
||||
]
|
||||
}
|
||||
+37
-9
@@ -2,16 +2,20 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { estimateTextWidth } from '@/utils/format';
|
||||
|
||||
import metricBarSource from '../MetricBar.tsx?raw';
|
||||
import type { MetricBarProps } from '../metricBarModel';
|
||||
import { buildMetricBarPresentation } from '../metricBarModel';
|
||||
|
||||
// estimateTextWidth(text) = text.length * 5.5 + 8 (mirrored from @/utils/format).
|
||||
// For label='CPU' + sublabel='8c' the composed threshold text is 'CPU (8c)'
|
||||
// (length 8) -> estimateTextWidth = 8 * 5.5 + 8 = 52. So a containerWidth of 52
|
||||
// trips the >= true arm of the showSublabel check, and 51 trips the false arm.
|
||||
// The long form needs its text plus the 4px of padding the label span
|
||||
// carries. For label='CPU' + sublabel='8c' the text is 'CPU' semibold (21.83)
|
||||
// and ' (8c)' at normal weight (23.04): 44.87, rounded up to 45, plus 4 = 49.
|
||||
// So a containerWidth of 49 trips the >= true arm of the showSublabel check,
|
||||
// and 48 trips the false arm.
|
||||
const LABEL = 'CPU';
|
||||
const SUBLABEL = '8c';
|
||||
const SUBLABEL_THRESHOLD = estimateTextWidth(`${LABEL} (${SUBLABEL})`); // 52
|
||||
const LABEL_PADDING_PX = 4;
|
||||
const SUBLABEL_THRESHOLD =
|
||||
estimateTextWidth(LABEL, { detail: ` (${SUBLABEL})` }) + LABEL_PADDING_PX; // 49
|
||||
|
||||
// Tailwind background class mirrored from metricThresholds.BG_CLASSES.normal.
|
||||
// Default cpu thresholds are warning 80 / critical 90 (METRIC_THRESHOLDS.cpu),
|
||||
@@ -65,23 +69,47 @@ describe('buildMetricBarPresentation (branch coverage 0720pm)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...))', () => {
|
||||
describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...) + padding)', () => {
|
||||
it('returns showSublabel=true when containerWidth meets the threshold exactly (>= true arm)', () => {
|
||||
// estimateTextWidth('CPU (8c)') === 52; containerWidth 52 -> 52 >= 52 true.
|
||||
expect(SUBLABEL_THRESHOLD).toBe(52);
|
||||
// The threshold is 49; containerWidth 49 -> 49 >= 49 true.
|
||||
expect(SUBLABEL_THRESHOLD).toBe(49);
|
||||
const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD);
|
||||
expect(p.showLabel).toBe(true);
|
||||
expect(p.showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('returns showSublabel=false when containerWidth is one below the threshold (false arm)', () => {
|
||||
// containerWidth 51 < 52 -> the >= arm is false -> showSublabel false.
|
||||
// containerWidth 48 < 49 -> the >= arm is false -> showSublabel false.
|
||||
// showLabel itself is unaffected by the width check (still true here).
|
||||
const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD - 1);
|
||||
expect(p.showLabel).toBe(true);
|
||||
expect(p.showSublabel).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => {
|
||||
// Measured in the browser on 2026-10-03: that label is 110.1px of text
|
||||
// at text-[10px], so it needs 114.1px here and 118.1px in the memory
|
||||
// bar's wider chip. The old 5.5px-per-character estimate put it at
|
||||
// 112.5px, showed the long form in a 113px bar and clipped it.
|
||||
const props = makeProps({ value: 60, label: '60%', sublabel: '265 GB/440 GB' });
|
||||
expect(buildMetricBarPresentation(props, 109).showSublabel).toBe(false);
|
||||
expect(buildMetricBarPresentation(props, 113).showSublabel).toBe(false);
|
||||
expect(buildMetricBarPresentation(props, 114).showSublabel).toBe(false);
|
||||
expect(buildMetricBarPresentation(props, 115).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('fits a dotted label that the per-character estimate turned away', () => {
|
||||
// '71% (5.65/8.00TB)' renders 93.5px wide: full stops and the slash are
|
||||
// half a digit. It fits a 100px bar; 17 characters at 5.5px did not.
|
||||
const props = makeProps({ value: 71, label: '71%', sublabel: '5.65/8.00TB' });
|
||||
expect(buildMetricBarPresentation(props, 100).showSublabel).toBe(true);
|
||||
expect(buildMetricBarPresentation(props, 97).showSublabel).toBe(false);
|
||||
});
|
||||
|
||||
it('counts the padding the label span carries in MetricBar', () => {
|
||||
expect(metricBarSource).toContain('text-ellipsis px-0.5 text-center');
|
||||
});
|
||||
|
||||
it('returns showSublabel=false when sublabel is empty (Boolean(props.sublabel) false arm)', () => {
|
||||
// Even with a very wide container, Boolean('') is false -> showSublabel false.
|
||||
const p = buildMetricBarPresentation(makeProps({ sublabel: '' }), 1000);
|
||||
|
||||
+68
-5
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { AnomalyReport } from '@/types/aiIntelligence';
|
||||
import type { Disk } from '@/types/api';
|
||||
|
||||
import stackedDiskBarSource from '../StackedDiskBar.tsx?raw';
|
||||
import { buildStackedDiskBarPresentation } from '../stackedDiskBarModel';
|
||||
|
||||
// The four small helpers under test (getDiskUsagePercent, getShortDiskLabel,
|
||||
@@ -117,7 +118,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
describe('getInlineDiskText (via miniDisks[].inlineText)', () => {
|
||||
// estimateInlineTextWidth(text) = text.length * 5.4 + 4 (module-internal).
|
||||
// estimateInlineTextWidth(text) is the label's width at text-[9px]
|
||||
// semibold, rounded up, plus the slot's 2px of padding (module-internal).
|
||||
it('returns the full text when slotWidth <= 0 (short-circuit)', () => {
|
||||
// slotWidth = containerWidth(0) / 1 = 0 -> first operand of the || .
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
@@ -130,7 +132,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
it('returns the full text when it fits within the slot', () => {
|
||||
// 'sda1 50%' width = 8 * 5.4 + 4 = 47.2 <= slotWidth(60).
|
||||
// 'sda1 50%' width = 46 + 2 = 48 <= slotWidth(60).
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
{
|
||||
disks: [makeDisk({ mountpoint: '/dev/sda1', total: 100, used: 50, free: 50, usage: 50 })],
|
||||
@@ -141,8 +143,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
it('falls back to the percent label when only it fits', () => {
|
||||
// shortLabel 'a', percentLabel '100%'. full 'a 100%' width 36.4 > 30;
|
||||
// '100%' width 25.6 <= 30 -> returns '100%'.
|
||||
// shortLabel 'a', percentLabel '100%'. full 'a 100%' width 38 > 30;
|
||||
// '100%' width 30 <= 30 -> returns '100%'.
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
{ disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] },
|
||||
30,
|
||||
@@ -151,7 +153,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
it('falls back to the short label when the percent label does not fit but the short label does', () => {
|
||||
// slotWidth 20: full 36.4 > 20; percent '100%' 25.6 > 20; short 'a' 9.4 <= 20 -> 'a'.
|
||||
// slotWidth 20: full 38 > 20; percent '100%' 30 > 20; short 'a' 8 <= 20 -> 'a'.
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
{ disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] },
|
||||
20,
|
||||
@@ -167,6 +169,26 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
);
|
||||
expect(p.miniDisks[0].inlineText).toBe('');
|
||||
});
|
||||
|
||||
it('sizes each slot after the gap between slots', () => {
|
||||
// Two disks in a 117.58px bar (Proxmox nodes at a 900px viewport) get
|
||||
// 57.79px each once the 2px gap is out. 'backup 80%' needs 58 + 2, so
|
||||
// the slot keeps the percentage; splitting the bar in half had let the
|
||||
// full text in and clipped it.
|
||||
const disks = [
|
||||
makeDisk({ mountpoint: '/backup', total: 100, used: 80, free: 20, usage: 80 }),
|
||||
makeDisk({ mountpoint: '/', total: 100, used: 50, free: 50, usage: 50 }),
|
||||
];
|
||||
const narrow = buildStackedDiskBarPresentation({ disks }, 117.58);
|
||||
expect(narrow.miniDisks.map((disk) => disk.inlineText)).toEqual(['80%', '/ 50%']);
|
||||
const wide = buildStackedDiskBarPresentation({ disks }, 122);
|
||||
expect(wide.miniDisks[0].inlineText).toBe('backup 80%');
|
||||
});
|
||||
|
||||
it('counts the padding and gap the inline slots carry in StackedDiskBar', () => {
|
||||
expect(stackedDiskBarSource).toContain('flex h-full items-stretch gap-0.5');
|
||||
expect(stackedDiskBarSource).toContain('overflow-hidden px-px text-center text-[9px]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStackedDiskColor (via segments[].color, mode=stacked)', () => {
|
||||
@@ -467,6 +489,47 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
expect(unknown.anomalyRatio).toBe('');
|
||||
});
|
||||
|
||||
it('shows the sublabel only where the whole label fits', () => {
|
||||
// '60% (265 GB/440 GB)' is 110.1px of text in the browser and sits in
|
||||
// 4px of padding: a 113px bar keeps the percentage, a 115px bar fits it.
|
||||
const GiB = 1024 ** 3;
|
||||
const disks = [
|
||||
makeDisk({
|
||||
mountpoint: '/',
|
||||
total: 440 * GiB,
|
||||
used: 265 * GiB,
|
||||
free: 175 * GiB,
|
||||
usage: 60,
|
||||
}),
|
||||
];
|
||||
const narrow = buildStackedDiskBarPresentation({ disks }, 113);
|
||||
expect(narrow.displayLabel).toBe('60%');
|
||||
expect(narrow.displaySublabel).toBe('265 GB/440 GB');
|
||||
expect(narrow.showSublabel).toBe(false);
|
||||
expect(buildStackedDiskBarPresentation({ disks }, 114).showSublabel).toBe(false);
|
||||
expect(buildStackedDiskBarPresentation({ disks }, 115).showSublabel).toBe(true);
|
||||
expect(stackedDiskBarSource).toContain('text-ellipsis px-0.5 text-center');
|
||||
});
|
||||
|
||||
it('leaves room for the anomaly marker that shares the line', () => {
|
||||
// ' 2.5x' adds 25.35px, taking the threshold from 115 to 140.
|
||||
const GiB = 1024 ** 3;
|
||||
const props = {
|
||||
disks: [
|
||||
makeDisk({
|
||||
mountpoint: '/',
|
||||
total: 440 * GiB,
|
||||
used: 265 * GiB,
|
||||
free: 175 * GiB,
|
||||
usage: 60,
|
||||
}),
|
||||
],
|
||||
anomaly: makeAnomaly({ baseline_mean: 10, current_value: 25 }),
|
||||
};
|
||||
expect(buildStackedDiskBarPresentation(props, 139).showSublabel).toBe(false);
|
||||
expect(buildStackedDiskBarPresentation(props, 140).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the sublabel when containerWidth is too narrow to fit it', () => {
|
||||
// displaySublabel non-empty but containerWidth < estimateTextWidth(...) ->
|
||||
// showSublabel false (the width-failing arm of the && ).
|
||||
|
||||
+40
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AnomalyReport } from '@/types/aiIntelligence';
|
||||
|
||||
import stackedMemoryBarSource from '../StackedMemoryBar.tsx?raw';
|
||||
import type { StackedMemoryBarProps } from '../stackedMemoryBarModel';
|
||||
import { buildStackedMemoryBarPresentation } from '../stackedMemoryBarModel';
|
||||
|
||||
@@ -194,4 +195,43 @@ describe('stackedMemoryBarModel (branch coverage 0724pm)', () => {
|
||||
expect(p.anomalyDescription).toBe('Memory spike');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showSublabel fit (label text plus the chip padding)', () => {
|
||||
// 265 of 440 GiB reads '60% (265 GB/440 GB)': 110.1px of text in the
|
||||
// browser, 118.1px with the chip's 8px of padding. This is the label the
|
||||
// Kubernetes clusters table clipped in a 113px bar on 2026-10-03.
|
||||
const clusterMemory = makeProps({ used: 265 * GiB, total: 440 * GiB });
|
||||
|
||||
it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => {
|
||||
const p = buildStackedMemoryBarPresentation(clusterMemory, 113);
|
||||
expect(p.displayLabel).toBe('60%');
|
||||
expect(p.displaySublabel).toBe('265 GB/440 GB');
|
||||
expect(p.showSublabel).toBe(false);
|
||||
expect(buildStackedMemoryBarPresentation(clusterMemory, 118).showSublabel).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the long label once the bar is wide enough for it', () => {
|
||||
expect(buildStackedMemoryBarPresentation(clusterMemory, 119).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves room for the anomaly marker that shares the line', () => {
|
||||
// ' 3.0x' adds 25.44px, taking the threshold from 119 to 145.
|
||||
const withMarker = makeProps({ ...clusterMemory, anomaly: makeAnomaly() });
|
||||
expect(buildStackedMemoryBarPresentation(withMarker, 144).showSublabel).toBe(false);
|
||||
expect(buildStackedMemoryBarPresentation(withMarker, 145).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an anomaly that renders no marker', () => {
|
||||
// The marker needs both a description and a ratio.
|
||||
const noDescription = makeProps({
|
||||
...clusterMemory,
|
||||
anomaly: makeAnomaly({ description: '' }),
|
||||
});
|
||||
expect(buildStackedMemoryBarPresentation(noDescription, 119).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('counts the padding the label chip carries in StackedMemoryBar', () => {
|
||||
expect(stackedMemoryBarSource).toContain('px-1 text-center');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,4 +65,38 @@ describe('useMetricBarState', () => {
|
||||
unmount();
|
||||
expect(observers[0].disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the long label only at an observed width that fits it', () => {
|
||||
const observers: MockResizeObserver[] = [];
|
||||
globalThis.ResizeObserver = class extends MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
super(callback);
|
||||
observers.push(this);
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
let captured: ReturnType<typeof useMetricBarState> | undefined;
|
||||
|
||||
const Harness = () => {
|
||||
captured = useMetricBarState({
|
||||
value: 60,
|
||||
label: '60%',
|
||||
sublabel: '265 GB/440 GB',
|
||||
type: 'memory',
|
||||
});
|
||||
return <div ref={captured.setContainerRef} />;
|
||||
};
|
||||
|
||||
render(() => <Harness />);
|
||||
|
||||
// '60% (265 GB/440 GB)' renders 110.1px of text plus 4px of padding. The
|
||||
// width arrives from the resize observer alone; the fit never reads the
|
||||
// bar's own layout.
|
||||
observers[0].trigger(113);
|
||||
expect(captured!.presentation().showSublabel).toBe(false);
|
||||
observers[0].trigger(115);
|
||||
expect(captured!.presentation().showSublabel).toBe(true);
|
||||
observers[0].trigger(109);
|
||||
expect(captured!.presentation().showSublabel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,34 @@ describe('useStackedMemoryBarState', () => {
|
||||
unmount();
|
||||
expect(observers[0].disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the long label only at an observed width that fits it', () => {
|
||||
const observers: MockResizeObserver[] = [];
|
||||
globalThis.ResizeObserver = class extends MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
super(callback);
|
||||
observers.push(this);
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
let captured: ReturnType<typeof useStackedMemoryBarState> | undefined;
|
||||
|
||||
const Harness = () => {
|
||||
captured = useStackedMemoryBarState({
|
||||
used: 265 * 1024 ** 3,
|
||||
total: 440 * 1024 ** 3,
|
||||
});
|
||||
return <div ref={captured.setContainerRef} />;
|
||||
};
|
||||
|
||||
render(() => <Harness />);
|
||||
|
||||
// '60% (265 GB/440 GB)' renders 110.1px of text plus the chip's 8px of
|
||||
// padding: the 113px Kubernetes clusters bar keeps the percentage.
|
||||
observers[0].trigger(113);
|
||||
expect(captured!.presentation().displaySublabel).toBe('265 GB/440 GB');
|
||||
expect(captured!.presentation().showSublabel).toBe(false);
|
||||
observers[0].trigger(119);
|
||||
expect(captured!.presentation().showSublabel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface MetricBarPresentation {
|
||||
width: number;
|
||||
}
|
||||
|
||||
// Horizontal padding around the label inside the bar (px-0.5 in MetricBar.tsx).
|
||||
const LABEL_PADDING_PX = 4;
|
||||
|
||||
export function buildMetricBarPresentation(
|
||||
props: MetricBarProps,
|
||||
containerWidth: number,
|
||||
@@ -31,7 +34,8 @@ export function buildMetricBarPresentation(
|
||||
const showSublabel =
|
||||
showLabel &&
|
||||
Boolean(props.sublabel) &&
|
||||
containerWidth >= estimateTextWidth(`${props.label} (${props.sublabel})`);
|
||||
containerWidth >=
|
||||
estimateTextWidth(props.label, { detail: ` (${props.sublabel})` }) + LABEL_PADDING_PX;
|
||||
const metric = props.type || 'cpu';
|
||||
const metricType: MetricType = metric === 'generic' ? 'cpu' : metric;
|
||||
|
||||
|
||||
@@ -149,8 +149,17 @@ function getShortDiskLabel(label: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Horizontal padding around the aggregate label inside the bar (px-0.5 in
|
||||
// StackedDiskBar.tsx).
|
||||
const LABEL_PADDING_PX = 4;
|
||||
// Inline per-disk labels are text-[9px] font-semibold, in px-px slots that a
|
||||
// gap-0.5 separates.
|
||||
const INLINE_DISK_FONT_PX = 9;
|
||||
const INLINE_DISK_SLOT_PADDING_PX = 2;
|
||||
const INLINE_DISK_SLOT_GAP_PX = 2;
|
||||
|
||||
function estimateInlineTextWidth(text: string): number {
|
||||
return text.length * 5.4 + 4;
|
||||
return estimateTextWidth(text, { fontPx: INLINE_DISK_FONT_PX }) + INLINE_DISK_SLOT_PADDING_PX;
|
||||
}
|
||||
|
||||
function getInlineDiskText(shortLabel: string, percentLabel: string, slotWidth: number): string {
|
||||
@@ -274,7 +283,10 @@ export function buildStackedDiskBarPresentation(
|
||||
const inlineDiskMode =
|
||||
(miniMode || hasMultipleDisks) && !aggregateMode && !explicitStackedMode && !verticalBarsMode;
|
||||
const useStackedSegments = hasMultipleDisks && explicitStackedMode;
|
||||
const inlineDiskSlotWidth = disks.length > 0 ? containerWidth / disks.length : 0;
|
||||
const inlineDiskSlotWidth =
|
||||
disks.length > 0
|
||||
? (containerWidth - INLINE_DISK_SLOT_GAP_PX * (disks.length - 1)) / disks.length
|
||||
: 0;
|
||||
const totalCapacity = hasDisks
|
||||
? disks.reduce((sum, disk) => sum + (disk.total || 0), 0)
|
||||
: (props.aggregateDisk?.total ?? 0);
|
||||
@@ -312,15 +324,19 @@ export function buildStackedDiskBarPresentation(
|
||||
(aggregateMode &&
|
||||
hasMultipleDisks &&
|
||||
maxLabelShort.length > 0 &&
|
||||
containerWidth >= estimateTextWidth(`${displayLabel} ${maxLabelShort}`));
|
||||
containerWidth >=
|
||||
estimateTextWidth(displayLabel, { detail: ` ${maxLabelShort}` }) + LABEL_PADDING_PX);
|
||||
// The anomaly marker shares the label's line whenever it renders.
|
||||
const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : '';
|
||||
const showSublabel =
|
||||
displaySublabel.length > 0 &&
|
||||
containerWidth >=
|
||||
estimateTextWidth(
|
||||
`${displayLabel}${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${
|
||||
estimateTextWidth(`${displayLabel}${anomalyMarker}`, {
|
||||
detail: `${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${
|
||||
showDiskCount ? ` ${diskCountLabel}` : ''
|
||||
}`,
|
||||
);
|
||||
}) +
|
||||
LABEL_PADDING_PX;
|
||||
const barColor =
|
||||
aggregateMode && hasMultipleDisks && maxInfo
|
||||
? getMetricColorRgba(maxInfo.percent, 'disk', props.thresholds)
|
||||
|
||||
@@ -271,6 +271,9 @@ function getTooltipRows(
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Horizontal padding of the label chip inside the bar (px-1 in StackedMemoryBar.tsx).
|
||||
const LABEL_PADDING_PX = 8;
|
||||
|
||||
export function buildStackedMemoryBarPresentation(
|
||||
props: StackedMemoryBarProps,
|
||||
containerWidth: number,
|
||||
@@ -281,16 +284,21 @@ export function buildStackedMemoryBarPresentation(
|
||||
!props.unavailable && props.total > 0
|
||||
? `${formatBytes(props.used)}/${formatBytes(props.total)}`
|
||||
: '';
|
||||
const anomalyRatio = formatAnomalyRatio(props.anomaly) ?? '';
|
||||
// The anomaly marker shares the label's line whenever it renders.
|
||||
const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : '';
|
||||
const showSublabel =
|
||||
displaySublabel.length > 0 &&
|
||||
containerWidth >= estimateTextWidth(`${displayLabel} (${displaySublabel})`);
|
||||
containerWidth >=
|
||||
estimateTextWidth(`${displayLabel}${anomalyMarker}`, { detail: ` (${displaySublabel})` }) +
|
||||
LABEL_PADDING_PX;
|
||||
|
||||
return {
|
||||
anomalyClass: props.anomaly
|
||||
? (ANOMALY_SEVERITY_CLASS[props.anomaly.severity] ?? 'text-yellow-400')
|
||||
: 'text-yellow-400',
|
||||
anomalyDescription: props.anomaly?.description,
|
||||
anomalyRatio: formatAnomalyRatio(props.anomaly) ?? '',
|
||||
anomalyRatio,
|
||||
displayLabel,
|
||||
displayPercentValue: utilizationPercent,
|
||||
displaySublabel,
|
||||
|
||||
@@ -1516,10 +1516,7 @@ describe('Docker native tables', () => {
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
// The snake_case rollout token reads as words; the raw token stays in the title.
|
||||
expect(screen.getByText('Rollback started')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rollback started')).toHaveClass(
|
||||
'whitespace-normal',
|
||||
'wrap-break-word',
|
||||
);
|
||||
expect(screen.getByText('Rollback started')).toHaveClass('whitespace-normal', 'wrap-break-word');
|
||||
expect(
|
||||
screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'),
|
||||
).toContain('rollback_started');
|
||||
|
||||
+11
@@ -321,9 +321,20 @@ describe('platform overview layout guardrails', () => {
|
||||
]) {
|
||||
expect(source).toContain('platform-table-mobile-w-30');
|
||||
}
|
||||
// The images phone row keeps image, size and the update verdict; host and
|
||||
// consumers move to the row expansion so the verdict is not clipped.
|
||||
expect(dockerImagesTableSource).toMatch(
|
||||
/sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/,
|
||||
);
|
||||
expect(dockerImagesTableSource).toMatch(
|
||||
/sortKey="host"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[18%\]"/,
|
||||
);
|
||||
expect(dockerImagesTableSource).toMatch(
|
||||
/sortKey="usedBy"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[24%\]"/,
|
||||
);
|
||||
expect(dockerImagesTableSource).toMatch(
|
||||
/sortKey="update"[\s\S]{0,120}?class="platform-table-mobile-w-25 md:w-\[16%\]"/,
|
||||
);
|
||||
expect(dockerServicesTableSource).toMatch(
|
||||
/sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/,
|
||||
);
|
||||
|
||||
@@ -58,24 +58,26 @@ describe('formatPowerOnHours - boundaries', () => {
|
||||
});
|
||||
|
||||
describe('estimateTextWidth - edge inputs', () => {
|
||||
it('counts whitespace characters as full-width', () => {
|
||||
expect(estimateTextWidth(' ')).toBe(13.5);
|
||||
expect(estimateTextWidth(' ')).toBe(19);
|
||||
it('gives a space its own narrow advance', () => {
|
||||
// A semibold space is 2.78px in the fallback advances.
|
||||
expect(estimateTextWidth(' ')).toBe(3);
|
||||
expect(estimateTextWidth(' ')).toBe(6);
|
||||
});
|
||||
|
||||
it('counts control characters (tab, newline) as length 1', () => {
|
||||
expect(estimateTextWidth('\t')).toBe(13.5);
|
||||
expect(estimateTextWidth('\n')).toBe(13.5);
|
||||
it('counts a glyph outside the measured ASCII range as a full em', () => {
|
||||
expect(estimateTextWidth('\t')).toBe(10);
|
||||
expect(estimateTextWidth('\n')).toBe(10);
|
||||
expect(estimateTextWidth('—')).toBe(10);
|
||||
});
|
||||
|
||||
it('counts surrogate pairs as length 2 (UTF-16 code units)', () => {
|
||||
it('counts surrogate pairs as two UTF-16 code units', () => {
|
||||
// "😀" is one code point but two UTF-16 code units -> length === 2
|
||||
expect('😀'.length).toBe(2);
|
||||
expect(estimateTextWidth('😀')).toBe(19);
|
||||
expect(estimateTextWidth('😀')).toBe(20);
|
||||
});
|
||||
|
||||
it('scales linearly for very long strings', () => {
|
||||
expect(estimateTextWidth('a'.repeat(100))).toBe(558);
|
||||
expect(estimateTextWidth('a'.repeat(100))).toBe(589); // 100 x 5.89
|
||||
});
|
||||
|
||||
it('is strictly monotonic with respect to length', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
formatPowerOnHours,
|
||||
estimateTextWidth,
|
||||
@@ -36,12 +36,75 @@ describe('formatPowerOnHours', () => {
|
||||
});
|
||||
|
||||
describe('estimateTextWidth', () => {
|
||||
it('estimates width based on character count', () => {
|
||||
expect(estimateTextWidth('')).toBe(8);
|
||||
expect(estimateTextWidth('a')).toBe(13.5);
|
||||
expect(estimateTextWidth('abc')).toBe(24.5);
|
||||
expect(estimateTextWidth('hello')).toBe(35.5); // 5 chars = 27.5 + 8
|
||||
expect(estimateTextWidth('hello world')).toBe(68.5); // 11 chars = 60.5 + 8
|
||||
// jsdom has no layout, so these read the measured fallback advances
|
||||
// (macOS system font at 10px) rather than a live probe.
|
||||
it('returns zero for empty text', () => {
|
||||
expect(estimateTextWidth('')).toBe(0);
|
||||
});
|
||||
|
||||
it('sums per-glyph advances and rounds up', () => {
|
||||
expect(estimateTextWidth('a')).toBe(6); // 5.89
|
||||
expect(estimateTextWidth('abc')).toBe(19); // 5.89 + 6.51 + 5.9 = 18.3
|
||||
});
|
||||
|
||||
it('tells wide glyphs from narrow ones at the same length', () => {
|
||||
expect(estimateTextWidth('888')).toBe(21); // 3 x 6.88
|
||||
expect(estimateTextWidth('...')).toBe(11); // 3 x 3.43
|
||||
expect(estimateTextWidth('MMM')).toBe(28); // 3 x 9.08
|
||||
expect(estimateTextWidth('iii')).toBe(9); // 3 x 2.86
|
||||
});
|
||||
|
||||
it('measures detail at normal weight after the semibold value', () => {
|
||||
// 60% is 23.7 semibold, " (265 GB/440 GB)" 86.89 at normal weight. The
|
||||
// browser renders that label 110.1px wide.
|
||||
expect(estimateTextWidth('60%', { detail: ' (265 GB/440 GB)' })).toBe(111);
|
||||
expect(estimateTextWidth('265 GB')).toBe(38);
|
||||
expect(estimateTextWidth('', { detail: '265 GB' })).toBe(37);
|
||||
});
|
||||
|
||||
it('scales with the label font size', () => {
|
||||
expect(estimateTextWidth('sda1 50%')).toBe(52); // 51.03
|
||||
expect(estimateTextWidth('sda1 50%', { fontPx: 9 })).toBe(46); // 45.93
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateTextWidth with layout', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reads glyph advances from one hidden probe and reuses them', async () => {
|
||||
// A stand-in font: digits 8px, capitals 9px, everything else 4px, each
|
||||
// probe cell holding its glyph sixteen times.
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: Element,
|
||||
) {
|
||||
const text = this.textContent ?? '';
|
||||
const advance = /\d/.test(text[0]) ? 8 : /[A-Z]/.test(text[0]) ? 9 : 4;
|
||||
return { width: text.length * advance } as DOMRect;
|
||||
});
|
||||
vi.resetModules();
|
||||
const { estimateTextWidth: measured } = await import('@/utils/format');
|
||||
|
||||
// 8 digits, 4 capitals, 7 others.
|
||||
expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(8 * 8 + 4 * 9 + 7 * 4);
|
||||
// U+0020..U+007E at both weights, plus the ten tabular digits.
|
||||
expect(rect).toHaveBeenCalledTimes(95 + 10 + 95);
|
||||
expect(document.body.childElementCount).toBe(0);
|
||||
|
||||
measured('73%', { detail: ' (475 GB/648 GB)' });
|
||||
expect(rect).toHaveBeenCalledTimes(95 + 10 + 95);
|
||||
});
|
||||
|
||||
it('keeps the measured table when a stubbed layout reports every box alike', async () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 100,
|
||||
} as DOMRect);
|
||||
vi.resetModules();
|
||||
const { estimateTextWidth: measured } = await import('@/utils/format');
|
||||
|
||||
expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(111);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -283,13 +283,131 @@ export function formatPowerOnHours(hours: number, condensed = false): string {
|
||||
return condensed ? `${hours}h` : `${hours} hours`;
|
||||
}
|
||||
|
||||
// Metric bar labels are set at text-[10px]: the value semibold (with tabular
|
||||
// digits while it animates), any detail after it at normal weight. Glyph
|
||||
// widths in that type vary too much for a per-character constant (a digit is
|
||||
// more than twice as wide as a full stop), so label widths are summed from
|
||||
// per-glyph advances of the font the browser actually renders. A hidden probe
|
||||
// carrying the label's own type classes measures them once.
|
||||
const LABEL_FONT_PX = 10;
|
||||
const LABEL_PROBE_FIRST_CODE = 0x20;
|
||||
const LABEL_PROBE_LAST_CODE = 0x7e;
|
||||
const LABEL_PROBE_REPEAT = 16;
|
||||
|
||||
interface LabelGlyphAdvances {
|
||||
// Semibold; a digit takes the wider of its proportional and tabular advance.
|
||||
value: readonly number[];
|
||||
// Normal weight.
|
||||
detail: readonly number[];
|
||||
}
|
||||
|
||||
// Advances in px for U+0020..U+007E as the probe reads them in Chrome with the
|
||||
// macOS system font (2026-10-03). They stand in wherever layout is
|
||||
// unavailable, such as unit tests.
|
||||
const FALLBACK_LABEL_GLYPH_ADVANCES: LabelGlyphAdvances = {
|
||||
value: [
|
||||
2.78, 3.54, 5.51, 6.7, 6.7, 10.11, 7.46, 3.43, 4.25, 4.25, 4.92, 6.7, 3.43, 4.92, 3.43, 3.37,
|
||||
6.79, 6.7, 6.7, 6.7, 6.85, 6.7, 6.8, 6.7, 6.88, 6.8, 3.43, 3.43, 6.7, 6.7, 6.7, 5.55, 9.36,
|
||||
7.25, 6.93, 7.45, 7.52, 6.25, 6.01, 7.68, 7.82, 3.13, 5.92, 7.05, 5.98, 9.08, 7.71, 7.93, 6.73,
|
||||
7.93, 6.93, 6.75, 6.64, 7.65, 7.18, 10.08, 7.25, 7.03, 6.83, 4.25, 3.37, 4.25, 6.7, 5.88, 5.12,
|
||||
5.89, 6.51, 5.9, 6.51, 6.03, 4.08, 6.46, 6.3, 2.86, 2.86, 5.94, 2.93, 9.24, 6.25, 6.22, 6.47,
|
||||
6.47, 4.35, 5.55, 4.11, 6.25, 5.82, 8.35, 5.74, 5.91, 5.69, 4.25, 2.98, 4.25, 6.7,
|
||||
],
|
||||
detail: [
|
||||
2.93, 3.23, 4.9, 6.42, 6.42, 9.38, 7.24, 3.09, 3.94, 3.94, 4.84, 6.42, 3.09, 4.84, 3.09, 3.17,
|
||||
6.42, 4.76, 6.16, 6.39, 6.56, 6.3, 6.49, 6, 6.51, 6.49, 3.09, 3.09, 6.42, 6.42, 6.42, 5.25, 9.3,
|
||||
6.86, 6.69, 7.28, 7.39, 6.08, 5.84, 7.59, 7.54, 2.8, 5.5, 6.71, 5.8, 8.86, 7.54, 7.84, 6.47,
|
||||
7.84, 6.66, 6.49, 6.46, 7.5, 6.86, 9.8, 6.91, 6.67, 6.74, 3.94, 3.17, 3.94, 6.42, 5.59, 5.12,
|
||||
5.64, 6.26, 5.72, 6.26, 5.84, 3.74, 6.22, 6.01, 2.59, 2.59, 5.55, 2.65, 8.82, 5.96, 6.03, 6.23,
|
||||
6.22, 4.02, 5.27, 3.75, 5.96, 5.54, 7.87, 5.37, 5.55, 5.51, 3.94, 2.71, 3.94, 6.42,
|
||||
],
|
||||
};
|
||||
|
||||
let labelGlyphAdvances: LabelGlyphAdvances | undefined;
|
||||
let labelGlyphAdvancesPixelRatio = 0;
|
||||
|
||||
function measureLabelGlyphAdvances(): LabelGlyphAdvances | null {
|
||||
if (typeof document === 'undefined' || !document.body) return null;
|
||||
|
||||
const probe = document.createElement('div');
|
||||
probe.setAttribute('aria-hidden', 'true');
|
||||
probe.className =
|
||||
'pointer-events-none invisible fixed left-0 top-0 whitespace-pre font-sans text-[10px] leading-none';
|
||||
const addGroup = (className: string, firstCode: number, lastCode: number) => {
|
||||
const group = document.createElement('div');
|
||||
group.className = className;
|
||||
for (let code = firstCode; code <= lastCode; code++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'w-max';
|
||||
cell.textContent = String.fromCharCode(code).repeat(LABEL_PROBE_REPEAT);
|
||||
group.appendChild(cell);
|
||||
}
|
||||
probe.appendChild(group);
|
||||
return group;
|
||||
};
|
||||
const semiboldGroup = addGroup('font-semibold', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE);
|
||||
const tabularGroup = addGroup('font-semibold tabular-nums', 0x30, 0x39);
|
||||
const normalGroup = addGroup('font-normal', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE);
|
||||
|
||||
document.body.appendChild(probe);
|
||||
const read = (group: HTMLElement) =>
|
||||
Array.from(group.children, (cell) => cell.getBoundingClientRect().width / LABEL_PROBE_REPEAT);
|
||||
const semibold = read(semiboldGroup);
|
||||
const tabular = read(tabularGroup);
|
||||
const normal = read(normalGroup);
|
||||
probe.remove();
|
||||
|
||||
// No layout (jsdom), or a stubbed one that reports every box alike.
|
||||
const at = (advances: number[], glyph: string) =>
|
||||
advances[glyph.charCodeAt(0) - LABEL_PROBE_FIRST_CODE];
|
||||
if (!(at(normal, '.') > 0 && at(normal, 'M') > at(normal, '.'))) return null;
|
||||
|
||||
const firstDigit = 0x30 - LABEL_PROBE_FIRST_CODE;
|
||||
tabular.forEach((advance, digit) => {
|
||||
semibold[firstDigit + digit] = Math.max(semibold[firstDigit + digit], advance);
|
||||
});
|
||||
return { value: semibold, detail: normal };
|
||||
}
|
||||
|
||||
function getLabelGlyphAdvances(): LabelGlyphAdvances {
|
||||
// Zoom changes how glyph advances round, so the table is per pixel ratio.
|
||||
const pixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1;
|
||||
if (!labelGlyphAdvances || pixelRatio !== labelGlyphAdvancesPixelRatio) {
|
||||
labelGlyphAdvances = measureLabelGlyphAdvances() ?? FALLBACK_LABEL_GLYPH_ADVANCES;
|
||||
labelGlyphAdvancesPixelRatio = pixelRatio;
|
||||
}
|
||||
return labelGlyphAdvances;
|
||||
}
|
||||
|
||||
function sumLabelGlyphAdvances(text: string, advances: readonly number[]): number {
|
||||
let width = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
// A glyph outside the measured range counts as a full em.
|
||||
width += advances[text.charCodeAt(index) - LABEL_PROBE_FIRST_CODE] ?? LABEL_FONT_PX;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
export interface EstimateTextWidthOptions {
|
||||
/** Text that follows at normal weight, such as ` (265 GB/440 GB)`. */
|
||||
detail?: string;
|
||||
/** Label font size in px when it is not the bar label's 10px. */
|
||||
fontPx?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate rendered text width based on character count.
|
||||
* Estimate the rendered width in px of a metric bar label: `text` semibold,
|
||||
* `options.detail` after it at normal weight. The result is the text alone,
|
||||
* rounded up; callers add the padding their label sits in.
|
||||
* Used for determining if labels fit inside metric bars.
|
||||
* ALL text-width estimation MUST use this function.
|
||||
*/
|
||||
export function estimateTextWidth(text: string): number {
|
||||
return text.length * 5.5 + 8;
|
||||
export function estimateTextWidth(text: string, options: EstimateTextWidthOptions = {}): number {
|
||||
const advances = getLabelGlyphAdvances();
|
||||
const width =
|
||||
sumLabelGlyphAdvances(text, advances.value) +
|
||||
sumLabelGlyphAdvances(options.detail ?? '', advances.detail);
|
||||
return Math.ceil((width * (options.fontPx ?? LABEL_FONT_PX)) / LABEL_FONT_PX);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -649,7 +649,7 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t
|
||||
finished = finished.Add(-18 * time.Minute)
|
||||
for i := range containers {
|
||||
profile := offlineProfiles[i%len(offlineProfiles)]
|
||||
containers[i].Name = profile.Name
|
||||
containers[i].Name = demoContainerProfileName(profile.Name, i/len(offlineProfiles))
|
||||
containers[i].Image = profile.Image
|
||||
containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags)
|
||||
containers[i].State = "exited"
|
||||
@@ -662,6 +662,16 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t
|
||||
return containers
|
||||
}
|
||||
|
||||
// demoContainerProfileName keeps container names unique per host. Docker
|
||||
// refuses two containers with one name on a host, so a profile reused for a
|
||||
// second round takes a numbered suffix instead of producing duplicate rows.
|
||||
func demoContainerProfileName(name string, round int) string {
|
||||
if round <= 0 {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", name, round+1)
|
||||
}
|
||||
|
||||
func applyDemoDockerContainerProfiles(
|
||||
containers []models.DockerContainer,
|
||||
profiles []demoDockerContainerProfile,
|
||||
@@ -672,7 +682,7 @@ func applyDemoDockerContainerProfiles(
|
||||
})
|
||||
for i := range containers {
|
||||
profile := profiles[i%len(profiles)]
|
||||
containers[i].Name = profile.Name
|
||||
containers[i].Name = demoContainerProfileName(profile.Name, i/len(profiles))
|
||||
containers[i].Image = profile.Image
|
||||
containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags)
|
||||
containers[i].State = "running"
|
||||
|
||||
@@ -987,3 +987,27 @@ func demoProxmoxNodeIsOffline(graph FixtureGraph, nodeName string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDemoDockerContainerNamesAreUniquePerHost(t *testing.T) {
|
||||
cfg := DefaultConfig
|
||||
cfg.DockerHostCount = 3
|
||||
cfg.DockerContainersPerHost = 8
|
||||
|
||||
graph := buildFixtureGraph(cfg, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
// Docker refuses two containers with one name on a host. The demo used to
|
||||
// reuse a curated profile for a second round and show two "customer-portal"
|
||||
// rows per host, which also doubled every image's "used by" list.
|
||||
for _, host := range graph.State.DockerHosts {
|
||||
seen := map[string]struct{}{}
|
||||
for _, container := range host.Containers {
|
||||
if _, dup := seen[container.Name]; dup {
|
||||
t.Fatalf("host %s names two containers %q", host.Hostname, container.Name)
|
||||
}
|
||||
seen[container.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
if !dockerContainerNameExists(graph, "customer-portal") {
|
||||
t.Fatal("expected the first round of curated names to stay unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
+50
-38
@@ -5197,50 +5197,62 @@ func generateStorage(nodes []models.Node) []models.Storage {
|
||||
}
|
||||
sort.Strings(clusterInstances)
|
||||
|
||||
// Every node reports the cluster's two shared PBS storage definitions. This
|
||||
// stays linear in fleet size: the former one-storage-per-target-node shape
|
||||
// grew quadratically and made a large demo look busy for the wrong reason.
|
||||
// Each cluster has two shared PBS storage definitions. Every node reports
|
||||
// them to the API, but the poller merges a shared storage into one
|
||||
// cluster-scoped entry (issue 1049), so the fixture carries that merged
|
||||
// shape: one row per store with the reporting nodes listed, never one row
|
||||
// per node with a different usage figure on each.
|
||||
for _, clusterInstance := range clusterInstances {
|
||||
clusterNodes := clusterNodesByInstance[clusterInstance]
|
||||
sort.Slice(clusterNodes, func(i, j int) bool { return clusterNodes[i].Name < clusterNodes[j].Name })
|
||||
nodeNames := make([]string, 0, len(clusterNodes))
|
||||
nodeIDs := make([]string, 0, len(clusterNodes))
|
||||
anyNodeOnline := false
|
||||
for _, node := range clusterNodes {
|
||||
for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} {
|
||||
pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024
|
||||
pbsID := fmt.Sprintf("%s-%s-%s", node.Instance, node.Name, storeName)
|
||||
pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now)
|
||||
pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0))
|
||||
isOffline := node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0
|
||||
status := "available"
|
||||
enabled := true
|
||||
active := true
|
||||
lastSeen := now
|
||||
if isOffline {
|
||||
status = "offline"
|
||||
enabled = false
|
||||
active = false
|
||||
pbsUsed = 0
|
||||
pbsUsage = 0
|
||||
lastSeen = now.Add(-10 * time.Minute)
|
||||
}
|
||||
storage = append(storage, models.Storage{
|
||||
ID: pbsID,
|
||||
Name: storeName,
|
||||
Node: node.Name,
|
||||
Instance: node.Instance,
|
||||
Type: "pbs",
|
||||
Status: status,
|
||||
Total: pbsTotal,
|
||||
Used: pbsUsed,
|
||||
Free: pbsTotal - pbsUsed,
|
||||
Usage: pbsUsage,
|
||||
Content: "backup",
|
||||
Shared: true,
|
||||
Enabled: enabled,
|
||||
Active: active,
|
||||
LastSeen: lastSeen,
|
||||
})
|
||||
nodeNames = append(nodeNames, node.Name)
|
||||
nodeIDs = append(nodeIDs, fmt.Sprintf("%s-%s", node.Instance, node.Name))
|
||||
if node.Status == "online" && node.ConnectionHealth != "offline" && node.Uptime > 0 {
|
||||
anyNodeOnline = true
|
||||
}
|
||||
}
|
||||
for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} {
|
||||
pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024
|
||||
pbsID := fmt.Sprintf("%s-cluster-%s", clusterInstance, storeName)
|
||||
pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now)
|
||||
pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0))
|
||||
status := "available"
|
||||
enabled := true
|
||||
active := true
|
||||
lastSeen := now
|
||||
if !anyNodeOnline {
|
||||
status = "offline"
|
||||
enabled = false
|
||||
active = false
|
||||
pbsUsed = 0
|
||||
pbsUsage = 0
|
||||
lastSeen = now.Add(-10 * time.Minute)
|
||||
}
|
||||
storage = append(storage, models.Storage{
|
||||
ID: pbsID,
|
||||
Name: storeName,
|
||||
Node: "cluster",
|
||||
Nodes: append([]string(nil), nodeNames...),
|
||||
NodeIDs: append([]string(nil), nodeIDs...),
|
||||
NodeCount: len(nodeNames),
|
||||
Instance: clusterInstance,
|
||||
Type: "pbs",
|
||||
Status: status,
|
||||
Total: pbsTotal,
|
||||
Used: pbsUsed,
|
||||
Free: pbsTotal - pbsUsed,
|
||||
Usage: pbsUsage,
|
||||
Content: "backup",
|
||||
Shared: true,
|
||||
Enabled: enabled,
|
||||
Active: active,
|
||||
LastSeen: lastSeen,
|
||||
})
|
||||
}
|
||||
|
||||
sharedTotal := int64(20 * 1024 * 1024 * 1024 * 1024)
|
||||
sharedID := fmt.Sprintf("%s-shared-storage", clusterInstance)
|
||||
|
||||
@@ -1291,3 +1291,34 @@ func TestRebaseMockReplicationJobsLeavesUnchangedJobsAlone(t *testing.T) {
|
||||
t.Fatalf("expected at most one job to roll over in one second, %d of %d stayed put", unchanged, len(jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFixtureStateMergesSharedPBSStorageLikeThePoller(t *testing.T) {
|
||||
cfg := DefaultConfig
|
||||
cfg.NodeCount = 5
|
||||
|
||||
data := buildFixtureState(cfg)
|
||||
|
||||
// Issue 1049: the poller folds a shared storage reported by every node
|
||||
// into one cluster-scoped row. The fixture must not fan it back out into
|
||||
// one row per node, each with its own usage figure.
|
||||
seen := map[string]models.Storage{}
|
||||
for _, storage := range data.Storage {
|
||||
if storage.Type != "pbs" {
|
||||
continue
|
||||
}
|
||||
key := storage.Instance + "/" + storage.Name
|
||||
if _, dup := seen[key]; dup {
|
||||
t.Fatalf("shared PBS storage %s appears more than once", key)
|
||||
}
|
||||
seen[key] = storage
|
||||
if storage.Node != "cluster" || storage.NodeCount < 2 || len(storage.Nodes) != storage.NodeCount {
|
||||
t.Fatalf("shared PBS storage %s is not cluster-scoped: node=%q nodes=%v count=%d", key, storage.Node, storage.Nodes, storage.NodeCount)
|
||||
}
|
||||
if !strings.HasSuffix(storage.ID, "-cluster-"+storage.Name) {
|
||||
t.Fatalf("shared PBS storage %s keeps a node-scoped id %q", key, storage.ID)
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
t.Fatal("expected shared PBS storage in the fixture")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1563,7 +1563,11 @@ func resourceFromPBSDatastore(instance models.PBSInstance, datastore models.PBSD
|
||||
name := strings.TrimSpace(datastore.Name)
|
||||
assessment := storagehealth.AssessPBSDatastore(datastore)
|
||||
risk := storageRiskFromAssessment(assessment)
|
||||
status := storageStatus(statusFromString(datastore.Status), risk)
|
||||
// The PBS poller reports datastores as "available"/"unavailable", the same
|
||||
// storage vocabulary PVE uses; the generic string mapper does not know it
|
||||
// and left every healthy datastore "unknown".
|
||||
datastoreStatus, _ := statusFromStorageState(datastore.Status)
|
||||
status := storageStatus(datastoreStatus, risk)
|
||||
incidents := incidentsFromAssessment("pulse", string(SourcePBS), "pbs-datastore:"+name, assessment, instance.LastSeen)
|
||||
status = incidentsStatus(status, incidents)
|
||||
|
||||
|
||||
@@ -1242,6 +1242,28 @@ func TestResourceFromPBSDatastoreDerivesRiskAndIncidents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceFromPBSDatastoreReadsStorageStatusVocabulary(t *testing.T) {
|
||||
instance := models.PBSInstance{ID: "pbs-1", Name: "pbs-main", LastSeen: time.Now().UTC()}
|
||||
// The poller stamps "available" when PBS reports no error and
|
||||
// "unavailable" when it does; neither is in the generic status mapper.
|
||||
cases := []struct {
|
||||
status string
|
||||
want ResourceStatus
|
||||
}{
|
||||
{status: "available", want: StatusOnline},
|
||||
{status: "unavailable", want: StatusOffline},
|
||||
{status: "degraded", want: StatusWarning},
|
||||
{status: "", want: StatusUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
datastore := models.PBSDatastore{Name: "backup-store", Status: tc.status, Total: 100, Used: 10}
|
||||
resource, _ := resourceFromPBSDatastore(instance, datastore)
|
||||
if resource.Status != tc.want {
|
||||
t.Fatalf("status %q: Status = %q, want %q", tc.status, resource.Status, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceFromPBSInstanceRollsUpDatastoreRisk(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
instance := models.PBSInstance{
|
||||
|
||||
@@ -843,3 +843,26 @@ func TestPBSHostHistoryThreeServerTopology(t *testing.T) {
|
||||
t.Fatalf("three-server topology: services=%d linked hosts=%d linked disks=%d", services, linkedHosts, linkedDisks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFromStorageStateCoversPBSAndPVEVocabulary(t *testing.T) {
|
||||
// PVE storage and PBS datastores both report "available"/"unavailable";
|
||||
// the mapping has to be shared so neither surface falls back to unknown.
|
||||
cases := []struct {
|
||||
state string
|
||||
want ResourceStatus
|
||||
known bool
|
||||
}{
|
||||
{state: "available", want: StatusOnline, known: true},
|
||||
{state: "ACTIVE", want: StatusOnline, known: true},
|
||||
{state: "degraded", want: StatusWarning, known: true},
|
||||
{state: "unavailable", want: StatusOffline, known: true},
|
||||
{state: "", want: StatusUnknown, known: false},
|
||||
{state: "mystery", want: StatusUnknown, known: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, known := statusFromStorageState(tc.state)
|
||||
if got != tc.want || known != tc.known {
|
||||
t.Fatalf("statusFromStorageState(%q) = (%q, %v), want (%q, %v)", tc.state, got, known, tc.want, tc.known)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +32,25 @@ func statusFromGuest(status string) ResourceStatus {
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromStorage(storage models.Storage) ResourceStatus {
|
||||
switch strings.ToLower(strings.TrimSpace(storage.Status)) {
|
||||
// statusFromStorageState maps the storage-native status vocabulary, which
|
||||
// includes the "available"/"unavailable" pair PVE storage and PBS datastores
|
||||
// report, onto a resource status. The second result is false when the string
|
||||
// is not a storage state the caller can act on.
|
||||
func statusFromStorageState(status string) (ResourceStatus, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "online", "running", "available", "active", "ok":
|
||||
return StatusOnline
|
||||
return StatusOnline, true
|
||||
case "warning", "degraded":
|
||||
return StatusWarning
|
||||
return StatusWarning, true
|
||||
case "offline", "down", "unavailable", "error":
|
||||
return StatusOffline
|
||||
return StatusOffline, true
|
||||
}
|
||||
return StatusUnknown, false
|
||||
}
|
||||
|
||||
func statusFromStorage(storage models.Storage) ResourceStatus {
|
||||
if status, ok := statusFromStorageState(storage.Status); ok {
|
||||
return status
|
||||
}
|
||||
if !storage.Active {
|
||||
return StatusOffline
|
||||
|
||||
Reference in New Issue
Block a user