Remove the unreachable host metric cells from grouped workload rows (#2622)

Both production mounts of the Workloads surface, the Proxmox and vSphere
overviews, set `compactGroupHeaders` unconditionally. The non-compact
branch in `WorkloadPanel` (per-column node CPU, memory, disk and I/O
bars or sparklines, uptime, version, temperature) and the
`NodeGroupHeader` column mode and inline host facts that only it reached
never rendered. They were left over from the retired standalone
Workloads page.

This removes the option, the dead renderers and the `NodeGroupHeader`
props that only served them. Grouped host rows keep the shape both pages
already render: one spanning cell with status, linked name, cluster and
agent badges. Host stats stay in each page's own hosts table.

Verification
- No other production consumer: `WorkloadsSurface` → `WorkloadsTable` →
`WorkloadPanel` is entered only from `ProxmoxPageSurface` and
`VmwarePageSurface`; no references in pulse-pro, pulse-enterprise or
pulse-mobile.
- Browser pass on a production build embedded in a private mock backend,
`/proxmox/overview` and `/vmware` at 1440x900 and 390x844, Bars and
Trends modes: every grouped host row is one cell spanning the guest
table's visible columns, with no metric cells or host facts; group-row
clicks open nothing; guest drawers still open; no page errors.
- The rewritten `NodeGroupHeader` test fails against the old component
(its default facts render `PVE 9.1.9`).
- `tsc`, full `npm run lint`, vitest over Workloads, shared, features,
hooks, utils, Storage and app tests (only the Mac-only
`agentInstallCommand` failures, unrelated), bundle budgets, and all
release-control guards pass.
This commit is contained in:
rcourtman
2026-10-06 23:42:36 +01:00
committed by GitHub
17 changed files with 120 additions and 469 deletions
@@ -2911,9 +2911,9 @@ Agent`), with the plain-language source phrase available through accessible
`pveVersion` or a Pulse Agent report whose OS identity resolves to Unraid or
Proxmox VE. They must omit the version rather than showing unrelated
collector OS versions, such as Debian 12, beside an API-backed PVE badge.
Shared row primitives that render Proxmox node identity, including
`frontend-modern/src/components/shared/NodeGroupHeader.tsx`, must route raw
PVE manager payloads through
Surfaces that render Proxmox node versions, including the Proxmox page
model at `frontend-modern/src/features/proxmox/proxmoxPageModel.ts`, must
route raw PVE manager payloads through
`frontend-modern/src/utils/proxmoxVersion.ts` rather than inlining
page-local parsing or falling back to unrelated agent OS versions.
System title metadata must apply the same identity rule: once the primary
@@ -4395,12 +4395,14 @@ The shared table chrome now allows `TableCardHeader` to expose a right-aligned
action slot, currently used by the Workloads/Proxmox metric display control.
That slot belongs to the table header band and must not reintroduce nested
cards or page-local toolbar wrappers inside `TableCard`. Proxmox host grouping
also extends the shared `NodeGroupHeader` row pattern: host metrics may align
with workload table columns, but the shared primitive owns the header/table
shell boundary rather than platform pages copying their own card headers.
Compact PVE version text in that header must come from the shared Proxmox
version formatter so raw `pve-manager/...` payloads and platform-page host
version cells stay consistent.
also extends the shared `NodeGroupHeader` row pattern, which owns the
header/table shell boundary rather than platform pages copying their own card
headers. `frontend-modern/src/components/shared/NodeGroupHeader.tsx` renders
node identity only (status dot, linked name, cluster and agent badges); in
table-row mode that identity sits in one cell spanning the table. It carries no
per-column cell renderer and no inline
version, temperature, or uptime facts: host metrics and versions belong to the
platform page's host table, so a group row must not duplicate them.
Mobile navigation now recognizes `proxmox` as a first-class platform tab in
the shared priority model so app-shell ordering remains centralized.
@@ -1955,22 +1955,20 @@ query persistence once per resource, or run on the steady-state alert hot path;
ancestor lookup remains bounded by canonical hierarchy depth for each active
alert actually evaluated.
The embedded WorkloadsSurface exposes a `compactGroupHeaders` prop on
`frontend-modern/src/components/Workloads/useWorkloadsState.ts` that
platform pages owning their own hosts table (Proxmox overview today) set
to strip per-host metric cells out of the `NodeGroupHeader` rows in
grouped mode. The flag is threaded through
`frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`,
`frontend-modern/src/components/Workloads/WorkloadsTable.tsx`, and
`frontend-modern/src/components/Workloads/WorkloadPanel.tsx`; when set,
`WorkloadPanel` calls `NodeGroupHeader` without `columns` /
`renderColumnCell` so the existing colspan layout renders just the
status dot, linked node name, cluster badge, and agent badge — the
duplicate CPU / Memory / Disk / uptime / temperature / version stats
the top-of-page hosts table already owns are dropped. New embedded
platform-page consumers that render their own hosts summary must set
this flag; standalone Workloads surfaces (no top hosts table) keep the
original verbose group rows by default.
Grouped host rows in the embedded WorkloadsSurface are identity-only
dividers. `frontend-modern/src/components/Workloads/WorkloadPanel.tsx`
renders each grouped node through `NodeGroupHeader` as one cell spanning
the table: status dot, linked node name, cluster badge, and agent badge.
Per-host CPU, memory, disk, network and disk I/O, uptime, temperature,
and version stats belong to the platform page's own hosts table (the
Proxmox nodes table today), so the workloads table must not render them
per column or inline in the group row. The former `compactGroupHeaders`
option and the per-column node metric cells it switched off were removed
because both remaining production mounts (Proxmox and vSphere overview)
set it unconditionally, so the non-compact path no longer rendered
anywhere. A new embedded consumer that needs host
stats beside its workloads must render its own hosts table rather than
re-growing metric cells in the shared group row.
WorkloadsSurface stays monitoring-first. It must not render a persistent
aggregate banner just because running VMs or system containers lack an
@@ -970,6 +970,11 @@ the page supplies it through the Workloads `tableTitle` slot and the shared
states. This heading alignment changes neither workload backup scope nor the
storage/recovery evidence carried by the adjacent Backup column and Backups
tab.
The guest table's grouped node rows are identity-only dividers. Node stats and
the node drawer stay with the Proxmox nodes table above it, so `ProxmoxPageSurface`
passes `groupNodeDrawerMode: 'disabled'` and no host-metric option to the
embedded Workloads state. Those dividers carry no backup or recovery evidence;
the per-guest Backup column remains the overview's protection signal.
The overview's node-row action selects the shared workload node scope and
reveals the guest section. The title names the selected node and reports the
filtered guest count, including zero. Activating the selected node again or
@@ -2973,7 +2973,10 @@ canonical WebSocket snapshot, including the public `vmware-vsphere` alias for
the raw `vmware` source, rather than degrading a source-scoped page to periodic
REST refreshes. VMware Overview passes that same source-scoped snapshot into
the embedded Workloads state, so hosts and VMs share one inventory generation
and one explicit refresh path.
and one explicit refresh path. Beyond disabling the grouped host drawer, it
passes no option that adds host metrics to grouped rows: the shared Workloads
group row carries host identity only, and per-host stats stay in the page's
own hosts table.
Its page-owned workload toolbar consumes the complete shared
`getWorkloadsMetricFilterProps` binding, so vSphere VMs expose the same Bars,
Trends, Details, History, range, and first-use discovery contract as every
@@ -0,0 +1,49 @@
{
"version": 1,
"base_sha": "c3adf0c7b9fa7f4283f7fd832a806659b009b51b",
"verified_at": "2026-10-06T21:45:33Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/shared/NodeGroupHeader.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "67c2c51ffceb96893f968dc141dbce54f0affe66fb389ffeeba8c8c94988968c",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "e49032445f566b4e6a4329314f4ada41ed6f7652ba52e9e3fc413047a49f7878",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "a340532f9e3e0e28d83e9a0fb369c8be973acb0dea7b321fd646345ff924db38",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "a29034c5149b7323e86bc2fe795d9b18db805b1ad32e32013fb71547256d61f5",
"frontend-modern/src/components/shared/NodeGroupHeader.tsx": "e435c1e6ab04b85a022459cf19a67645bc8339f15ef986ffcb5b42ba27ce667f",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "80c860be3bee37251c16d21044074267e48991b9cb39b87d335cb2155066ec96",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "6c74063a298a259ce5f60485174529e9bffbe7293ae0710f53dc95d6f3c11265"
},
"routes": ["/proxmox/overview", "/vmware"],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Production vite build of this tree (rebased on c3adf0c7b9) embedded in a private DEMO_MODE mock backend (50 PVE nodes, 929 guests, 7 ESXi hosts), headless Chromium via Playwright; desktop 1440x900 and phone 390x844.",
"Proxmox Overview guest table, Grouped layout: every grouped node row (8 desktop, 2 phone in the rendered window) is one td whose colspan equals the guest table's visible header count (8 desktop, 5 phone), with status dot, linked node name, cluster badge and Agent badge only; no data-workload-col cells, no PVE version, temperature or uptime text.",
"vSphere Overview VM table: the 7 host group rows render as one spanning cell (colspan 6 desktop, 5 phone) with host name and cluster badge, unchanged shape.",
"Bars and Trends metric modes both inspected: Trends adds four sparklines per guest row (Proxmox 140 to 560 images, vSphere 18 to 72) while each grouped host row keeps exactly its one status-dot image.",
"Screenshots of grouped rows in both modes and of open guest drawers inspected at both viewports; no page errors.",
"This is the shape both pages already rendered on main, where compactGroupHeaders was always set; it is not a check of host drawers, which both pages disable."
],
"interactions": [
"Opened the View popover (desktop) and the Filters popover (phone) and toggled Bars to Trends and back.",
"Clicked a grouped host row on each page: no inline node drawer row appears and nothing else changes.",
"Clicked the first guest row under a group on each page: its inline guest drawer opens.",
"Scrolled the first grouped host row to the viewport centre before each capture."
]
}
@@ -1,10 +1,6 @@
import { createMemo, For, Show } from 'solid-js';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { EnhancedCPUBar } from '@/components/Workloads/EnhancedCPUBar';
import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import {
GROUPED_TABLE_ROW_BADGE_CLASS,
getGroupedTableRowCellClass,
@@ -16,12 +12,9 @@ import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { createSummaryInteractiveRowPreviewHandlers } from '@/components/shared/summaryInteractionA11y';
import { buildSummaryDisclosureControlsId } from '@/components/shared/summaryInteractionA11y';
import { TableBody, TableCell, TableRow } from '@/components/shared/Table';
import type { Node } from '@/types/api';
import { getAlertsForResource, getAlertStyles } from '@/utils/alerts';
import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity';
import { formatSpeed, formatUptime } from '@/utils/format';
import { isNodeOnline } from '@/utils/status';
import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature';
import { getCanonicalWorkloadId, getWorkloadMetadataIdCandidates } from '@/utils/workloads';
import {
resolveSummaryGroupMemberInteractionState,
@@ -34,14 +27,12 @@ import { NodeDrawer } from './NodeDrawer';
import { buildWorkloadSummaryGroupScope } from './workloadSelectors';
import { getWorkloadGuestMetadataRecord } from './workloadGuestMetadataRecord';
import type { WorkloadsState } from './useWorkloadsState';
import type { WorkloadTableMetric } from './workloadMetricHistoryModel';
type WorkloadPanelProps = Pick<
WorkloadsState,
| 'activeAlerts'
| 'alertsEnabled'
| 'bottomSpacerHeight'
| 'compactGroupHeaders'
| 'getGroupLabel'
| 'getNodeTemperatureThresholds'
| 'groupedGuests'
@@ -58,7 +49,6 @@ type WorkloadPanelProps = Pick<
| 'focusedSummaryWorkloadGroupScope'
| 'focusedSummaryWorkloadGroupId'
| 'hoveredSummaryWorkloadGroupScope'
| 'isMobile'
| 'nestedWorkloadContextByGuestId'
| 'nodeByInstance'
| 'search'
@@ -80,289 +70,9 @@ type WorkloadPanelProps = Pick<
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
| 'workloadColumnWidths'
| 'workloadTableVisibleColumns'
>;
const GROUP_NODE_METRIC_CELL_CLASS = 'px-1.5 sm:px-2 py-0.5 align-middle';
const GROUP_NODE_NAME_CELL_CLASS = getGroupedTableRowCellClass(
'py-1! pl-2! sm:pl-3! pr-1.5! sm:pr-2!',
);
const getGroupNodeColumnCellClass = (_columnId: string, isNameColumn: boolean): string => {
if (isNameColumn) return GROUP_NODE_NAME_CELL_CLASS;
return GROUP_NODE_METRIC_CELL_CLASS;
};
const normalizeMetricPercent = (value: number | null | undefined): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
return value <= 1 ? Math.max(0, value * 100) : Math.max(0, value);
};
const formatMetricPercent = (value: number | null | undefined): string =>
`${Math.round(normalizeMetricPercent(value))}%`;
const getUsedPercent = (used?: number | null, total?: number | null): number => {
if (typeof used !== 'number' || typeof total !== 'number' || total <= 0) return 0;
return normalizeMetricPercent((used / total) * 100);
};
const getGroupNodeMetricKey = (node: Node): string => node.linkedAgentId || node.id || node.name;
const getGroupNodePveVersion = (node: Node): string => {
const version = (node.pveVersion || '').trim();
if (!version || version.toLowerCase() === 'unknown') return '';
return (
version.match(/pve-manager\/([^/\s]+)/i)?.[1] || version.match(/\d+(?:\.\d+)+/)?.[0] || version
);
};
const renderGroupNodeEmptyCell = () => (
<div class="text-center">
<span class="text-xs text-slate-400" aria-hidden="true">
—
</span>
</div>
);
export function WorkloadPanel(props: WorkloadPanelProps) {
const renderGroupNodeSparkline = (
node: Node,
metric: WorkloadTableMetric,
valueLabel: string,
title: string,
unit = '%',
valueLabelMode: 'inline' | 'tooltip' | 'hidden' = 'inline',
formatValue?: (value: number) => string,
) => (
<MetricMiniSparkline
series={props.workloadMetricHistory.getNodeMetricSeries(node, metric)}
valueLabel={valueLabel}
valueLabelMode={valueLabelMode}
title={title}
unit={unit}
formatValue={formatValue}
/>
);
const renderGroupNodeRatePair = (
leftLabel: string,
leftClass: string,
leftValue: number | null | undefined,
rightLabel: string,
rightClass: string,
rightValue: number | null | undefined,
) => {
const left = Math.max(0, leftValue ?? 0);
const right = Math.max(0, rightValue ?? 0);
if (left <= 0 && right <= 0) return renderGroupNodeEmptyCell();
return (
<div class="grid w-full min-w-0 grid-cols-[0.75rem_minmax(0,1fr)_0.75rem_minmax(0,1fr)] items-center gap-x-1 overflow-hidden text-[11px] tabular-nums">
<span class={`inline-flex w-3 justify-center ${leftClass}`}>{leftLabel}</span>
<span
class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted"
title={formatSpeed(left)}
>
{formatSpeed(left)}
</span>
<span class={`inline-flex w-3 justify-center ${rightClass}`}>{rightLabel}</span>
<span
class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted"
title={formatSpeed(right)}
>
{formatSpeed(right)}
</span>
</div>
);
};
const renderGroupNodeInfoCell = (node: Node) => {
const version = getGroupNodePveVersion(node);
const temperature = getCpuTemperature(node.temperature);
if (!version && temperature === null) return renderGroupNodeEmptyCell();
return (
<div class="flex items-center justify-center gap-2 text-[10px] font-medium text-muted">
<Show when={version}>
<span title="Proxmox VE version">PVE {version}</span>
</Show>
<Show when={temperature !== null}>
<span
class={getTemperatureTextClass(temperature, props.getNodeTemperatureThresholds(node))}
title="CPU temperature"
>
{formatTemperature(temperature)}
</span>
</Show>
</div>
);
};
const renderGroupNodeColumnCell = (columnId: string, node: Node) => {
switch (columnId) {
case 'type':
return (
<div class="flex justify-center">
<span
class="inline-flex items-center px-1 py-0.5 text-[10px] font-medium rounded-sm whitespace-nowrap bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
title="Proxmox VE node"
>
PVE
</span>
</div>
);
case 'info':
case 'vmid':
return renderGroupNodeInfoCell(node);
case 'cpu':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'cpu',
formatMetricPercent(node.cpu),
`${node.name} CPU history`,
'%',
'inline',
formatMetricPercent,
);
}
return (
<div class="h-4">
<EnhancedCPUBar
usage={normalizeMetricPercent(node.cpu)}
loadAverage={node.loadAverage?.[0]}
cores={props.isMobile() ? undefined : node.cpuInfo?.cores}
model={node.cpuInfo?.model}
resourceId={getGroupNodeMetricKey(node)}
/>
</div>
);
case 'memory':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'memory',
node.memory?.usageUnavailable
? 'N/A'
: formatMetricPercent(getUsedPercent(node.memory?.used, node.memory?.total)),
`${node.name} memory history`,
'%',
'inline',
formatMetricPercent,
);
}
return (
<div class="h-4">
<StackedMemoryBar
used={node.memory?.used || 0}
total={node.memory?.total || 0}
unavailable={node.memory?.usageUnavailable === true}
balloon={node.memory?.balloon || 0}
swapUsed={node.memory?.swapUsed || 0}
swapTotal={node.memory?.swapTotal || 0}
resourceId={getGroupNodeMetricKey(node)}
/>
</div>
);
case 'disk':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'disk',
formatMetricPercent(node.disk?.usage),
`${node.name} disk usage history`,
'%',
'inline',
formatMetricPercent,
);
}
return (
<div class="h-4">
<StackedDiskBar
aggregateDisk={{
total: node.disk?.total || 0,
used: node.disk?.used || 0,
free:
node.disk?.free ?? Math.max(0, (node.disk?.total || 0) - (node.disk?.used || 0)),
usage: node.disk?.usage || 0,
}}
/>
</div>
);
case 'uptime':
return (
<div class="flex justify-center">
<Show when={(node.uptime ?? 0) > 0} fallback={renderGroupNodeEmptyCell()}>
<span
class={`text-xs whitespace-nowrap ${
node.uptime > 0 && node.uptime < 3600 ? 'text-orange-500' : 'text-muted'
}`}
>
{formatUptime(node.uptime, props.isMobile())}
</span>
</Show>
</div>
);
case 'node':
case 'context':
return (
<div class="flex justify-center">
<span class="text-xs text-muted truncate max-w-[120px]" title={node.name}>
{node.name}
</span>
</div>
);
case 'os':
return (
<div class="flex justify-center">
<span class="text-xs text-muted">PVE</span>
</div>
);
case 'netIo':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'netIo',
`${formatSpeed(node.networkIn ?? 0)} / ${formatSpeed(node.networkOut ?? 0)}`,
`${node.name} network I/O history`,
'B/s',
'tooltip',
formatSpeed,
);
}
return renderGroupNodeRatePair(
'↓',
'text-emerald-500',
node.networkIn,
'↑',
'text-orange-400',
node.networkOut,
);
case 'diskIo':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'diskIo',
`${formatSpeed(node.diskRead ?? 0)} / ${formatSpeed(node.diskWrite ?? 0)}`,
`${node.name} disk I/O history`,
'B/s',
'tooltip',
formatSpeed,
);
}
return renderGroupNodeRatePair(
'R',
'font-mono text-blue-500',
node.diskRead,
'W',
'font-mono text-amber-500',
node.diskWrite,
);
default:
return renderGroupNodeEmptyCell();
}
};
return (
<TableBody ref={props.setTableBodyRef} class="divide-y divide-border">
<Show when={props.groupedWindowing.isWindowed() && props.topSpacerHeight() > 0}>
@@ -485,22 +195,8 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
>
<NodeGroupHeader
node={node()!}
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
renderAs="tr"
colspan={props.totalColumns()}
columns={
props.compactGroupHeaders() ? undefined : props.workloadTableVisibleColumns()
}
columnCellClass={
props.compactGroupHeaders() ? undefined : getGroupNodeColumnCellClass
}
renderColumnCell={
props.compactGroupHeaders() ? undefined : renderGroupNodeColumnCell
}
showFactsInName={
!props.compactGroupHeaders() &&
!props.workloadTableVisibleColumnIds().includes('info')
}
trClass={
canOpenNodeDrawer()
? 'cursor-pointer select-none duration-150 [&>td>div]:flex-nowrap'
@@ -192,7 +192,6 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
activeAlerts={state.activeAlerts}
alertsEnabled={state.alertsEnabled}
bottomSpacerHeight={state.bottomSpacerHeight}
compactGroupHeaders={state.compactGroupHeaders}
getGroupLabel={state.getGroupLabel}
getNodeTemperatureThresholds={state.getNodeTemperatureThresholds}
groupedGuests={state.groupedGuests}
@@ -17,7 +17,6 @@ type WorkloadsTableProps = Pick<
| 'activeAlerts'
| 'alertsEnabled'
| 'bottomSpacerHeight'
| 'compactGroupHeaders'
| 'getGroupLabel'
| 'getNodeTemperatureThresholds'
| 'groupedGuests'
@@ -133,7 +132,6 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
activeAlerts={props.activeAlerts}
alertsEnabled={props.alertsEnabled}
bottomSpacerHeight={props.bottomSpacerHeight}
compactGroupHeaders={props.compactGroupHeaders}
getGroupLabel={props.getGroupLabel}
getNodeTemperatureThresholds={props.getNodeTemperatureThresholds}
groupedGuests={props.groupedGuests}
@@ -150,7 +148,6 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
focusedSummaryWorkloadGroupScope={props.focusedSummaryWorkloadGroupScope}
focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope}
isMobile={props.isMobile}
nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId}
nodeByInstance={props.nodeByInstance}
search={props.search}
@@ -171,7 +168,6 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
workloadMetricHistory={props.workloadMetricHistory}
workloadTableLayoutMode={props.workloadTableLayoutMode}
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={props.workloadTableVisibleColumns}
workloadColumnWidths={props.workloadColumnWidths}
/>
</Table>
@@ -454,32 +454,6 @@ describe('Workloads platform-page embed contract', () => {
expect(nodesTableSource).toContain('isSparklineMode');
});
it('exposes compactGroupHeaders so platform pages can strip duplicate host stats from group rows', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain('compactGroupHeaders?: boolean;');
expect(stateSource).toContain('compactGroupHeaders: () => props.compactGroupHeaders === true,');
const tableSource = (await import('../WorkloadsTable.tsx?raw')).default;
expect(tableSource).toContain(`| 'compactGroupHeaders'`);
expect(tableSource).toContain('compactGroupHeaders={props.compactGroupHeaders}');
const panelSource = (await import('../WorkloadPanel.tsx?raw')).default;
expect(panelSource).toContain(`| 'compactGroupHeaders'`);
// When the flag is set the panel falls back to NodeGroupHeader's
// colspan layout: no per-column metric cells, no inline node facts.
expect(panelSource).toContain(
'props.compactGroupHeaders() ? undefined : props.workloadTableVisibleColumns()',
);
expect(panelSource).toContain(
'props.compactGroupHeaders() ? undefined : renderGroupNodeColumnCell',
);
expect(panelSource).toContain('!props.compactGroupHeaders() &&');
const proxmoxSource = (await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw'))
.default;
expect(proxmoxSource).toContain('compactGroupHeaders');
});
it('lets platform pages move grouped host drawers to a dedicated host table owner', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain("groupNodeDrawerMode?: 'inline' | 'disabled';");
@@ -105,12 +105,6 @@ export interface WorkloadsSurfaceProps {
}>;
resourceSnapshotRefetch?: () => Promise<unknown>;
statusModeStorageScope?: string;
// Platform pages that render their own hosts table above the embedded
// workloads surface (e.g. Proxmox overview) own the per-host CPU / Memory
// / Disk / Temperature / uptime / version stats. Setting
// `compactGroupHeaders` strips those stats from the NodeGroupHeader rows
// in grouped mode so the section dividers don't duplicate the info.
compactGroupHeaders?: boolean;
// Default Workloads behavior owns grouped host row drawers inline. Platform
// pages with a dedicated host table can disable that drawer so host details
// open from the host-owned table instead of the embedded guest table.
@@ -728,7 +722,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadsNoInventoryState,
ws,
groupingMode,
compactGroupHeaders: () => props.compactGroupHeaders === true,
groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',
groupLabelBadges,
} as const;
@@ -1,12 +1,8 @@
import { Component, For, Show, type JSX } from 'solid-js';
import { Component, Show, type JSX } from 'solid-js';
import type { Node } from '@/types/api';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { getNodeDisplayName, getNodeExternalUrl, hasAlternateDisplayName } from '@/utils/nodes';
import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator } from '@/utils/status';
import { formatUptime } from '@/utils/format';
import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature';
import { formatProxmoxVersion } from '@/utils/proxmoxVersion';
import {
GROUPED_TABLE_ROW_BADGE_CLASS,
getGroupedTableRowCellClass,
@@ -17,13 +13,8 @@ import { ResourceNameWithWebInterfaceLink } from './WebInterfaceLink';
interface NodeGroupHeaderProps {
node: Node;
colspan?: number;
columns?: Array<{ id: string }>;
columnCellClass?: (columnId: string, isNameColumn: boolean) => string;
leadingAction?: JSX.Element;
renderColumnCell?: (columnId: string, node: Node) => JSX.Element;
renderAs?: 'tr' | 'div';
showFactsInName?: boolean;
temperatureThresholds?: MetricDisplayThresholds | null;
trClass?: string;
trProps?: JSX.HTMLAttributes<HTMLTableRowElement> &
Partial<Record<`data-${string}`, string | undefined>>;
@@ -35,12 +26,6 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
const nodeUrl = () => getNodeExternalUrl(props.node);
const displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node);
const pveVersion = () => formatProxmoxVersion(props.node.pveVersion);
const cpuTemperature = () => getCpuTemperature(props.node.temperature);
const hasNodeFacts = () =>
Boolean(pveVersion()) ||
cpuTemperature() !== null ||
(typeof props.node.uptime === 'number' && props.node.uptime > 0);
const InnerContent = () => (
<div
@@ -84,25 +69,6 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
Agent
</span>
</Show>
<Show when={props.showFactsInName !== false && hasNodeFacts()}>
<span class="hidden sm:inline-flex flex-wrap items-center gap-3 text-[10px] font-medium text-muted">
<Show when={pveVersion()}>
<span title="Proxmox VE version">PVE {pveVersion()}</span>
</Show>
<Show when={cpuTemperature() !== null}>
<span
class={getTemperatureTextClass(cpuTemperature(), props.temperatureThresholds)}
title="CPU temperature"
>
{formatTemperature(cpuTemperature())}
</span>
</Show>
<Show when={typeof props.node.uptime === 'number' && props.node.uptime > 0}>
<span title="Node uptime">{formatUptime(props.node.uptime, true)}</span>
</Show>
</span>
</Show>
</div>
);
@@ -118,38 +84,9 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
}
>
<tr class={getGroupedTableRowClass(props.trClass)} {...props.trProps}>
<Show
when={(props.columns?.length ?? 0) > 0 && Boolean(props.renderColumnCell)}
fallback={
<td colspan={props.colspan} class={getGroupedTableRowCellClass()}>
<InnerContent />
</td>
}
>
<For each={props.columns}>
{(column) => {
const isNameColumn = () => column.id === 'name';
return (
<td
data-workload-col={column.id}
class={
props.columnCellClass?.(column.id, isNameColumn()) ??
(isNameColumn()
? getGroupedTableRowCellClass()
: 'px-1.5 sm:px-2 py-0.5 align-middle')
}
>
<Show
when={isNameColumn()}
fallback={props.renderColumnCell?.(column.id, props.node)}
>
<InnerContent />
</Show>
</td>
);
}}
</For>
</Show>
<td colspan={props.colspan} class={getGroupedTableRowCellClass()}>
<InnerContent />
</td>
</tr>
</Show>
);
@@ -216,6 +216,7 @@ import proxmoxCoverageTableSource from '@/features/proxmox/ProxmoxCoverageTable.
import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
import proxmoxHostTableModelSource from '@/features/proxmox/proxmoxHostTableModel.ts?raw';
import proxmoxPageModelSource from '@/features/proxmox/proxmoxPageModel.ts?raw';
import proxmoxRecoverableTableSource from '@/features/proxmox/ProxmoxRecoverableTable.tsx?raw';
import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplicationTable.tsx?raw';
import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw';
@@ -1406,9 +1407,9 @@ describe('shared primitive guardrails', () => {
});
it('routes Proxmox node version presentation through the shared formatter', () => {
expect(nodeGroupHeaderSource).toContain("from '@/utils/proxmoxVersion'");
expect(nodeGroupHeaderSource).toContain('formatProxmoxVersion(props.node.pveVersion)');
expect(nodeGroupHeaderSource).not.toContain('pve-manager\\/');
expect(proxmoxPageModelSource).toContain("from '@/utils/proxmoxVersion'");
expect(proxmoxPageModelSource).toContain('formatProxmoxVersion(resource.proxmox?.pveVersion)');
expect(proxmoxPageModelSource).not.toContain('pve-manager\\/');
expect(proxmoxVersionSource).toContain('formatProxmoxVersion');
expect(proxmoxVersionSource).toContain('pve-manager\\/');
@@ -1,9 +1,8 @@
import { render, screen } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import type { Node } from '@/types/api';
import { temperatureStore } from '@/utils/temperature';
const makeNode = (overrides: Partial<Node> = {}): Node => ({
id: 'cluster-a-pve1',
@@ -34,18 +33,11 @@ const makeNode = (overrides: Partial<Node> = {}): Node => ({
});
describe('NodeGroupHeader', () => {
beforeEach(() => {
temperatureStore.setUnit('celsius');
});
it('renders compact infrastructure facts in the grouped workload header', () => {
it('renders node identity in the grouped workload header', () => {
render(() => <NodeGroupHeader node={makeNode()} />);
expect(screen.getByText('pve1')).toBeInTheDocument();
expect(screen.getByText('homelab')).toBeInTheDocument();
expect(screen.getByText('PVE 9.1.9')).toBeInTheDocument();
expect(screen.getByText('62°C')).toBeInTheDocument();
expect(screen.getByText('2h')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Open web interface for pve1' });
expect(link).toHaveAttribute('href', 'https://pve1:8006');
@@ -53,20 +45,21 @@ describe('NodeGroupHeader', () => {
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('colors grouped header temperatures from configured thresholds', () => {
render(() => (
<NodeGroupHeader
node={makeNode({
temperature: {
available: true,
cpuPackage: 76,
lastUpdate: '2026-01-01T00:00:00Z',
},
})}
temperatureThresholds={{ warning: 80, critical: 85 }}
/>
it('spans the table in one cell and leaves host stats to the platform host table', () => {
const { container } = render(() => (
<table>
<tbody>
<NodeGroupHeader node={makeNode()} renderAs="tr" colspan={9} />
</tbody>
</table>
));
expect(screen.getByText('76°C')).toHaveClass('text-green-600');
const cells = container.querySelectorAll('tr > td');
expect(cells).toHaveLength(1);
expect(cells[0]).toHaveAttribute('colspan', '9');
expect(cells[0]).toHaveTextContent('pve1');
expect(screen.queryByText('PVE 9.1.9')).not.toBeInTheDocument();
expect(screen.queryByText(/°[CF]/)).not.toBeInTheDocument();
expect(screen.queryByText('2h')).not.toBeInTheDocument();
});
});
@@ -495,7 +495,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
// surface, so label it with the platform vocabulary instead of 'Info'.
columnLabelOverrides: { info: 'ID' },
statusModeStorageScope: PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE,
compactGroupHeaders: true,
groupNodeDrawerMode: 'disabled',
metricDisplayMode: props.metricDisplayMode,
onMetricDisplayModeChange: props.setMetricDisplayMode,
@@ -638,7 +637,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
forcedPlatform={PROXMOX_PLATFORM_FILTER}
excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}
showNestedExcludedWorkloads
compactGroupHeaders
groupNodeDrawerMode="disabled"
suppressFilterToolbar
emptyStateTitle="No Proxmox workloads"
@@ -214,6 +214,16 @@ describe('ProxmoxPageSurface contract', () => {
);
});
it('leaves host details to the nodes table instead of the embedded guest table', () => {
setResources([makeResource({ id: 'vm-1', type: 'vm' })]);
renderSurface();
expect(mockWorkloadsOptions).toHaveBeenCalledWith(
expect.objectContaining({ groupNodeDrawerMode: 'disabled' }),
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -474,7 +474,6 @@ function VmwareOverview(props: VmwareOverviewProps) {
statusModeStorageScope: VMWARE_WORKLOAD_STATUS_STORAGE_SCOPE,
columnVisibilityStorageScope: VMWARE_WORKLOAD_COLUMN_VISIBILITY_SCOPE,
additionalDefaultHiddenColumnIds: [...VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS],
compactGroupHeaders: true,
inventorySourcesQuery: props.inventorySourcesQuery,
groupNodeDrawerMode: 'disabled',
metricDisplayMode: props.metricDisplayMode,
@@ -545,7 +544,6 @@ function VmwareOverview(props: VmwareOverviewProps) {
useWorkloads
forcedPlatform={VMWARE_PLATFORM_FILTER}
forcedViewMode="vm"
compactGroupHeaders
groupNodeDrawerMode="disabled"
suppressFilterToolbar
emptyStateTitle="No vSphere VMs"
@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest';
import nodeDrawerOverviewSource from '@/components/Workloads/NodeDrawerOverview.tsx?raw';
import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
import nodeGroupHeaderSource from '@/components/shared/NodeGroupHeader.tsx?raw';
import temperatureGaugeSource from '@/components/shared/TemperatureGauge.tsx?raw';
import dockerHostDrawerOverviewSource from '@/features/docker/DockerHostDrawerOverview.tsx?raw';
import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw';
describe('temperature display guardrails', () => {
it('keeps colored temperature displays threshold-aware', () => {
expect(nodeGroupHeaderSource).toContain('temperatureThresholds');
expect(workloadPanelSource).toContain('getNodeTemperatureThresholds(node)');
expect(workloadPanelSource).toContain(
'temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}',
);
expect(nodeDrawerOverviewSource).toContain('getTemperatureTextClass(value, thresholds)');
expect(dockerHostDrawerOverviewSource).toContain('temperatureThresholds()');
expect(agentsMachinesTableSource).toContain('getAgentMachineTemperatureMetric');
@@ -19,7 +19,6 @@ describe('temperature display guardrails', () => {
});
it('does not reintroduce the previous local temperature severity calls', () => {
expect(nodeGroupHeaderSource).not.toContain('getTemperatureTextClass(cpuTemperature())');
expect(workloadPanelSource).not.toContain('getTemperatureTextClass(temperature)}');
expect(nodeDrawerOverviewSource).not.toContain('getTemperatureTextClass(value)');
expect(dockerHostDrawerOverviewSource).not.toContain('getTemperatureTextClass(temperature)');