From 0ff8132acb0fa15286b4a801cdbce9cbd7167fb5 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:40:53 +0100 Subject: [PATCH 1/7] Carry Kubernetes job times into the controller row expansion The Workload Controllers table hides its Detail column below a 76rem container, and that column was the only place a Job's completion time and a CronJob's last success or last run were shown. The row expansion rendered only the generic Observed state, Last seen, Identity and Aliases, so a phone or a half-width pane had no way to read when a job finished. At 390px the 15 percent Kind track also clipped "DaemonSet", "StatefulSet" and "CronJob", and the Target track clipped "1 completions". The drawer's Kubernetes model now builds a per-kind controller section (Job, CronJob, DaemonSet, StatefulSet, ReplicaSet) that the always-visible summary tables render in the same slot as the Docker container section, so the facts are one tap away in every width and on the Infrastructure drawer too. Job rows lead with Started, Completed and Duration; CronJob rows with Schedule, Last run and Last success; each timestamp is the absolute time with its age in parentheses and wraps in a phone-width cell. Target, the counts, the StatefulSet service name, Namespace and Cluster follow, which also shows the service name the Detail column truncates at 1440px. The phone projection keeps Controller, Kind, Ready and Issues and demotes Target with platform-table-phone-hidden, widening Kind to 25 percent so every kind label fits whole. The Target count now pluralizes through getPlatformResourceCountNoun, so a single completion reads "1 completion" in the row as it does in the expansion. Verified with a Playwright clip sweep at 1440, 1280, 900 and 390px against a private mock-mode stack; the receipt records the measurements. Contract-Neutral: Kubernetes controller facts move into the existing drawer summary slot and the phone row uses the existing platform-table-phone-hidden marker; owning unified-resources clause updated; no frontend-primitives clause, primitive, or API change --- .../internal/subsystems/unified-resources.md | 13 ++ frontend-modern/browser-verification.json | 88 ++------ .../Infrastructure/ResourceDetailSummary.tsx | 2 + ...esourceDetailDrawerKubernetesModel.test.ts | 204 +++++++++++++++++- .../resourceDetailDrawerKubernetesModel.ts | 149 +++++++++++++ .../kubernetes/KubernetesControllersTable.tsx | 25 ++- .../KubernetesControllersTable.test.tsx | 126 ++++++++++- 7 files changed, 529 insertions(+), 78 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ae311147f4..2ebaa48331 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -797,6 +797,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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index db181fda93..2ff0c99157 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,56 +1,20 @@ { "version": 1, - "base_sha": "0e90236d3bb901dc84d4d07e70d4d2a09d5e6705", - "verified_at": "2026-10-02T23:55:48Z", + "base_sha": "74fd189fd03098b7ed94d806b15f570722048670", + "verified_at": "2026-10-03T09:40:30Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/Table.tsx" + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx" ], "content_sha256": { - "frontend-modern/src/components/shared/Table.tsx": "8e5029d5ee9dddc86a4733fa1e7249793cdbedb135f3634e75112f8fb3b327c9" + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f6f1b090580e4bea0d6496b908f72049c886c4faeee7aca3a3b11e9417c67df7" }, "routes": [ - "/proxmox/overview", - "/proxmox/storage", - "/proxmox/replication", - "/proxmox/ceph", - "/proxmox/mail", - "/docker/overview", - "/docker/images", - "/docker/storage", - "/docker/networks", - "/docker/swarm", - "/kubernetes/overview", - "/kubernetes/nodes", - "/kubernetes/workloads", - "/kubernetes/services", - "/kubernetes/storage", - "/kubernetes/configuration", - "/kubernetes/events", - "/truenas/overview", - "/truenas/storage", - "/truenas/services", - "/truenas/apps", - "/truenas/vms", - "/truenas/shares", - "/truenas/protection", - "/vmware/overview", - "/vmware/storage", - "/vmware/networks", - "/vmware/health", - "/vmware/activity", - "/standalone/machines", - "/standalone/availability", - "/alerts/overview", - "/alerts/history", - "/alerts/thresholds", - "/actions", - "/settings/infrastructure", - "/settings/monitoring", - "/settings/pulse-intelligence", - "/settings/organization", - "/settings/security-users", - "/settings/security-webhooks" + "/kubernetes" ], "viewports": [ { @@ -65,36 +29,22 @@ "width": 900, "height": 900 }, - { - "width": 700, - "height": 900 - }, - { - "width": 560, - "height": 900 - }, { "width": 390, "height": 900 } ], "states": [ - "Mock stack on origin/main plus this change; Playwright sweep of every table on the routes above at all six viewports, before and after, recording computed th/td padding per cell and leaf elements with scrollWidth > clientWidth under hidden overflow (455 table states compared)", - "Default TableCell/TableHead with no caller padding keep 8px/12px horizontal and 2px/6px vertical padding (Settings infrastructure source and agent tables unchanged at 12px/12px/6px/6px)", - "Platform tables (Proxmox nodes, Docker hosts and containers, Kubernetes clusters/nodes/deployments/pods, TrueNAS systems, vSphere, Machines): cells 12px -> 8px horizontal at 700px and up and 8px -> 6px at 560/390px; header cells keep the stylesheet 6px horizontal and drop to 2px vertical, header rows 29px -> 21px; row height 32px unchanged; clipped-cell count improved in 89 table states, unchanged in 362; the 4 states counted worse are live mock-value drift (relative-time and usage labels) with identical or wider content boxes", - "Physical Disks table (Proxmox > Storage > Physical Disks): cells 8px -> 4px horizontal at 390px and 12px -> 6px at 700px, matching the composed px-1 sm:px-1.5 lg:px-2; clipped cells at 390px 93 -> 18; detail row 12px -> 16px horizontal at 700px and 1440px", - "Storage pools table unchanged at 6px/8px cell padding across 390, 560 and 700px", - "Inline detail rows (Proxmox node, Kubernetes controller, TrueNAS system expansions): detail cell padding 12px/12px/2px/2px -> 0, detail panel inset now only its own px-2 sm:px-4; incident timeline panel in Alert History gets its p-3", - "Alerts overview stats table: dot column right padding 12px -> 0 and value column 12px -> 6px at 700px and up as its classes name; Alert History header cells 12px -> 8px horizontal; AI cost table (Settings > Pulse Intelligence) Est. USD column 12px -> 4px as its px-1 names", - "Workloads table header cells now 6px/8px to match their raw td data cells; Proxmox replication compact-mode !px-1 override still applies at 390px", - "Reactive case: switching a cell class between px-1 and a non-padding class re-resolves the base padding (unit test)" + "Mock estate (1 Kubernetes cluster, 2 nodes, 6 pods, 2 deployments) with one CronJob carrying lastScheduleTime and lastSuccessfulTime, one completed Job with startTime and completionTime, one DaemonSet and one StatefulSet in the Workload Controllers table, Chromium; re-run on 2026-10-03 against this tree after main gained the shared Table cell-padding change (#2427)", + "Workload Controllers table at the full desktop layout where Scope and Detail show (1440px), at container widths where they are hidden (1280 and 900px), and at the phone projection (390px) where the row keeps Controller, Kind, Ready and Issues and the Target column is demoted", + "Each CronJob, Job, DaemonSet and StatefulSet row expanded to its inline detail at all four widths; the summary tables show Runtime context, the new per-kind controller section and Identity" ], "interactions": [ - "Load each route at 1440, 1280, 900, 700, 560 and 390px and run the padding and clip sweep over all tables", - "Click Physical Disks on Proxmox > Storage at 390, 560, 700 and 1440px and expand the first disk row", - "Expand the first node row on Proxmox > Overview, the first deployment row on Kubernetes > Workloads, the first host row on Docker > Overview and the first system row on TrueNAS > Overview at 390, 560, 700 and 1440px", - "Expand the first alert row incident timeline on Alerts > History at 1440px", - "Compare before and after JSON sweeps per route, tab, viewport and table; re-measure the four states counted worse three times each", - "Revert and restore the change on the running dev server to A/B the Kubernetes clusters memory bar label at 560px" - ] + "Measured every controllers table header and cell for clipped text (scrollWidth over clientWidth) at each viewport: at 390px the Kind track is 90px and \"DaemonSet\", \"StatefulSet\" and \"CronJob\" fit whole where the 54px track had clipped them, no Target cell remains to clip \"1 completions\", and only the four long controller names truncate with an ellipsis; no viewport scrolls horizontally", + "Expanded each controller row by tapping it and read the new section: the CronJob shows Schedule \"0 2 * * *\", Last run and Last success as \"Oct 2, 2026, 10:25 PM (7m ago)\" style absolute times, Active, Namespace and Cluster; the Job shows Started, Completed, Duration 5m, Target \"1 completion\", Succeeded, Namespace and Cluster; the DaemonSet and StatefulSet show Target, Current, Ready, Available, Updated and the StatefulSet Service name whole", + "Measured the expansion for clipped text at each width: the wrapped timestamp values no longer clip at 390px (an earlier single-line \"absolute \u00b7 age\" value needed 169px in a 142px cell), and the only remaining clipped values are the pre-existing Identity IDs and alias badges", + "Read the Job row Target at 1440px as \"1 completion\" after the pluralization fix, matching the expansion", + "Compared before and after screenshots at 1440 and 390px: the desktop drawer gains a Job or CronJob card between Runtime context and Identity, and the phone row reads Controller, Kind, Ready, Issues with every kind label whole" + ], + "command": "playwright chromium against a private mock-mode stack built from this commit (backend :7697, vite :5217), sweep script in the session scratchpad" } diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 8291e514a0..2f03d28dcb 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -13,6 +13,7 @@ import { TagBadges } from '@/components/shared/TagBadges'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference'; import { formatInteger } from './resourceDetailMappers'; +import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel'; import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState'; interface ResourceSummaryPresentationProps { @@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component { ).toBe('https://prod.k8s.local:6443'); }); }); + +describe('buildKubernetesControllerSection', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const absolute = (timestamp: string): string => + formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } }); + + it('returns nothing for resources that are not workload controllers', () => { + expect( + buildKubernetesControllerSection( + makeResource({ id: 'pod-1', type: 'pod', kubernetes: { namespace: 'apps' } }), + ), + ).toBeNull(); + expect(buildKubernetesControllerSection(makeResource({ id: 'vm-1', type: 'vm' }))).toBeNull(); + expect( + buildKubernetesControllerSection(makeResource({ id: 'job-1', type: 'k8s-job' })), + ).toBeNull(); + }); + + it('leads a Job with its absolute start and completion times, then the counts', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + const section = buildKubernetesControllerSection( + makeResource({ + id: 'nightly-import', + type: 'k8s-job', + kubernetes: { + clusterName: 'prod', + namespace: 'batch', + desiredReplicas: 10, + active: 1, + succeeded: 8, + failed: 2, + startTime: '2026-05-24T12:55:00Z', + completionTime: '2026-05-24T13:00:00Z', + }, + }), + ); + + expect(section?.label).toBe('Job'); + expect(section?.testId).toBe('resource-kubernetes-controller-section'); + expect(section?.rows).toEqual([ + { label: 'Started', value: `${absolute('2026-05-24T12:55:00Z')} (36m ago)`, wrap: true }, + { label: 'Completed', value: `${absolute('2026-05-24T13:00:00Z')} (31m ago)`, wrap: true }, + { label: 'Duration', value: '5m' }, + { label: 'Target', value: '10 completions' }, + { label: 'Active', value: '1' }, + { label: 'Succeeded', value: '8' }, + { label: 'Failed', value: '2', tone: 'danger' }, + { label: 'Namespace', value: 'batch' }, + { label: 'Cluster', value: 'prod' }, + ]); + }); + + it('keeps a running Job on its start time without a completion or duration row', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + const section = buildKubernetesControllerSection( + makeResource({ + id: 'nightly-import', + type: 'k8s-job', + kubernetes: { + namespace: 'batch', + desiredReplicas: 1, + active: 1, + failed: 0, + startTime: '2026-05-24T13:29:30Z', + }, + }), + ); + + expect(section?.rows.map((row) => row.label)).toEqual([ + 'Started', + 'Target', + 'Active', + 'Failed', + 'Namespace', + ]); + expect(section?.rows[0]).toEqual({ + label: 'Started', + value: `${absolute('2026-05-24T13:29:30Z')} (1m ago)`, + wrap: true, + }); + expect(section?.rows[3]).toEqual({ label: 'Failed', value: '0', tone: 'default' }); + }); + + it('leads a CronJob with its schedule and the last run and last success times', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + const section = buildKubernetesControllerSection( + makeResource({ + id: 'billing-rollup', + type: 'k8s-cronjob', + kubernetes: { + clusterName: 'prod', + namespace: 'batch', + schedule: '0 2 * * *', + suspend: true, + active: 0, + lastScheduleTime: '2026-05-24T02:00:00Z', + lastSuccessfulTime: '2026-05-23T02:00:00Z', + }, + }), + ); + + expect(section?.label).toBe('CronJob'); + expect(section?.rows).toEqual([ + { label: 'Schedule', value: '0 2 * * *', valueClass: 'font-mono' }, + { label: 'Last run', value: `${absolute('2026-05-24T02:00:00Z')} (11h ago)`, wrap: true }, + { label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true }, + { label: 'Suspended', value: 'Yes', tone: 'warning' }, + { label: 'Active', value: '0' }, + { label: 'Namespace', value: 'batch' }, + { label: 'Cluster', value: 'prod' }, + ]); + }); + + it('carries the service name and counts for StatefulSets and DaemonSets', () => { + const statefulSet = buildKubernetesControllerSection( + makeResource({ + id: 'checkout-api-stateful', + type: 'k8s-statefulset', + kubernetes: { + namespace: 'apps', + desiredReplicas: 3, + currentReplicas: 3, + readyReplicas: 2, + availableReplicas: 2, + updatedReplicas: 2, + serviceName: 'checkout-headless', + }, + }), + ); + expect(statefulSet?.label).toBe('StatefulSet'); + expect(statefulSet?.rows).toEqual([ + { label: 'Service', value: 'checkout-headless' }, + { label: 'Target', value: '3 pods' }, + { label: 'Current', value: '3' }, + { label: 'Ready', value: '2' }, + { label: 'Available', value: '2' }, + { label: 'Updated', value: '2' }, + { label: 'Namespace', value: 'apps' }, + ]); + + const daemonSet = buildKubernetesControllerSection( + makeResource({ + id: 'node-exporter', + type: 'k8s-daemonset', + kubernetes: { + namespace: 'observability', + desiredNumberScheduled: 6, + currentNumberScheduled: 6, + numberReady: 5, + numberAvailable: 5, + numberUnavailable: 1, + numberMisscheduled: 0, + updatedReplicas: 5, + }, + }), + ); + expect(daemonSet?.label).toBe('DaemonSet'); + expect(daemonSet?.rows).toEqual([ + { label: 'Target', value: '6 nodes' }, + { label: 'Current', value: '6' }, + { label: 'Ready', value: '5' }, + { label: 'Available', value: '5' }, + { label: 'Updated', value: '5' }, + { label: 'Unavailable', value: '1', tone: 'warning' }, + { label: 'Misscheduled', value: '0', tone: 'default' }, + { label: 'Namespace', value: 'observability' }, + ]); + }); + + it('shows the ReplicaSet labelling and generation fields the Detail column carries', () => { + const section = buildKubernetesControllerSection( + makeResource({ + id: 'checkout-api-replicaset', + type: 'k8s-replicaset', + kubernetes: { + desiredReplicas: 4, + readyReplicas: 3, + fullyLabeledReplicas: 4, + observedGeneration: 7, + }, + }), + ); + expect(section?.label).toBe('ReplicaSet'); + expect(section?.rows).toEqual([ + { label: 'Target', value: '4 pods' }, + { label: 'Ready', value: '3' }, + { label: 'Fully labeled', value: '4' }, + { label: 'Observed generation', value: '7' }, + ]); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts index 5d3d5e9275..2d44225f6b 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts @@ -3,9 +3,18 @@ import { compactDetailRows as compactRows, compactDetailSections as compactSections, formatDetailBytesValue, + formatDetailCountValue, + formatDetailIntegerValue, makeDetailRow as makeRow, + type DetailRow, type DetailSection, + type DetailValueTone, } from '@/components/shared/detailSectionModel'; +import { + formatPlatformTableDateTimeValue, + formatPlatformTableDurationValue, + formatPlatformTableRelativeTimeValue, +} from '@/features/platformPage/sharedPlatformPage'; export type ResourceDetailDrawerKubernetesSection = DetailSection; @@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = ( return compactSections(sections); }; +const countRow = ( + label: string, + value: number | undefined, + options: { noun?: string; tone?: (count: number) => DetailValueTone } = {}, +): DetailRow | null => { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const text = options.noun + ? formatDetailCountValue(value, options.noun) + : formatDetailIntegerValue(value); + return makeRow(label, text, options.tone ? { tone: options.tone(value) } : {}); +}; + +const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default'); +const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default'); + +// Kubernetes reports controller timestamps as RFC 3339 strings. The table row +// only has room for an age, so the expansion carries the absolute time first +// and the age after it: "Oct 2, 2026, 09:13 PM (1h ago)". The value wraps +// because a phone-width detail cell is too narrow for both on one line. +const timestampRow = (label: string, timestamp?: string | null): DetailRow | null => { + const raw = asString(timestamp); + if (!raw) return null; + const absolute = formatPlatformTableDateTimeValue(raw, { + emptyText: '', + dateTimeFormat: { year: 'numeric' }, + }); + if (!absolute) return makeRow(label, raw, { wrap: true }); + const relative = formatPlatformTableRelativeTimeValue(raw, { emptyText: '' }); + return makeRow(label, relative ? `${absolute} (${relative})` : absolute, { wrap: true }); +}; + +const elapsedSeconds = (start?: string | null, end?: string | null): number | undefined => { + const startedAt = Date.parse(asString(start) ?? ''); + const endedAt = Date.parse(asString(end) ?? ''); + if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) { + return undefined; + } + return (endedAt - startedAt) / 1000; +}; + +type ControllerRows = { label: string; rows: Array }; + +// Each kind leads with what its narrow table row drops: the Detail column +// (service name, timestamps) below the large layout and Target on a phone. +const controllerRows = (resource: Resource): ControllerRows | null => { + const k = resource.kubernetes; + if (!k) return null; + switch (resource.type) { + case 'k8s-replicaset': + return { + label: 'ReplicaSet', + rows: [ + countRow('Target', k.desiredReplicas, { noun: 'pod' }), + countRow('Current', k.currentReplicas), + countRow('Ready', k.readyReplicas), + countRow('Available', k.availableReplicas), + countRow('Fully labeled', k.fullyLabeledReplicas), + countRow('Observed generation', k.observedGeneration), + ], + }; + case 'k8s-statefulset': + return { + label: 'StatefulSet', + rows: [ + makeRow('Service', k.serviceName), + countRow('Target', k.desiredReplicas, { noun: 'pod' }), + countRow('Current', k.currentReplicas), + countRow('Ready', k.readyReplicas), + countRow('Available', k.availableReplicas), + countRow('Updated', k.updatedReplicas), + ], + }; + case 'k8s-daemonset': + return { + label: 'DaemonSet', + rows: [ + countRow('Target', k.desiredNumberScheduled, { noun: 'node' }), + countRow('Current', k.currentNumberScheduled), + countRow('Ready', k.numberReady), + countRow('Available', k.numberAvailable), + countRow('Updated', k.updatedReplicas), + countRow('Unavailable', k.numberUnavailable, { tone: warnWhenPositive }), + countRow('Misscheduled', k.numberMisscheduled, { tone: warnWhenPositive }), + ], + }; + case 'k8s-job': + return { + label: 'Job', + rows: [ + timestampRow('Started', k.startTime), + timestampRow('Completed', k.completionTime), + makeRow( + 'Duration', + formatPlatformTableDurationValue(elapsedSeconds(k.startTime, k.completionTime), { + emptyText: '', + }), + ), + countRow('Target', k.desiredReplicas, { noun: 'completion' }), + countRow('Active', k.active), + countRow('Succeeded', k.succeeded), + countRow('Failed', k.failed, { tone: dangerWhenPositive }), + ], + }; + case 'k8s-cronjob': + return { + label: 'CronJob', + rows: [ + makeRow('Schedule', k.schedule, { valueClass: 'font-mono' }), + timestampRow('Last run', k.lastScheduleTime), + timestampRow('Last success', k.lastSuccessfulTime), + makeRow('Suspended', k.suspend === true ? 'Yes' : null, { tone: 'warning' }), + countRow('Active', k.active), + ], + }; + default: + return null; + } +}; + +// Workload-controller facts for the always-visible summary of the resource +// drawer, the same slot Docker containers use for their timestamps. The +// controllers table hides Scope and Detail below its large layout and Target +// on a phone, so without this a Job's completion time or a CronJob's last +// success is unreachable from a narrow row. +export const buildKubernetesControllerSection = (resource: Resource): DetailSection | null => { + const controller = controllerRows(resource); + if (!controller) return null; + const rows = compactRows([ + ...controller.rows, + makeRow('Namespace', resource.kubernetes?.namespace), + makeRow('Cluster', resource.kubernetes?.clusterName), + ]); + if (rows.length === 0) return null; + return { + label: controller.label, + rows, + testId: 'resource-kubernetes-controller-section', + }; +}; + export const buildKubernetesDetailsSummary = (resource: Resource): string | null => { const k = resource.kubernetes; if (!k) return null; diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx index 8d7c18e9d3..3b64504700 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx @@ -16,6 +16,7 @@ import { formatPlatformTableDateTimeValue, formatPlatformTableRelativeTimeValue, formatPlatformTableTextValue, + getPlatformResourceCountNoun, getPlatformTableCellClassForKind, PlatformTableShell, type PlatformTableSortValue, @@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string => const controllerKind = (resource: Resource): string => resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type; +const countedTarget = (count: number | undefined, noun: string): string => { + const value = count ?? 0; + return `${value} ${getPlatformResourceCountNoun(noun, value)}`; +}; + const targetValue = (resource: Resource): string => { switch (resource.type) { case 'k8s-replicaset': case 'k8s-statefulset': - return `${resource.kubernetes?.desiredReplicas ?? 0} pods`; + return countedTarget(resource.kubernetes?.desiredReplicas, 'pods'); case 'k8s-daemonset': - return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`; + return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes'); case 'k8s-job': - return `${resource.kubernetes?.desiredReplicas ?? 0} completions`; + return countedTarget(resource.kubernetes?.desiredReplicas, 'completions'); case 'k8s-cronjob': return formatPlatformTableTextValue(resource.kubernetes?.schedule); default: @@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{ kind="text" sort={sort} sortKey="kind" - class="platform-table-mobile-w-15 md:w-[8%]" + class="platform-table-mobile-w-25 md:w-[8%]" > Kind @@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{ > Scope + {/* The phone row keeps controller, kind, ready and issues. A + 15% kind track clipped "DaemonSet" and "StatefulSet" and the + target track clipped "1 completions", so target moves to the + row expansion, which also carries the schedule and job + timestamps the narrower layouts drop. */} Target @@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{ kind="text" sort={sort} sortKey="exceptions" - class="platform-table-mobile-w-20 md:w-[11%]" + class="platform-table-mobile-w-30 md:w-[11%]" > @@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{ {target()} diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx index cf201f66f5..1823b37427 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx @@ -1,6 +1,16 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('@/api/resources', () => ({ + ResourceAPI: { + getFacetBundle: vi.fn().mockResolvedValue({ + capabilities: [], + relationships: [], + recentChanges: [], + }), + }, +})); + import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; import { KubernetesControllersTable } from '../KubernetesControllersTable'; @@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => { expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument(); }); + it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => { + render(() => ( + } + emptyTitle="No controllers" + emptyDescription="No controllers" + showToolbar={false} + /> + )); + + // "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and + // "1 completions" clipped in the target track, so the phone row widens kind + // and issues and demotes target to the row expansion. + expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25'); + expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden'); + expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden'); + expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30'); + expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30'); + expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15'); + expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden'); + }); + + it('shows the absolute Job and CronJob times in the row expansion', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + render(() => ( + } + emptyTitle="No controllers" + emptyDescription="No controllers" + showToolbar={false} + /> + )); + + // Tapping the row opens the expansion; the Detail column is hidden below a + // large container, so this is where a phone or half-width pane reads the + // completion time. + fireEvent.click(screen.getByText('nightly-import')); + const job = screen.getByTestId('resource-kubernetes-controller-section'); + expect(within(job).getByText('Started')).toBeInTheDocument(); + expect( + within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`), + ).toBeInTheDocument(); + expect(within(job).getByText('Completed')).toBeInTheDocument(); + expect( + within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`), + ).toBeInTheDocument(); + expect(within(job).getByText('Duration')).toBeInTheDocument(); + expect(within(job).getByText('5m')).toBeInTheDocument(); + expect(within(job).getByText('Target')).toBeInTheDocument(); + expect(within(job).getByText('10 completions')).toBeInTheDocument(); + expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2'); + + fireEvent.click(screen.getByText('billing-rollup')); + const cron = screen.getByTestId('resource-kubernetes-controller-section'); + expect(within(cron).getByText('Schedule')).toBeInTheDocument(); + expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument(); + expect(within(cron).getByText('Last run')).toBeInTheDocument(); + expect( + within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`), + ).toBeInTheDocument(); + expect(within(cron).getByText('Last success')).toBeInTheDocument(); + expect( + within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`), + ).toBeInTheDocument(); + expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch'); + }); + it('falls back to start and last-run times when a Job or CronJob has not completed', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); @@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => { /> )); + // A single desired completion reads as "1 completion", matching the + // expansion's Target row. + expect(screen.getByText('1 completion')).toBeInTheDocument(); expect(screen.getByText('Started 1m ago')).toHaveAttribute( 'title', `Started: ${absoluteTime('2026-05-24T13:29:30Z')}`, From 5db751985c71bdc172936cb8aed6bbf560d326a0 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:28:27 +0100 Subject: [PATCH 2/7] Fit metric bar labels by measured glyph widths, not character count The metric, stacked memory and stacked disk bars choose between "60%" and "60% (265 GB/440 GB)" by estimating the label's width. The estimate was 5.5px per character plus 8, which is off by -7px to +5px on real labels: at the label's 10px type a digit is 6.7px wide and a full stop 3px. In the Kubernetes clusters table at a 560px viewport the memory bar is 113px wide, the estimate came to 112.5px, the label needs 118px, and it rendered with an ellipsis. A sweep of the platform tables found 24 such bars on main, all stacked memory bars, at 560, 900 and 1440px. estimateTextWidth now sums per-glyph advances. It reads them once per page from a hidden probe that carries the label's type classes, so the estimate follows whichever system font the browser renders; the advances measured for the macOS system font stand in where there is no layout. The value is measured semibold and the detail at normal weight, and each model adds the padding its own label sits in instead of sharing one constant. Two more gaps in the same decision are closed. The stacked bars now count the anomaly marker that shares the label's line, and the inline per-disk slots are sized after the gap between them: a two-disk Proxmox node at 900px clipped "backup 80%" by 2px. The probe is the estimator's only layout read, 1.7ms on the 429-bar Proxmox overview, and never per bar. Bar widths still come from the resize observer alone. --- .../subsystems/performance-and-scalability.md | 16 +++ frontend-modern/browser-verification.json | 70 +++++----- .../metricBarModel.branchcov0720pm.test.ts | 46 +++++-- ...stackedDiskBarModel.branchcov0712b.test.ts | 73 ++++++++++- ...ckedMemoryBarModel.branchcov0724pm.test.ts | 40 ++++++ .../__tests__/useMetricBarState.test.tsx | 34 +++++ .../useStackedMemoryBarState.test.tsx | 30 +++++ .../components/Workloads/metricBarModel.ts | 6 +- .../Workloads/stackedDiskBarModel.ts | 28 +++- .../Workloads/stackedMemoryBarModel.ts | 12 +- .../utils/__tests__/format.coverage.test.ts | 20 +-- .../src/utils/__tests__/formatExtra.test.ts | 77 ++++++++++- frontend-modern/src/utils/format.ts | 124 +++++++++++++++++- 13 files changed, 495 insertions(+), 81 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 8df6300caa..2523038a27 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2536,6 +2536,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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index db181fda93..e1d137895f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,25 @@ { "version": 1, - "base_sha": "0e90236d3bb901dc84d4d07e70d4d2a09d5e6705", - "verified_at": "2026-10-02T23:55:48Z", + "base_sha": "74fd189fd03098b7ed94d806b15f570722048670", + "verified_at": "2026-10-03T10:27:38Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/Table.tsx" + "frontend-modern/src/components/Workloads/metricBarModel.ts", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", + "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts", + "frontend-modern/src/utils/format.ts" ], "content_sha256": { - "frontend-modern/src/components/shared/Table.tsx": "8e5029d5ee9dddc86a4733fa1e7249793cdbedb135f3634e75112f8fb3b327c9" + "frontend-modern/src/components/Workloads/metricBarModel.ts": "1c312bae85ff75b042fb502586662eebc91bc8b5d54054890e2f3bba155d5505", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "c7719b5646d60cb4683b10d9dd0567729d346dd95e003cb8bb62be33b3d39ac1", + "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts": "7b2395cc5082ca1fdfb1a47aa0cc6a0f130e87ff57df3827f9f46a29cd704d7a", + "frontend-modern/src/utils/format.ts": "eed4e6a02476120a262b412120c158ac93583e2d6cec9dd39915253777605261" }, "routes": [ "/proxmox/overview", "/proxmox/storage", "/proxmox/replication", + "/proxmox/backups/date", "/proxmox/ceph", "/proxmox/mail", "/docker/overview", @@ -40,61 +47,46 @@ "/vmware/health", "/vmware/activity", "/standalone/machines", - "/standalone/availability", - "/alerts/overview", - "/alerts/history", - "/alerts/thresholds", - "/actions", - "/settings/infrastructure", - "/settings/monitoring", - "/settings/pulse-intelligence", - "/settings/organization", - "/settings/security-users", - "/settings/security-webhooks" + "/standalone/availability" ], "viewports": [ { "width": 1440, - "height": 900 - }, - { - "width": 1280, - "height": 900 + "height": 1400 }, { "width": 900, - "height": 900 + "height": 1400 }, { "width": 700, - "height": 900 + "height": 1400 }, { "width": 560, - "height": 900 + "height": 1400 }, { "width": 390, - "height": 900 + "height": 1400 } ], "states": [ - "Mock stack on origin/main plus this change; Playwright sweep of every table on the routes above at all six viewports, before and after, recording computed th/td padding per cell and leaf elements with scrollWidth > clientWidth under hidden overflow (455 table states compared)", - "Default TableCell/TableHead with no caller padding keep 8px/12px horizontal and 2px/6px vertical padding (Settings infrastructure source and agent tables unchanged at 12px/12px/6px/6px)", - "Platform tables (Proxmox nodes, Docker hosts and containers, Kubernetes clusters/nodes/deployments/pods, TrueNAS systems, vSphere, Machines): cells 12px -> 8px horizontal at 700px and up and 8px -> 6px at 560/390px; header cells keep the stylesheet 6px horizontal and drop to 2px vertical, header rows 29px -> 21px; row height 32px unchanged; clipped-cell count improved in 89 table states, unchanged in 362; the 4 states counted worse are live mock-value drift (relative-time and usage labels) with identical or wider content boxes", - "Physical Disks table (Proxmox > Storage > Physical Disks): cells 8px -> 4px horizontal at 390px and 12px -> 6px at 700px, matching the composed px-1 sm:px-1.5 lg:px-2; clipped cells at 390px 93 -> 18; detail row 12px -> 16px horizontal at 700px and 1440px", - "Storage pools table unchanged at 6px/8px cell padding across 390, 560 and 700px", - "Inline detail rows (Proxmox node, Kubernetes controller, TrueNAS system expansions): detail cell padding 12px/12px/2px/2px -> 0, detail panel inset now only its own px-2 sm:px-4; incident timeline panel in Alert History gets its p-3", - "Alerts overview stats table: dot column right padding 12px -> 0 and value column 12px -> 6px at 700px and up as its classes name; Alert History header cells 12px -> 8px horizontal; AI cost table (Settings > Pulse Intelligence) Est. USD column 12px -> 4px as its px-1 names", - "Workloads table header cells now 6px/8px to match their raw td data cells; Proxmox replication compact-mode !px-1 override still applies at 390px", - "Reactive case: switching a cell class between px-1 and a non-padding class re-resolves the base padding (unit test)" + "Mock stack on origin/main plus this change, Chromium, system font: Playwright sweep of every table on the 32 routes above at all five viewports (160 loads, 2,579 metric bars), recording th/td descendants with scrollWidth > clientWidth under hidden overflow and, per bar, observed width, rendered label, and the label's Range width plus padding", + "Same sweep on unmodified origin/main against the same backend process for a bar-by-bar comparison: 24 stacked memory bars showed a long label that did not fit, overflowing by 0.8px to 7.5px (3 on Kubernetes clusters at 560px, 4 on Proxmox overview at 900px, 16 on Docker overview and 1 on Kubernetes nodes at 1440px), and 3 inline per-disk labels were clipped on Proxmox overview at 900px", + "After the change none of those clip at any viewport: the 24 bars show the percentage alone and the inline disk slots show the percentage", + "Long labels at comfortable widths are kept: 550 bars show the long form against 567 before; of the 17 fewer, 24 are the clipped ones, 14 Proxmox guest disk bars at 900px gained the long form with 2.5px to 5.5px to spare, and 7 memory bars dropped it (5 on vSphere at 900px that had 1.0px to 2.8px to spare, 2 on Kubernetes nodes at 1440px that overflowed by under 0.5px)", + "Kubernetes clusters memory bar at 560px: 112.98px wide, shows the percentage; the same bar shows the long label at 900px (152.92px) and 1440px (182.11px) and returns to the percentage when the window narrows again", + "Still clipped and not changed here: the storage usage bar on /proxmox/storage (EnhancedStorageBar, which has no fit decision and does not use the estimator) clips at 390px (72 rows) and by up to 2.3px at 700px (19 rows), the same before and after", + "Every glyph forced to Verdana (10% wider than the system font): 2,219 bars on the 11 bar-bearing routes, 433 long labels, none clipped. Every glyph forced to Arial (7% narrower): 799 long labels, none clipped. The estimator follows the rendered font in both directions", + "WebKit, system font, same 11 routes: 2,219 bars, 550 long labels, none clipped", + "One-time glyph probe measured on the 429-bar Proxmox overview: 1.7 ms; about 110 ns per estimate afterwards" ], "interactions": [ - "Load each route at 1440, 1280, 900, 700, 560 and 390px and run the padding and clip sweep over all tables", - "Click Physical Disks on Proxmox > Storage at 390, 560, 700 and 1440px and expand the first disk row", - "Expand the first node row on Proxmox > Overview, the first deployment row on Kubernetes > Workloads, the first host row on Docker > Overview and the first system row on TrueNAS > Overview at 390, 560, 700 and 1440px", - "Expand the first alert row incident timeline on Alerts > History at 1440px", - "Compare before and after JSON sweeps per route, tab, viewport and table; re-measure the four states counted worse three times each", - "Revert and restore the change on the running dev server to A/B the Kubernetes clusters memory bar label at 560px" + "Load each route at 1440, 900, 700, 560 and 390px, wait for row and bar counts to settle, and run the clip sweep and bar inventory", + "Stash the change, repeat the full sweep on origin/main against the same backend process, restore the change and compare bar by bar", + "Hover the Kubernetes clusters memory bar at 560px while it shows the percentage alone and read used and free from its tooltip", + "Resize one loaded Kubernetes overview page through 560, 700, 900, 1440 and back to 560px and read the memory bar label at each width", + "Repeat the sweep on the bar-bearing routes with a global Verdana override, a global Arial override, and in WebKit" ] } diff --git a/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts b/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts index a3af452ccc..9db7b42aae 100644 --- a/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts @@ -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); diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts index 054068a04c..4e522ee8ba 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts @@ -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)', () => { @@ -463,6 +485,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 && ). diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts index 25c8480cd8..1914ed8d65 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts @@ -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('bg-surface/80 px-1 text-center'); + }); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx index 4006b7cc27..69c01b4f53 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx @@ -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 | undefined; + + const Harness = () => { + captured = useMetricBarState({ + value: 60, + label: '60%', + sublabel: '265 GB/440 GB', + type: 'memory', + }); + return
; + }; + + render(() => ); + + // '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); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx index 2dddbc9f42..4dceac1f31 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx @@ -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 | undefined; + + const Harness = () => { + captured = useStackedMemoryBarState({ + used: 265 * 1024 ** 3, + total: 440 * 1024 ** 3, + }); + return
; + }; + + render(() => ); + + // '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); + }); }); diff --git a/frontend-modern/src/components/Workloads/metricBarModel.ts b/frontend-modern/src/components/Workloads/metricBarModel.ts index 86cefc8e69..7e867e01bf 100644 --- a/frontend-modern/src/components/Workloads/metricBarModel.ts +++ b/frontend-modern/src/components/Workloads/metricBarModel.ts @@ -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; diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index 7b2adc3f65..0b6c8f35f5 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -144,8 +144,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 { @@ -269,7 +278,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); @@ -307,15 +319,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) diff --git a/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts b/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts index e37b93b9f6..a5a02eb298 100644 --- a/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts @@ -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, diff --git a/frontend-modern/src/utils/__tests__/format.coverage.test.ts b/frontend-modern/src/utils/__tests__/format.coverage.test.ts index c96512214a..440ea82325 100644 --- a/frontend-modern/src/utils/__tests__/format.coverage.test.ts +++ b/frontend-modern/src/utils/__tests__/format.coverage.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/__tests__/formatExtra.test.ts b/frontend-modern/src/utils/__tests__/formatExtra.test.ts index 319ad23a3d..0de416527a 100644 --- a/frontend-modern/src/utils/__tests__/formatExtra.test.ts +++ b/frontend-modern/src/utils/__tests__/formatExtra.test.ts @@ -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); }); }); diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 01ed35b2b7..7d959f9d89 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -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); } /** From 96038b953aa374962b3995db421b930c75db6071 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:09:54 +0100 Subject: [PATCH 3/7] Drop the retired shared browser receipt before merging main main retired frontend-modern/browser-verification.json in favour of per-commit receipts (#2436, #2437). Deleting it here lets origin/main merge without a modify/delete conflict; this branch's earlier commits stay valid evidence in range mode. --- frontend-modern/browser-verification.json | 50 ----------------------- 1 file changed, 50 deletions(-) delete mode 100644 frontend-modern/browser-verification.json diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json deleted file mode 100644 index 2ff0c99157..0000000000 --- a/frontend-modern/browser-verification.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": 1, - "base_sha": "74fd189fd03098b7ed94d806b15f570722048670", - "verified_at": "2026-10-03T09:40:30Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", - "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts", - "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx" - ], - "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8", - "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51", - "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f6f1b090580e4bea0d6496b908f72049c886c4faeee7aca3a3b11e9417c67df7" - }, - "routes": [ - "/kubernetes" - ], - "viewports": [ - { - "width": 1440, - "height": 900 - }, - { - "width": 1280, - "height": 900 - }, - { - "width": 900, - "height": 900 - }, - { - "width": 390, - "height": 900 - } - ], - "states": [ - "Mock estate (1 Kubernetes cluster, 2 nodes, 6 pods, 2 deployments) with one CronJob carrying lastScheduleTime and lastSuccessfulTime, one completed Job with startTime and completionTime, one DaemonSet and one StatefulSet in the Workload Controllers table, Chromium; re-run on 2026-10-03 against this tree after main gained the shared Table cell-padding change (#2427)", - "Workload Controllers table at the full desktop layout where Scope and Detail show (1440px), at container widths where they are hidden (1280 and 900px), and at the phone projection (390px) where the row keeps Controller, Kind, Ready and Issues and the Target column is demoted", - "Each CronJob, Job, DaemonSet and StatefulSet row expanded to its inline detail at all four widths; the summary tables show Runtime context, the new per-kind controller section and Identity" - ], - "interactions": [ - "Measured every controllers table header and cell for clipped text (scrollWidth over clientWidth) at each viewport: at 390px the Kind track is 90px and \"DaemonSet\", \"StatefulSet\" and \"CronJob\" fit whole where the 54px track had clipped them, no Target cell remains to clip \"1 completions\", and only the four long controller names truncate with an ellipsis; no viewport scrolls horizontally", - "Expanded each controller row by tapping it and read the new section: the CronJob shows Schedule \"0 2 * * *\", Last run and Last success as \"Oct 2, 2026, 10:25 PM (7m ago)\" style absolute times, Active, Namespace and Cluster; the Job shows Started, Completed, Duration 5m, Target \"1 completion\", Succeeded, Namespace and Cluster; the DaemonSet and StatefulSet show Target, Current, Ready, Available, Updated and the StatefulSet Service name whole", - "Measured the expansion for clipped text at each width: the wrapped timestamp values no longer clip at 390px (an earlier single-line \"absolute \u00b7 age\" value needed 169px in a 142px cell), and the only remaining clipped values are the pre-existing Identity IDs and alias badges", - "Read the Job row Target at 1440px as \"1 completion\" after the pluralization fix, matching the expansion", - "Compared before and after screenshots at 1440 and 390px: the desktop drawer gains a Job or CronJob card between Runtime context and Identity, and the phone row reads Controller, Kind, Ready, Issues with every kind label whole" - ], - "command": "playwright chromium against a private mock-mode stack built from this commit (backend :7697, vite :5217), sweep script in the session scratchpad" -} From d08c7f6d05904a61fc9cf8fbfb69d899918ef1e8 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:09:54 +0100 Subject: [PATCH 4/7] Drop the retired shared browser receipt before merging main main retired frontend-modern/browser-verification.json in favour of per-commit receipts (#2436, #2437). Deleting it here lets origin/main merge without a modify/delete conflict; this branch's earlier commits stay valid evidence in range mode. --- frontend-modern/browser-verification.json | 92 ----------------------- 1 file changed, 92 deletions(-) delete mode 100644 frontend-modern/browser-verification.json diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json deleted file mode 100644 index e1d137895f..0000000000 --- a/frontend-modern/browser-verification.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "version": 1, - "base_sha": "74fd189fd03098b7ed94d806b15f570722048670", - "verified_at": "2026-10-03T10:27:38Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/metricBarModel.ts", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", - "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts", - "frontend-modern/src/utils/format.ts" - ], - "content_sha256": { - "frontend-modern/src/components/Workloads/metricBarModel.ts": "1c312bae85ff75b042fb502586662eebc91bc8b5d54054890e2f3bba155d5505", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "c7719b5646d60cb4683b10d9dd0567729d346dd95e003cb8bb62be33b3d39ac1", - "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts": "7b2395cc5082ca1fdfb1a47aa0cc6a0f130e87ff57df3827f9f46a29cd704d7a", - "frontend-modern/src/utils/format.ts": "eed4e6a02476120a262b412120c158ac93583e2d6cec9dd39915253777605261" - }, - "routes": [ - "/proxmox/overview", - "/proxmox/storage", - "/proxmox/replication", - "/proxmox/backups/date", - "/proxmox/ceph", - "/proxmox/mail", - "/docker/overview", - "/docker/images", - "/docker/storage", - "/docker/networks", - "/docker/swarm", - "/kubernetes/overview", - "/kubernetes/nodes", - "/kubernetes/workloads", - "/kubernetes/services", - "/kubernetes/storage", - "/kubernetes/configuration", - "/kubernetes/events", - "/truenas/overview", - "/truenas/storage", - "/truenas/services", - "/truenas/apps", - "/truenas/vms", - "/truenas/shares", - "/truenas/protection", - "/vmware/overview", - "/vmware/storage", - "/vmware/networks", - "/vmware/health", - "/vmware/activity", - "/standalone/machines", - "/standalone/availability" - ], - "viewports": [ - { - "width": 1440, - "height": 1400 - }, - { - "width": 900, - "height": 1400 - }, - { - "width": 700, - "height": 1400 - }, - { - "width": 560, - "height": 1400 - }, - { - "width": 390, - "height": 1400 - } - ], - "states": [ - "Mock stack on origin/main plus this change, Chromium, system font: Playwright sweep of every table on the 32 routes above at all five viewports (160 loads, 2,579 metric bars), recording th/td descendants with scrollWidth > clientWidth under hidden overflow and, per bar, observed width, rendered label, and the label's Range width plus padding", - "Same sweep on unmodified origin/main against the same backend process for a bar-by-bar comparison: 24 stacked memory bars showed a long label that did not fit, overflowing by 0.8px to 7.5px (3 on Kubernetes clusters at 560px, 4 on Proxmox overview at 900px, 16 on Docker overview and 1 on Kubernetes nodes at 1440px), and 3 inline per-disk labels were clipped on Proxmox overview at 900px", - "After the change none of those clip at any viewport: the 24 bars show the percentage alone and the inline disk slots show the percentage", - "Long labels at comfortable widths are kept: 550 bars show the long form against 567 before; of the 17 fewer, 24 are the clipped ones, 14 Proxmox guest disk bars at 900px gained the long form with 2.5px to 5.5px to spare, and 7 memory bars dropped it (5 on vSphere at 900px that had 1.0px to 2.8px to spare, 2 on Kubernetes nodes at 1440px that overflowed by under 0.5px)", - "Kubernetes clusters memory bar at 560px: 112.98px wide, shows the percentage; the same bar shows the long label at 900px (152.92px) and 1440px (182.11px) and returns to the percentage when the window narrows again", - "Still clipped and not changed here: the storage usage bar on /proxmox/storage (EnhancedStorageBar, which has no fit decision and does not use the estimator) clips at 390px (72 rows) and by up to 2.3px at 700px (19 rows), the same before and after", - "Every glyph forced to Verdana (10% wider than the system font): 2,219 bars on the 11 bar-bearing routes, 433 long labels, none clipped. Every glyph forced to Arial (7% narrower): 799 long labels, none clipped. The estimator follows the rendered font in both directions", - "WebKit, system font, same 11 routes: 2,219 bars, 550 long labels, none clipped", - "One-time glyph probe measured on the 429-bar Proxmox overview: 1.7 ms; about 110 ns per estimate afterwards" - ], - "interactions": [ - "Load each route at 1440, 900, 700, 560 and 390px, wait for row and bar counts to settle, and run the clip sweep and bar inventory", - "Stash the change, repeat the full sweep on origin/main against the same backend process, restore the change and compare bar by bar", - "Hover the Kubernetes clusters memory bar at 560px while it shows the percentage alone and read used and free from its tooltip", - "Resize one loaded Kubernetes overview page through 560, 700, 900, 1440 and back to 560px and read the memory bar label at each width", - "Repeat the sweep on the bar-bearing routes with a global Verdana override, a global Arial override, and in WebKit" - ] -} From 1106de8c267407dc98642fe92c174d3a10ec4a9b Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:21:04 +0100 Subject: [PATCH 5/7] Record the browser pass for main's Tailwind 4 renames in Kubernetes Merging origin/main brought PR #2443's class renames into two files this branch also changed. The merged content had no browser pass, so this receipt records one on a private mock stack across desktop and phone, light and dark. --- .../f1bc00520f68ff00.json | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 frontend-modern/browser-verification/f1bc00520f68ff00.json diff --git a/frontend-modern/browser-verification/f1bc00520f68ff00.json b/frontend-modern/browser-verification/f1bc00520f68ff00.json new file mode 100644 index 0000000000..d75329d97d --- /dev/null +++ b/frontend-modern/browser-verification/f1bc00520f68ff00.json @@ -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" + ] +} From b8a0f87e6ba53d9d93fa8634b9788f544e049dc1 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:35:27 +0100 Subject: [PATCH 6/7] Proxmox and Docker passes: storage words, first paint, table fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds #2432 as one commit on current main. Its branch conflicted with the Tailwind 4 move (#2443) in StackedDiskBar.tsx and DockerTasksTable.tsx; the resolution keeps this change and main's class renames (rounded-sm to rounded-xs). The eight original commits' reasons follow. Read PBS datastore status in the storage vocabulary: The PBS poller reports a datastore as "available", or "unavailable" when PBS returns an error for it. The datastore adapter mapped that string through the generic status mapper, which only knows online, offline and warning, so every healthy PBS datastore showed as "Unknown" in the Storage tab and an unavailable one was not flagged. PVE storage already maps the same vocabulary through its own storage mapper. Share that mapping so a PBS datastore reads online or offline like any other storage. Carry shared PBS storage as one cluster row in the mock estate: The mock emitted each cluster's two shared PBS storages once per node, each with its own usage figure. The real poller folds a shared storage into one cluster-scoped entry, which users asked for in issue 1049, so the demo's Storage tab contradicted the shipped behaviour: ten rows of "west-backup-primary" with different usage and growth on a five-node estate, and a hundred on the public demo. The fixture now carries the merged shape: one row per store with the reporting nodes listed and a cluster-scoped id, offline only when no cluster node is online. Name Proxmox storage types and shared hosts as operators know them: The Storage tab title-cased any storage type it did not map, so the default local-lvm on every Proxmox install read "Lvmthin" and an NFS share read "Nfs". Those types now carry the names the Proxmox UI uses: LVM-Thin, LVM, NFS, SMB/CIFS, iSCSI, ZFS, BTRFS, GlusterFS and ESXi. A storage the poller merged across the cluster showed the synthetic node marker "cluster" as its host. It now reads "Shared", with the reporting node count when the storage lists its nodes. Carry PMG metadata on REST-mapped resources: The Mail Gateway table reads resource.pmg. The REST-to-Resource mapper set every other provider facet at the top level but kept pmg only under platformData, so a row sourced from the REST snapshot rendered every PMG column (version, nodes, mail in, spam, virus, quarantine, queue, deferred) as a dash. The websocket snapshot carries pmg at the top level, so the table filled in only when a websocket row happened to replace the REST row; on a fresh load the two transports race, and about half the loads stayed blank for good. The mapper now carries pmg like the other facets, and a test pins it. Label multi-disk hosts with their fullest disk in Bars mode: A host with more than one agent-reported disk draws its Disk cell as one micro-bar per disk and no figure, while a single-disk host shows a percentage and Trends mode shows one for every host. Bars is the default view, and most Proxmox nodes have at least local-zfs beside a data pool, so the question "which node is running out of space?" had no at-a-glance answer for the rows that matter most; the column's own sort key was a number the cell never showed. The cell now carries the fullest disk's usage beside the micro-bars. Every bar keeps equal weight and no disk is named in the row; the mount and the per-disk breakdown stay in the title and the tooltip. Carry source status, action readiness and technology on REST rows: The same race that blanked the Mail Gateway table applies to three more fields the API returns: sourceStatus, actionReadiness and technology. The REST mapper set sourceStatus only under platformData and dropped the other two, while websocket rows carry all three at the top level. Docker container lifecycle actions read sourceStatus and actionReadiness to decide refusals, storage rows read sourceStatus for freshness, and search suggestions and the assistant context read technology, so whichever transport won a fresh load decided whether those consumers saw the data. The mapper now carries all three, the REST type declares the two it omitted, and the PMG mapping test pins them. Fit the Docker containers table and read Swarm state as words: On a 1280-1536px laptop window the containers table clipped its short values: the engine read "docker 2…", host names lost their tail, and the Update control itself was cut off, while the percentage-only CPU and memory bars had room to spare. The compact widths now give those columns their share. On a phone the row dropped to "runni…" with an unreadable Restarts header beside a wrapped Update button; the phone row now keeps container, state, CPU, memory and Update, and the restart count stays in the row expansion until mobile width. The images table on a phone showed "Not c…" for the update verdict and a clipped host; it keeps image, size and update check there and defers host and consumers to the expansion. Swarm services showed the rollout state as the raw token "rollback_started" and Swarm tasks showed a raw RFC3339 stamp with microseconds as their start time. The state reads as words and the start as an age, with the raw values in the title. Keep demo Docker container names unique per host: The demo scenario reused its curated container profiles for a second round on hosts with more containers than profiles, so a host showed two running "customer-portal" rows and every image's "used by" list read "customer-portal, customer-portal". Docker refuses two containers with one name on a host, so the duplicates read as a bug to anyone evaluating the demo. A reused profile now takes a numbered suffix. Contract-Neutral: Storage type labels, shared-host label, REST mapper carrying fields websocket rows already carry, Bars-mode disk label and Docker column fits are presentation and mapping only; owning unified-resources and storage-recovery clauses updated; no frontend-primitives primitive, API or runtime boundary change --- .../v6/internal/subsystems/monitoring.md | 7 ++ .../subsystems/performance-and-scalability.md | 13 +++ .../internal/subsystems/storage-recovery.md | 8 ++ .../internal/subsystems/unified-resources.md | 23 +++++ .../dceabcf6f551939d.json | 67 ++++++++++++++ .../components/Workloads/StackedDiskBar.tsx | 69 +++++++++------ .../__tests__/StackedDiskBar.test.tsx | 32 +++++++ ...stackedDiskBarModel.branchcov0712b.test.ts | 4 + .../Workloads/stackedDiskBarModel.ts | 8 ++ .../src/features/docker/DockerImagesTable.tsx | 14 +-- .../features/docker/DockerServicesTable.tsx | 7 +- .../src/features/docker/DockerTasksTable.tsx | 10 +-- .../__tests__/DockerNativeTables.test.tsx | 11 ++- .../dockerContainerTableModel.test.ts | 4 +- .../docker/dockerContainerTableModel.ts | 30 ++++--- .../platformOverviewLayout.guardrails.test.ts | 13 ++- .../resourceStoragePresentation.test.ts | 8 ++ .../__tests__/storageAdapters.test.ts | 31 +++++++ .../resourceStoragePresentation.ts | 24 +++++ .../storageBackups/storageAdapters.ts | 11 ++- .../__tests__/useUnifiedResources.test.ts | 58 ++++++++++++ .../src/hooks/useUnifiedResources.ts | 15 ++++ internal/mock/demo_scenarios.go | 14 ++- internal/mock/demo_scenarios_test.go | 24 +++++ internal/mock/generator.go | 88 +++++++++++-------- internal/mock/generator_test.go | 31 +++++++ internal/unifiedresources/adapters.go | 6 +- internal/unifiedresources/adapters_test.go | 22 +++++ .../unifiedresources/pbs_pmg_registry_test.go | 23 +++++ internal/unifiedresources/status.go | 21 +++-- 30 files changed, 592 insertions(+), 104 deletions(-) create mode 100644 frontend-modern/browser-verification/dceabcf6f551939d.json diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 6265060bec..848c054f05 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1557,6 +1557,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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 8df6300caa..a071784ef5 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -359,6 +359,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 @@ -2510,6 +2516,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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 97ffabafe7..b261249403 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2668,6 +2668,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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ae311147f4..61b7cd908b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2030,6 +2030,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` @@ -2553,6 +2561,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`. @@ -5408,6 +5421,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 diff --git a/frontend-modern/browser-verification/dceabcf6f551939d.json b/frontend-modern/browser-verification/dceabcf6f551939d.json new file mode 100644 index 0000000000..4187797721 --- /dev/null +++ b/frontend-modern/browser-verification/dceabcf6f551939d.json @@ -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" + ] +} diff --git a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx index 46c6d86628..868691bdb8 100644 --- a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx @@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) { } > - {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold */} + {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold. + The fullest disk's percentage sits beside them so the cell still reads + as a number at a glance; every bar keeps equal weight. */}
-
- - {(bar) => ( -
+
+ + + {presentation().verticalBarsLabel} + + +
+ + {(bar) => (
- + +
-
- )} - + )} + +
diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx index e550f069a0..391101ac73 100644 --- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx @@ -587,4 +587,36 @@ describe('StackedDiskBar', () => { expect(bar?.getAttribute('fill')).toContain('239, 68, 68'); }); }); + + describe('vertical-bars mode', () => { + it('labels the micro-bars with the fullest disk so the cell reads as a number', () => { + const { container } = render(() => ( + + )); + + const label = container.querySelector('[data-stacked-disk-max-label]'); + expect(label).toHaveTextContent('52%'); + expect(label).toHaveAttribute('title', 'Highest usage: /data 52%'); + // Every disk keeps its own bar with equal weight; the label is derived. + expect(container.querySelectorAll('[data-stacked-disk-fill="vertical"]')).toHaveLength(2); + }); + + it('omits the label when no disk has measured usage', () => { + const { container } = render(() => ( + + )); + + expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull(); + }); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts index 054068a04c..b56964dfe4 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts @@ -372,6 +372,10 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { title: '/x: 40% (40.0 B/100 B)', }); expect(p.verticalBars[1].color).toBe(CRITICAL); // 120% -> critical + // The fullest disk's usage labels the cell so a multi-disk host still + // reads as a number without a hover. + expect(p.verticalBarsLabel).toBe('120%'); + expect(p.verticalBarsLabelTitle).toBe('Highest usage: /y 120%'); expect(p.tooltipContent).toHaveLength(3); expect(p.tooltipContent[2]).toMatchObject({ label: '/z', percent: '—', used: '?' }); }); diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index 7b2adc3f65..ed72f85037 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -87,6 +87,10 @@ export interface StackedDiskBarPresentation { tooltipTitle: string; useStackedSegments: boolean; verticalBars: StackedDiskVerticalBar[]; + // The fullest disk's usage, shown beside the micro-bars so a multi-disk + // host answers "how close is this host to running out?" without a hover. + verticalBarsLabel: string; + verticalBarsLabelTitle: string; verticalBarsMode: boolean; } @@ -359,6 +363,8 @@ export function buildStackedDiskBarPresentation( }; }) : []; + const verticalBarsLabel = verticalBarsMode && maxInfo ? formatPercent(maxInfo.percent) : ''; + const verticalBarsLabelTitle = verticalBarsMode && maxInfo ? maxLabelFull : ''; const tooltipContent = buildTooltipContent(allDisks, { aggregateDisk: props.aggregateDisk, aggregateMode, @@ -402,6 +408,8 @@ export function buildStackedDiskBarPresentation( tooltipTitle: allDisks.length > 1 ? 'Disk Breakdown' : 'Disk Usage', useStackedSegments, verticalBars, + verticalBarsLabel, + verticalBarsLabelTitle, verticalBarsMode, }; } diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx index 88345be440..744bc18b71 100644 --- a/frontend-modern/src/features/docker/DockerImagesTable.tsx +++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx @@ -152,7 +152,7 @@ export const DockerImagesTable: Component< kind="text" sort={sort} sortKey="host" - class="platform-table-mobile-w-15 md:w-[18%]" + class="platform-table-phone-hidden md:w-[18%]" > Host @@ -160,7 +160,7 @@ export const DockerImagesTable: Component< kind="text" sort={sort} sortKey="usedBy" - class="platform-table-mobile-w-15 md:w-[24%]" + class="platform-table-phone-hidden md:w-[24%]" > Used by @@ -168,7 +168,7 @@ export const DockerImagesTable: Component< kind="numeric-value" sort={sort} sortKey="size" - class="platform-table-mobile-w-15 md:w-[12%]" + class="platform-table-mobile-w-20 md:w-[12%]" > Size @@ -176,7 +176,7 @@ export const DockerImagesTable: Component< kind="badge" sort={sort} sortKey="update" - class="platform-table-mobile-w-15 md:w-[16%]" + class="platform-table-mobile-w-25 md:w-[16%]" > @@ -211,13 +211,15 @@ export const DockerImagesTable: Component< /> } /> + {/* Host and consumers fit the row expansion on a phone; the + row keeps the image, its size and the update verdict. */} {dockerHostName(resource)} c.toUpperCase()) + : 'Updating'; const title = [state, message, completedAt].filter(Boolean).join(' | ') || label; return { label, title }; }; diff --git a/frontend-modern/src/features/docker/DockerTasksTable.tsx b/frontend-modern/src/features/docker/DockerTasksTable.tsx index e8fabdeaa0..e45f4a182b 100644 --- a/frontend-modern/src/features/docker/DockerTasksTable.tsx +++ b/frontend-modern/src/features/docker/DockerTasksTable.tsx @@ -12,6 +12,7 @@ import { createPlatformTableSortState, getPlatformTableCellClassForKind, getPlatformTableDateTimeSortValue, + PlatformTableRelativeTimeValue, PlatformTableShell, type PlatformTableSortValue, withPlatformStatusCounts, @@ -255,11 +256,10 @@ export const DockerTasksTable: Component = (props) => { diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 1695ae6c76..4d31066272 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -1512,7 +1512,11 @@ describe('Docker native tables', () => { expect(screen.getByText('replicated')).toBeInTheDocument(); expect(screen.getByText('4')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.getByText('rollback_started')).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').closest('[title]')?.getAttribute('title'), + ).toContain('rollback_started'); expect(screen.getByText('18080:8080/tcp')).toBeInTheDocument(); expect(screen.getByText('manager-1')).toBeInTheDocument(); expect(document.querySelector('[data-docker-service-row="service-1"]')).not.toBeNull(); @@ -1651,7 +1655,10 @@ describe('Docker native tables', () => { expect(screen.getByText('web.2')).toBeInTheDocument(); expect(screen.getByText('web')).toBeInTheDocument(); expect(screen.getByText('running 2 minutes')).toBeInTheDocument(); - expect(screen.getByText('2026-05-24T13:05:00Z')).toBeInTheDocument(); + // The raw RFC3339 stamp moves to the title; the cell shows an age. + const started = screen.getByTitle('2026-05-24T13:05:00Z'); + expect(started).toBeInTheDocument(); + expect(started.textContent).toMatch(/ago$/); }); it('renders Docker Swarm secret API metadata without secret data', () => { diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts index f8a8fc36f4..41f10a0869 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts @@ -30,7 +30,9 @@ describe('dockerContainerTableModel', () => { const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates']); + // Restarts waits for mobile width: a phone row could not fit its header + // beside the Update control, and the count stays in the row expansion. + expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']); expect(getDockerContainerColumnWidthStyle('container', 'phone', ids)).toEqual({ width: '30%', }); diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts index b573f3aba2..2961092c89 100644 --- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -70,7 +70,9 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record< state: 'narrow', cpu: 'narrow', memory: 'narrow', - restarts: 'phone', + // A phone row cannot fit the Restarts header beside the Update control; + // the count stays in the row expansion there and returns at mobile width. + restarts: 'mobile', updates: 'narrow', actions: 'mobile', host: 'tablet', @@ -126,11 +128,10 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< }, phone: { container: 32, - state: 13, - cpu: 15, - memory: 17, - restarts: 10, - updates: 13, + state: 14, + cpu: 14, + memory: 14, + updates: 16, }, mobile: { container: 30, @@ -151,17 +152,20 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< updates: 9, actions: 6, }, + // Compact is what a 1280-1536px laptop window gets. Host, Engine and the + // Update control are short values that were clipped ("docker 2…") while the + // percentage-only CPU and memory bars had room to spare. compact: { container: 18, - host: 10, - runtime: 8, - image: 18, + host: 12, + runtime: 9.5, + image: 16.5, state: 7, - cpu: 11, - memory: 12, - restarts: 9, + cpu: 9.5, + memory: 10, + restarts: 8.5, ports: 12, - updates: 9, + updates: 10, actions: 8, }, }; diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index b811259023..bae62908b8 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -321,8 +321,19 @@ 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-15 md:w-\[12%\]"/, + /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%\]"/, diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts index 14cd9deebd..5185db68c3 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts @@ -62,6 +62,14 @@ describe('resourceStoragePresentation', () => { ), ).toBe('Datastore'); expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage'); + // Proxmox plugin names are acronyms or compound words; title-casing them + // produced "Lvmthin" for every default local-lvm storage and "Nfs". + expect(getResourceStorageTopologyLabel(makeResource(), 'lvmthin')).toBe('LVM-Thin'); + expect(getResourceStorageTopologyLabel(makeResource(), 'lvm')).toBe('LVM'); + expect(getResourceStorageTopologyLabel(makeResource(), 'nfs')).toBe('NFS'); + expect(getResourceStorageTopologyLabel(makeResource(), 'cifs')).toBe('SMB/CIFS'); + expect(getResourceStorageTopologyLabel(makeResource(), 'iscsi')).toBe('iSCSI'); + expect(getResourceStorageTopologyLabel(makeResource(), 'btrfs')).toBe('BTRFS'); expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe( 'Rebuild Target', ); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts index 96866fbeda..7e5917d601 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts @@ -467,6 +467,37 @@ describe('storageAdapters', () => { expect(records[0].hostLabel).toBe('pve1'); }); + it('labels a poller-merged shared storage as shared with its node count', () => { + const records = buildStorageRecords({ + state: baseState(), + resources: [ + makeResourceStorage({ + id: 'cluster-a-cluster-nfs-iso', + name: 'nfs-iso', + parentName: 'cluster', + platformData: { type: 'nfs', node: 'cluster', instance: 'cluster-a', shared: true }, + storage: { + type: 'nfs', + shared: true, + nodes: ['pve1', 'pve2', 'pve3'], + } as Resource['storage'], + }), + makeResourceStorage({ + id: 'pve2-local-lvm', + name: 'local-lvm', + parentName: 'pve2', + platformData: { type: 'lvmthin', node: 'pve2', instance: 'cluster-a', shared: false }, + storage: { type: 'lvmthin', shared: false } as Resource['storage'], + }), + ], + }); + + // The poller folds a shared storage into one entry on the synthetic + // "cluster" node; a node-local storage keeps its node name. + expect(records.map((record) => record.hostLabel)).toEqual(['Shared · 3 nodes', 'pve2']); + expect(records.map((record) => record.topologyLabel)).toEqual(['NFS', 'LVM-Thin']); + }); + it('keeps dependency impact separate from healthy storage primary issues', () => { const records = buildStorageRecords({ state: baseState(), diff --git a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts index 0c3b5d618a..9302b84d66 100644 --- a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts +++ b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts @@ -244,6 +244,30 @@ export const getResourceStorageTopologyLabel = ( case 'rbd': case 'cephfs': return 'Cluster Storage'; + // Proxmox storage plugin names are acronyms or compound words that + // title-casing mangles ("Lvmthin" for the default local-lvm, "Nfs"); use + // the names the Proxmox UI shows for them. + case 'nfs': + return 'NFS'; + case 'cifs': + case 'smb': + return 'SMB/CIFS'; + case 'lvm': + return 'LVM'; + case 'lvmthin': + case 'lvm-thin': + return 'LVM-Thin'; + case 'iscsi': + case 'iscsidirect': + return 'iSCSI'; + case 'zfs': + return 'ZFS'; + case 'btrfs': + return 'BTRFS'; + case 'glusterfs': + return 'GlusterFS'; + case 'esxi': + return 'ESXi'; default: return titleize(storageType) || titleize(resource.type) || 'Storage'; } diff --git a/frontend-modern/src/features/storageBackups/storageAdapters.ts b/frontend-modern/src/features/storageBackups/storageAdapters.ts index 19b8ac6598..7e22fdd6d7 100644 --- a/frontend-modern/src/features/storageBackups/storageAdapters.ts +++ b/frontend-modern/src/features/storageBackups/storageAdapters.ts @@ -129,7 +129,16 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag const usedBytes = asNumberOrNull(resource.disk?.used); const freeBytes = asNumberOrNull(resource.disk?.free); const canonicalPlatform = getCanonicalStoragePlatformKey(resource, resource.storage?.platform); - const hostLabel = locationLabel; + // The poller folds a shared storage into one entry whose node is the + // synthetic "cluster" marker (the mock uses "shared"); show that as a + // shared storage with its reporting node count rather than a node name. + const sharedLocationMarkers = new Set(['cluster', 'shared']); + const hostLabel = + shared && sharedLocationMarkers.has(locationLabel.trim().toLowerCase()) + ? storageNodes.length > 1 + ? `Shared · ${storageNodes.length} nodes` + : 'Shared' + : locationLabel; const platformLabel = getResourceStoragePlatformLabel(canonicalPlatform); const issueLabel = getResourceStorageIssueLabel(resource); const issueSummary = getResourceStorageIssueSummary(resource); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 3408c0079d..079d6012a2 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -1626,6 +1626,64 @@ describe('useUnifiedResources', () => { dispose(); }); + it('keeps PMG metadata on the canonical resource for REST-first paints', async () => { + // The Mail Gateway table reads `resource.pmg`. The mapper used to keep it + // only under platformData, so a REST-sourced row rendered every PMG column + // as a dash until a websocket row happened to replace it. + apiFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { + id: 'pmg-1', + type: 'pmg', + name: 'mail-gateway-eu', + status: 'online', + lastSeen: '2026-02-06T12:00:00Z', + sources: ['pmg'], + uptime: 1_555_222, + technology: 'pmg', + sourceStatus: { pmg: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [{ name: 'restart', available: true }], + pmg: { + instanceId: 'pmg-eu', + hostname: 'pmg.example.lan', + version: '8.1-2', + nodeCount: 1, + mailCountTotal: 2_740, + spamIn: 321, + virusIn: 14, + queueTotal: 11, + queueDeferred: 5, + }, + }, + ], + }), + }); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ query: 'type=pmg&source=pmg', cacheKey: 'pmg-first-paint' }); + }); + + await result!.refetch(); + const row = result!.resources().find((resource) => resource.id === 'pmg-1'); + expect(row?.pmg).toEqual( + expect.objectContaining({ version: '8.1-2', spamIn: 321, queueDeferred: 5 }), + ); + expect(row?.platformData?.pmg).toEqual(row?.pmg); + // The per-source status, action readiness, and technology facets ride the + // same race: Docker lifecycle actions and storage freshness read them at + // the top level, where only websocket rows used to carry them. + expect(row?.sourceStatus?.pmg?.status).toBe('online'); + expect(row?.actionReadiness?.[0]?.name).toBe('restart'); + expect(row?.technology).toBe('pmg'); + + dispose(); + }); + it('falls back to canonical resource.uptime when no platform-specific field is set (vSphere)', async () => { // vSphere ESXi hosts and VMs populate only the canonical // Resource.Uptime field — there's no vmware-specific UptimeSeconds diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 9093092194..dbb5d9b3d8 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -18,8 +18,10 @@ import type { ResourceDockerMeta, ResourceHealth, ResourceMetricsTarget, + ResourceActionReadiness, ResourceAvailabilityMeta, ResourcePBSMeta, + ResourcePMGMeta, ResourcePolicyPostureSummary, ResourcePhysicalDiskMeta, ResourcePoolHealth, @@ -210,6 +212,8 @@ type APIResource = { sources?: string[]; platformScopes?: string[]; sourceStatus?: Record; + actionReadiness?: ResourceActionReadiness[]; + technology?: string; health?: ResourceHealth; identity?: { machineId?: string; @@ -868,6 +872,17 @@ const toResource = (v2: APIResource): Resource => { truenas: v2.truenas as ResourceTrueNASMeta | undefined, vmware: v2.vmware as ResourceVMwareMeta | undefined, pbs: v2.pbs as ResourcePBSMeta | undefined, + // Mail Gateway rows read `resource.pmg` directly. Leaving it only under + // platformData meant a REST-first paint rendered every PMG column as a + // dash until a websocket row happened to replace it, and never did when + // the REST snapshot landed last. + pmg: v2.pmg as ResourcePMGMeta | undefined, + // Same race for the per-source status, action readiness, and technology + // facets: Docker lifecycle actions and storage freshness read them from + // the top level, and the websocket rows carry them there. + sourceStatus: v2.sourceStatus, + actionReadiness: v2.actionReadiness, + technology: v2.technology, availability: v2.availability as ResourceAvailabilityMeta | undefined, availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined, physicalDisk: v2.physicalDisk, diff --git a/internal/mock/demo_scenarios.go b/internal/mock/demo_scenarios.go index e4fd41df0f..6211b82793 100644 --- a/internal/mock/demo_scenarios.go +++ b/internal/mock/demo_scenarios.go @@ -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" diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go index b5e8c34af9..4b46d58649 100644 --- a/internal/mock/demo_scenarios_test.go +++ b/internal/mock/demo_scenarios_test.go @@ -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") + } +} diff --git a/internal/mock/generator.go b/internal/mock/generator.go index c1d914d06d..658d7c48a6 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -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) diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go index 1c254a197c..98772cd2ff 100644 --- a/internal/mock/generator_test.go +++ b/internal/mock/generator_test.go @@ -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") + } +} diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 9d360e2ece..1a93cb5f03 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1562,7 +1562,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) diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index b06013ade9..b6309233d1 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -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{ diff --git a/internal/unifiedresources/pbs_pmg_registry_test.go b/internal/unifiedresources/pbs_pmg_registry_test.go index 0146f353fa..cb7ac971b3 100644 --- a/internal/unifiedresources/pbs_pmg_registry_test.go +++ b/internal/unifiedresources/pbs_pmg_registry_test.go @@ -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) + } + } +} diff --git a/internal/unifiedresources/status.go b/internal/unifiedresources/status.go index fa4650e281..fcd1676c74 100644 --- a/internal/unifiedresources/status.go +++ b/internal/unifiedresources/status.go @@ -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 From 5960a4dc3e9729de07504533480b99451dbc2114 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:40:37 +0100 Subject: [PATCH 7/7] Pin the memory label chip padding, not its retired background The Tailwind 4 move (#2443) dropped the chip's bg-surface/80, which never rendered under Tailwind 3, so this branch's source pin failed after merging main. The model counts the chip's px-1 padding; pin that class alone. --- .../__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts index 1914ed8d65..e88e3eb1ff 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts @@ -231,7 +231,7 @@ describe('stackedMemoryBarModel (branch coverage 0724pm)', () => { }); it('counts the padding the label chip carries in StackedMemoryBar', () => { - expect(stackedMemoryBarSource).toContain('bg-surface/80 px-1 text-center'); + expect(stackedMemoryBarSource).toContain('px-1 text-center'); }); }); });