Improve Proxmox node search coverage

Contract-Neutral: Behavioral search fix; no public subsystem contract delta
This commit is contained in:
rcourtman
2026-08-23 17:01:49 +01:00
parent d8fe2a9358
commit dc129cd3a4
7 changed files with 672 additions and 489 deletions
+20 -29
View File
@@ -1,23 +1,17 @@
{
"version": 1,
"base_sha": "4040a79f4f5ffc465131fb75c31146d93e45c8d5",
"verified_at": "2026-08-23T15:31:26Z",
"base_sha": "d8fe2a935875675b22a87c5471ffc923f7a0ab51",
"verified_at": "2026-08-23T16:50:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts",
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts"
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "eb8ddd4c5f03efa629ba480fa9cbe4bbd8293834e6d0e6bf05e3fbb81d247edf",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "765e4185fea10dd2e6da0c9e3de53ee53293ae6e1e0e19d6171fb01748fa5b8d",
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts": "792a66e9f01fa1ab731413527255c631ccd20bc26bcef24a74208f1e61f76d58",
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts": "b12b4a1df25c88a5468e867aa2b58f10a452d65be846dcfd1bcaa4942e506a96",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts": "ecfc505a901c65b7ed8e7d17d828961d1409c948c6f78bf2528ea907cf484343",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "d67a6b5d5f2de1ff3c0fe100652cf2e3d8ed2f74cda767a1bdb7462dea243b28"
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "5b2dce1ff4cae9624fa106ad9c01f29e0025b33e0def87cee30798e74fe31329",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "27d2077c659d40e2c51472086e745311221d771129455a4bae2be816ab116dbe",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "f80b8e01fd31e7f2de55cda5a2f5e58005068b07daa77c3356811ad7f5919051"
},
"routes": [
"/proxmox/overview"
@@ -28,25 +22,22 @@
"height": 720
},
{
"width": 375,
"height": 812
"width": 390,
"height": 844
}
],
"states": [
"The Proxmox overview renders the large local mock estate with grouped workload virtualization active",
"At 1280x720 the app scroll height remains exactly 29470 pixels at scroll positions 0, 1800, and 5000",
"At 375x812 the app scroll height remains exactly 36372 pixels at scroll positions 0, 2800, and 7000",
"All mounted phone guest rows measure 37 pixels and grouped node headers remain a single 28-pixel line",
"Back to top is absent near the top and appears after the 640-pixel threshold",
"The phone control is a 44-pixel fixed button positioned above the bottom navigation",
"The verified desktop and phone journeys reported no browser console errors"
"Default desktop Proxmox overview renders the 50-node mock estate with 8 clusters and 2 standalone nodes",
"Desktop search for pve31 renders one matching node as 1 of 50 and keeps the matching guest inventory visible",
"Narrow search for pve31 renders one matching node with the native pve31 identifier beside its friendly name",
"Narrow filtered layout has no horizontal overflow and retains the responsive node and workload columns",
"The browser verification run reported no console errors"
],
"interactions": [
"Scrolled repeatedly through the desktop mock estate and compared the app-owned scroll shell height at multiple virtual windows",
"Activated Back to top on desktop and confirmed the smooth scroll completed at zero and the control disappeared",
"Applied a temporary 375x812 viewport override and repeated the multi-position scroll-height measurements",
"Captured and visually inspected the phone viewport with the control above the mobile navigation",
"Activated Back to top on phone and confirmed the app scroll shell returned to zero while retaining the same total height",
"Reset the temporary viewport override and closed the temporary test tab after verification"
"Loaded the Proxmox overview and inspected the default 50-node desktop table",
"Entered pve31 in the shared workload search and verified the node table reduced to 1 of 50",
"Verified the same query left the matching Analytics A host and its pve31 workload rows visible",
"Applied a temporary 390x844 viewport override, reloaded the overview, entered pve31, and verified the narrow DOM and screenshot",
"Reset the temporary viewport override after verification"
]
}
@@ -54,6 +54,7 @@ import type { Resource } from '@/types/resource';
import { nodeFromResource } from '@/utils/resourceStateAdapters';
import {
getResourceClusterLabel,
filterProxmoxNodes,
getResourceVersion,
isProxmoxChildOfNode,
} from './proxmoxPageModel';
@@ -192,6 +193,7 @@ const getHostSortValue = (
export const ProxmoxNodesTable: Component<{
nodes: Resource[];
guests: Resource[];
search?: Accessor<string>;
metricDisplayMode?: Accessor<WorkloadsMetricDisplayMode>;
metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;
layoutWidth?: Accessor<number | null | undefined>;
@@ -217,13 +219,16 @@ export const ProxmoxNodesTable: Component<{
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
const isSparklineMode = () => displayMode() === 'sparklines';
const filteredNodes = createMemo(() =>
filterProxmoxNodes(props.nodes, props.guests, props.search?.() ?? ''),
);
const sort = createPlatformTableSortState({
storageKey: 'proxmoxNodes',
sortKeys: PROXMOX_HOST_SORT_KEYS,
descendingFirst: ['uptime', 'cpu', 'memory', 'disk', 'temp', 'vms', 'cts'],
});
const sortedNodes = createMemo(() =>
sort.sortRows(props.nodes, (node, key) => getHostSortValue(node, props.guests, key)),
sort.sortRows(filteredNodes(), (node, key) => getHostSortValue(node, props.guests, key)),
);
const previewLimit = createMemo(() =>
layoutMode() === 'narrow' || layoutMode() === 'phone' || layoutMode() === 'mobile' ? 4 : 8,
@@ -253,483 +258,504 @@ export const ProxmoxNodesTable: Component<{
/>
}
>
<PlatformTableShell
title={
<span class="inline-flex items-center gap-1.5">
Nodes
<Show when={inventoryCountsVisible()}>
<span class="font-semibold tabular-nums text-base-content">
{formatPlatformTableIntegerValue(props.nodes.length)}
</span>
</Show>
</span>
<Show
when={filteredNodes().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No matching Proxmox VE nodes"
description="Adjust the search to see nodes or guests in this Proxmox estate."
/>
}
actions={
<span class="inline-flex items-center gap-2">
<Show
when={
inventoryCountsVisible() &&
props.topology &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
</Show>
<PlatformTablePreviewToggle
expanded={nodePreview.expanded()}
canExpand={nodePreview.canExpand()}
total={props.nodes.length}
noun="nodes"
showCount={inventoryCountsVisible()}
onToggle={toggleNodePreview}
/>
</span>
}
cardClass="proxmox-nodes-card"
tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`}
colgroup={
<colgroup>
>
<PlatformTableShell
title={
<span class="inline-flex items-center gap-1.5">
Nodes
<Show when={inventoryCountsVisible()}>
<span class="font-semibold tabular-nums text-base-content">
{formatPlatformTableIntegerValue(filteredNodes().length)}
<Show when={filteredNodes().length !== props.nodes.length}>
<span class="ml-1 text-xs font-medium text-muted">
of {formatPlatformTableIntegerValue(props.nodes.length)}
</span>
</Show>
</span>
</Show>
</span>
}
actions={
<span class="inline-flex items-center gap-2">
<Show
when={
inventoryCountsVisible() &&
props.topology &&
!props.search?.()?.trim() &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
</Show>
<PlatformTablePreviewToggle
expanded={nodePreview.expanded()}
canExpand={nodePreview.canExpand()}
total={filteredNodes().length}
noun="nodes"
showCount={inventoryCountsVisible()}
onToggle={toggleNodePreview}
/>
</span>
}
cardClass="proxmox-nodes-card"
tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`}
colgroup={
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col
style={getProxmoxHostColumnWidthStyle(
column.id,
layoutMode(),
visibleColumnIds(),
)}
/>
)}
</For>
</colgroup>
}
header={
<For each={visibleColumns()}>
{(column) => (
<col
style={getProxmoxHostColumnWidthStyle(
column.id,
layoutMode(),
visibleColumnIds(),
)}
/>
<PlatformSortableTableHead kind={column.kind} sort={sort} sortKey={column.id}>
{(layoutMode() === 'narrow' ||
layoutMode() === 'phone' ||
layoutMode() === 'mobile') &&
column.id === 'memory'
? 'Mem'
: (layoutMode() === 'narrow' ||
layoutMode() === 'phone' ||
layoutMode() === 'mobile') &&
column.id === 'uptime'
? 'Age'
: column.label}
</PlatformSortableTableHead>
)}
</For>
</colgroup>
}
header={
<For each={visibleColumns()}>
{(column) => (
<PlatformSortableTableHead kind={column.kind} sort={sort} sortKey={column.id}>
{(layoutMode() === 'narrow' ||
}
body={
<For each={nodePreview.visibleRows()}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? '';
const visibleNodeLabel = () =>
layoutMode() === 'narrow' && nativeNodeName().length > 0
? nativeNodeName()
: name();
const showNativeNodeName = () =>
layoutMode() === 'phone' &&
nativeNodeName().length > 0 &&
nativeNodeName() !== name();
const drawerNode = createMemo(() => nodeFromResource(node));
const detailRowId = () => `proxmox-host-drawer-${node.id}`;
const isSelected = () => selectedNodeId() === node.id;
const toggleNodeDrawer = () =>
setSelectedNodeId((current) => (current === node.id ? null : node.id));
const handleActivationKey: JSX.EventHandler<HTMLTableRowElement, KeyboardEvent> = (
event,
) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleNodeDrawer();
};
const version = () => asTrimmedString(getResourceVersion(node));
const cluster = () => getResourceClusterLabel(node);
const counts = () => countGuestsForNode(props.guests, node);
const indicator = () => getSimpleStatusIndicator(node.status);
const connectionHealth = createMemo(() =>
(asTrimmedString(node.proxmox?.connectionHealth) ?? '').toLowerCase(),
);
const availabilityLabel = createMemo(() => {
if (node.status === 'offline') return 'Offline';
// A linked Pulse agent can still prove that a host is online
// while the Proxmox provider path is unavailable. In that
// hybrid case the provider metrics are stale, not evidence
// that the host itself is powered off.
if (connectionHealth() === 'error' && node.status === 'online') return 'Stale';
if (connectionHealth() === 'error') return 'Offline';
if (
node.status === 'unknown' ||
connectionHealth() === 'degraded' ||
connectionHealth() === 'stale'
) {
return 'Stale';
}
if (node.status === 'degraded' || node.status === 'warning') return 'Degraded';
return 'Online';
});
const isOnline = createMemo(() => availabilityLabel() === 'Online');
const usesCompactMetrics = () =>
layoutMode() === 'narrow' ||
layoutMode() === 'phone' ||
layoutMode() === 'mobile') &&
column.id === 'memory'
? 'Mem'
: (layoutMode() === 'narrow' ||
layoutMode() === 'phone' ||
layoutMode() === 'mobile') &&
column.id === 'uptime'
? 'Age'
: column.label}
</PlatformSortableTableHead>
)}
</For>
}
body={
<For each={nodePreview.visibleRows()}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? '';
const visibleNodeLabel = () =>
layoutMode() === 'narrow' && nativeNodeName().length > 0
? nativeNodeName()
: name();
const showNativeNodeName = () =>
layoutMode() === 'phone' &&
nativeNodeName().length > 0 &&
nativeNodeName() !== name();
const drawerNode = createMemo(() => nodeFromResource(node));
const detailRowId = () => `proxmox-host-drawer-${node.id}`;
const isSelected = () => selectedNodeId() === node.id;
const toggleNodeDrawer = () =>
setSelectedNodeId((current) => (current === node.id ? null : node.id));
const handleActivationKey: JSX.EventHandler<HTMLTableRowElement, KeyboardEvent> = (
event,
) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleNodeDrawer();
};
const version = () => asTrimmedString(getResourceVersion(node));
const cluster = () => getResourceClusterLabel(node);
const counts = () => countGuestsForNode(props.guests, node);
const indicator = () => getSimpleStatusIndicator(node.status);
const connectionHealth = createMemo(() =>
(asTrimmedString(node.proxmox?.connectionHealth) ?? '').toLowerCase(),
);
const availabilityLabel = createMemo(() => {
if (node.status === 'offline') return 'Offline';
// A linked Pulse agent can still prove that a host is online
// while the Proxmox provider path is unavailable. In that
// hybrid case the provider metrics are stale, not evidence
// that the host itself is powered off.
if (connectionHealth() === 'error' && node.status === 'online') return 'Stale';
if (connectionHealth() === 'error') return 'Offline';
if (
node.status === 'unknown' ||
connectionHealth() === 'degraded' ||
connectionHealth() === 'stale'
) {
return 'Stale';
}
if (node.status === 'degraded' || node.status === 'warning') return 'Degraded';
return 'Online';
});
const isOnline = createMemo(() => availabilityLabel() === 'Online');
const usesCompactMetrics = () =>
layoutMode() === 'narrow' || layoutMode() === 'phone' || layoutMode() === 'mobile';
const usesCondensedIdentity = () =>
layoutMode() === 'narrow' || layoutMode() === 'phone';
const uptime = () =>
formatNodeUptime(isOnline() ? node.uptime : undefined, usesCompactMetrics());
const fullUptime = () => formatNodeUptime(isOnline() ? node.uptime : undefined).label;
const metricsKey = () => buildMetricKeyForUnifiedResource(node);
const temperature = () => node.temperature;
const alertResourceIds = () => hostOverrideIdCandidates(node);
const temperatureThresholds = () =>
alertsActivation.getMetricThresholds('node', 'temperature', alertResourceIds());
const cpuThresholds = () =>
alertsActivation.getMetricThresholds('node', 'cpu', alertResourceIds());
const memoryThresholds = () =>
alertsActivation.getMetricThresholds('node', 'memory', alertResourceIds());
const diskThresholds = () =>
alertsActivation.getMetricThresholds('node', 'disk', alertResourceIds());
const cpuPercent = () => getPlatformTableFiniteMetric(node.cpu?.current) ?? 0;
const memoryUsed = () => getPlatformTableFiniteMetric(node.memory?.used) ?? 0;
const memoryTotal = () => getPlatformTableFiniteMetric(node.memory?.total) ?? 0;
const memoryPercent = () =>
memoryTotal() > 0
? (memoryUsed() / memoryTotal()) * 100
: (getPlatformTableFiniteMetric(node.memory?.current) ?? 0);
const memoryPercentOnly = () =>
!memoryTotal() && getPlatformTableFiniteMetric(node.memory?.current) !== undefined
? getPlatformTableFiniteMetric(node.memory?.current)
: undefined;
const diskPercent = () => getPlatformTableFiniteMetric(node.disk?.current) ?? 0;
const aggregateDisk = (): Disk | undefined => {
if (!node.disk) return undefined;
const total = getPlatformTableFiniteMetric(node.disk.total) ?? 0;
const used = getPlatformTableFiniteMetric(node.disk.used) ?? 0;
const free = getPlatformTableFiniteMetric(node.disk.free) ?? 0;
const usage = getPlatformTableFiniteMetric(node.disk.current) ?? 0;
return { total, used, free, usage } as Disk;
};
const legacyNode = () => projectResourceToLegacyNode(node);
const externalUrl = () => {
const shimmed = drawerNode();
return shimmed ? getNodeExternalUrl(shimmed) : '';
};
const pendingUpdates = () => drawerNode()?.pendingUpdates ?? 0;
const alertStyles = createMemo(() =>
getAlertStyles(node.id, activeAlerts, alertsEnabled(), node.name),
);
const alertAccentTone = createMemo<
'critical' | 'warning' | 'acknowledged' | undefined
>(() => {
const styles = alertStyles();
if (styles.hasUnacknowledgedAlert) {
return styles.severity === 'critical' ? 'critical' : 'warning';
}
return styles.hasAcknowledgedOnlyAlert ? 'acknowledged' : undefined;
});
const rowAlertBg = () => {
const styles = alertStyles();
if (!styles.hasUnacknowledgedAlert) return '';
return styles.severity === 'critical'
? 'bg-red-50 dark:bg-red-950'
: 'bg-yellow-50 dark:bg-yellow-950';
};
const cpuSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'cpu');
const memorySeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'memory');
const diskSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'disk');
const renderColumnCell = (column: ProxmoxHostTableColumn): JSX.Element => {
switch (column.id) {
case 'node':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<div
class={`flex min-w-0 items-center ${usesCondensedIdentity() ? 'gap-1' : 'gap-2'}`}
>
<PlatformResourceDetailToggleButton
expanded={isSelected()}
resourceLabel={name()}
controlsId={detailRowId()}
onToggle={toggleNodeDrawer}
/>
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<div class="flex min-w-0 flex-1 flex-col">
<ResourceNameWithWebInterfaceLink
name={name()}
url={externalUrl()}
class={`min-w-0 ${usesCondensedIdentity() ? '[&>a]:hidden' : ''}`}
nameClass="truncate font-semibold text-base-content"
title={`Open ${name()} web interface`}
>
{visibleNodeLabel()}
</ResourceNameWithWebInterfaceLink>
<Show when={showNativeNodeName()}>
<span class="-mt-0.5 truncate text-[10px] text-muted">
{nativeNodeName()}
layoutMode() === 'mobile';
const usesCondensedIdentity = () =>
layoutMode() === 'narrow' || layoutMode() === 'phone';
const uptime = () =>
formatNodeUptime(isOnline() ? node.uptime : undefined, usesCompactMetrics());
const fullUptime = () =>
formatNodeUptime(isOnline() ? node.uptime : undefined).label;
const metricsKey = () => buildMetricKeyForUnifiedResource(node);
const temperature = () => node.temperature;
const alertResourceIds = () => hostOverrideIdCandidates(node);
const temperatureThresholds = () =>
alertsActivation.getMetricThresholds('node', 'temperature', alertResourceIds());
const cpuThresholds = () =>
alertsActivation.getMetricThresholds('node', 'cpu', alertResourceIds());
const memoryThresholds = () =>
alertsActivation.getMetricThresholds('node', 'memory', alertResourceIds());
const diskThresholds = () =>
alertsActivation.getMetricThresholds('node', 'disk', alertResourceIds());
const cpuPercent = () => getPlatformTableFiniteMetric(node.cpu?.current) ?? 0;
const memoryUsed = () => getPlatformTableFiniteMetric(node.memory?.used) ?? 0;
const memoryTotal = () => getPlatformTableFiniteMetric(node.memory?.total) ?? 0;
const memoryPercent = () =>
memoryTotal() > 0
? (memoryUsed() / memoryTotal()) * 100
: (getPlatformTableFiniteMetric(node.memory?.current) ?? 0);
const memoryPercentOnly = () =>
!memoryTotal() && getPlatformTableFiniteMetric(node.memory?.current) !== undefined
? getPlatformTableFiniteMetric(node.memory?.current)
: undefined;
const diskPercent = () => getPlatformTableFiniteMetric(node.disk?.current) ?? 0;
const aggregateDisk = (): Disk | undefined => {
if (!node.disk) return undefined;
const total = getPlatformTableFiniteMetric(node.disk.total) ?? 0;
const used = getPlatformTableFiniteMetric(node.disk.used) ?? 0;
const free = getPlatformTableFiniteMetric(node.disk.free) ?? 0;
const usage = getPlatformTableFiniteMetric(node.disk.current) ?? 0;
return { total, used, free, usage } as Disk;
};
const legacyNode = () => projectResourceToLegacyNode(node);
const externalUrl = () => {
const shimmed = drawerNode();
return shimmed ? getNodeExternalUrl(shimmed) : '';
};
const pendingUpdates = () => drawerNode()?.pendingUpdates ?? 0;
const alertStyles = createMemo(() =>
getAlertStyles(node.id, activeAlerts, alertsEnabled(), node.name),
);
const alertAccentTone = createMemo<
'critical' | 'warning' | 'acknowledged' | undefined
>(() => {
const styles = alertStyles();
if (styles.hasUnacknowledgedAlert) {
return styles.severity === 'critical' ? 'critical' : 'warning';
}
return styles.hasAcknowledgedOnlyAlert ? 'acknowledged' : undefined;
});
const rowAlertBg = () => {
const styles = alertStyles();
if (!styles.hasUnacknowledgedAlert) return '';
return styles.severity === 'critical'
? 'bg-red-50 dark:bg-red-950'
: 'bg-yellow-50 dark:bg-yellow-950';
};
const cpuSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'cpu');
const memorySeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'memory');
const diskSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'disk');
const renderColumnCell = (column: ProxmoxHostTableColumn): JSX.Element => {
switch (column.id) {
case 'node':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<div
class={`flex min-w-0 items-center ${usesCondensedIdentity() ? 'gap-1' : 'gap-2'}`}
>
<PlatformResourceDetailToggleButton
expanded={isSelected()}
resourceLabel={name()}
controlsId={detailRowId()}
onToggle={toggleNodeDrawer}
/>
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<div class="flex min-w-0 flex-1 flex-col">
<ResourceNameWithWebInterfaceLink
name={name()}
url={externalUrl()}
class={`min-w-0 ${usesCondensedIdentity() ? '[&>a]:hidden' : ''}`}
nameClass="truncate font-semibold text-base-content"
title={`Open ${name()} web interface`}
>
{visibleNodeLabel()}
</ResourceNameWithWebInterfaceLink>
<Show when={showNativeNodeName()}>
<span class="-mt-0.5 truncate text-[10px] text-muted">
{nativeNodeName()}
</span>
</Show>
</div>
<Show when={!isOnline()}>
<span class="hidden rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted sm:inline-flex">
{availabilityLabel()}
</span>
</Show>
<Show when={isOnline() && pendingUpdates() > 0}>
<span
class={`hidden rounded px-1 py-0 text-[9px] font-medium whitespace-nowrap sm:inline-flex ${
pendingUpdates() >= 10
? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-400'
}`}
title={`${pendingUpdates()} pending apt update${pendingUpdates() !== 1 ? 's' : ''}`}
>
{pendingUpdates()} updates
</span>
</Show>
</div>
<Show when={!isOnline()}>
<span class="hidden rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted sm:inline-flex">
{availabilityLabel()}
</TableCell>
);
case 'version':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show when={version()} fallback={<span class="text-muted"></span>}>
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 font-mono text-[10px] text-base-content">
{version()}
</span>
</Show>
<Show when={isOnline() && pendingUpdates() > 0}>
<span
class={`hidden rounded px-1 py-0 text-[9px] font-medium whitespace-nowrap sm:inline-flex ${
pendingUpdates() >= 10
? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-400'
}`}
title={`${pendingUpdates()} pending apt update${pendingUpdates() !== 1 ? 's' : ''}`}
>
{pendingUpdates()} updates
</span>
</Show>
</div>
</TableCell>
);
case 'version':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show when={version()} fallback={<span class="text-muted"></span>}>
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 font-mono text-[10px] text-base-content">
{version()}
</span>
</Show>
</TableCell>
);
case 'uptime':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} tabular-nums ${
uptime().warn
? 'text-orange-600 dark:text-orange-400'
: 'text-base-content'
}`}
title={usesCompactMetrics() ? fullUptime() : undefined}
>
{uptime().label}
</TableCell>
);
case 'cpu':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show when={isOnline()} fallback={<PlatformTableMetricFallback />}>
<Show
when={isSparklineMode()}
fallback={
<ResponsiveMetricCell
class="w-full"
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
isRunning
showMobile={false}
thresholds={cpuThresholds()}
/>
}
>
<MetricMiniSparkline
series={cpuSeries()}
valueLabel={formatPlatformTablePercentValue(cpuPercent(), {
normalizeRatio: true,
clamp: true,
})}
title={`${name()} CPU history`}
/>
</Show>
</Show>
</TableCell>
);
case 'memory':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show
when={isOnline() && (memoryTotal() > 0 || memoryPercentOnly() != null)}
fallback={<PlatformTableMetricFallback />}
</TableCell>
);
case 'uptime':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} tabular-nums ${
uptime().warn
? 'text-orange-600 dark:text-orange-400'
: 'text-base-content'
}`}
title={usesCompactMetrics() ? fullUptime() : undefined}
>
<Show
when={isSparklineMode()}
fallback={
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
unavailable={drawerNode()?.memory?.usageUnavailable === true}
percentOnly={memoryPercentOnly()}
cache={drawerNode()?.memory?.cache || 0}
cacheInclusiveLabel="Shown in Proxmox"
swapUsed={drawerNode()?.memory?.swapUsed || 0}
swapTotal={drawerNode()?.memory?.swapTotal || 0}
thresholds={memoryThresholds()}
/>
}
>
<MetricMiniSparkline
series={memorySeries()}
valueLabel={
drawerNode()?.memory?.usageUnavailable
? 'N/A'
: formatPlatformTablePercentValue(memoryPercent(), {
normalizeRatio: true,
clamp: true,
})
{uptime().label}
</TableCell>
);
case 'cpu':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show when={isOnline()} fallback={<PlatformTableMetricFallback />}>
<Show
when={isSparklineMode()}
fallback={
<ResponsiveMetricCell
class="w-full"
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
isRunning
showMobile={false}
thresholds={cpuThresholds()}
/>
}
title={`${name()} memory history`}
/>
</Show>
</Show>
</TableCell>
);
case 'disk':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show
when={isOnline() && (aggregateDisk() || node.agent?.disks?.length)}
fallback={<PlatformTableMetricFallback />}
>
<Show
when={isSparklineMode()}
fallback={
<StackedDiskBar
mode={
usesCompactMetrics()
? 'aggregate'
: (node.agent?.disks?.length ?? 0) > 1
? 'vertical-bars'
: undefined
}
disks={normalizeDiskArray(node.agent?.disks)}
aggregateDisk={aggregateDisk()}
thresholds={diskThresholds()}
>
<MetricMiniSparkline
series={cpuSeries()}
valueLabel={formatPlatformTablePercentValue(cpuPercent(), {
normalizeRatio: true,
clamp: true,
})}
title={`${name()} CPU history`}
/>
}
</Show>
</Show>
</TableCell>
);
case 'memory':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show
when={isOnline() && (memoryTotal() > 0 || memoryPercentOnly() != null)}
fallback={<PlatformTableMetricFallback />}
>
<MetricMiniSparkline
series={diskSeries()}
valueLabel={formatPlatformTablePercentValue(diskPercent(), {
normalizeRatio: true,
clamp: true,
})}
title={`${name()} disk history`}
<Show
when={isSparklineMode()}
fallback={
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
unavailable={drawerNode()?.memory?.usageUnavailable === true}
percentOnly={memoryPercentOnly()}
cache={drawerNode()?.memory?.cache || 0}
cacheInclusiveLabel="Shown in Proxmox"
swapUsed={drawerNode()?.memory?.swapUsed || 0}
swapTotal={drawerNode()?.memory?.swapTotal || 0}
thresholds={memoryThresholds()}
/>
}
>
<MetricMiniSparkline
series={memorySeries()}
valueLabel={
drawerNode()?.memory?.usageUnavailable
? 'N/A'
: formatPlatformTablePercentValue(memoryPercent(), {
normalizeRatio: true,
clamp: true,
})
}
title={`${name()} memory history`}
/>
</Show>
</Show>
</TableCell>
);
case 'disk':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show
when={isOnline() && (aggregateDisk() || node.agent?.disks?.length)}
fallback={<PlatformTableMetricFallback />}
>
<Show
when={isSparklineMode()}
fallback={
<StackedDiskBar
mode={
usesCompactMetrics()
? 'aggregate'
: (node.agent?.disks?.length ?? 0) > 1
? 'vertical-bars'
: undefined
}
disks={normalizeDiskArray(node.agent?.disks)}
aggregateDisk={aggregateDisk()}
thresholds={diskThresholds()}
/>
}
>
<MetricMiniSparkline
series={diskSeries()}
valueLabel={formatPlatformTablePercentValue(diskPercent(), {
normalizeRatio: true,
clamp: true,
})}
title={`${name()} disk history`}
/>
</Show>
</Show>
</TableCell>
);
case 'temp':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show
when={
isOnline() &&
typeof temperature() === 'number' &&
(temperature() as number) > 0
}
fallback={<span class="text-xs text-muted"></span>}
>
<TemperatureGauge
value={temperature() as number}
thresholds={temperatureThresholds()}
/>
</Show>
</Show>
</TableCell>
);
case 'temp':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show
when={
isOnline() &&
typeof temperature() === 'number' &&
(temperature() as number) > 0
}
fallback={<span class="text-xs text-muted"></span>}
>
<TemperatureGauge
value={temperature() as number}
thresholds={temperatureThresholds()}
/>
</Show>
</TableCell>
);
case 'vms':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<span
class={counts().vms > 0 ? VMS_BADGE : ZERO_BADGE}
data-proxmox-vm-count
>
{counts().vms}
</span>
</TableCell>
);
case 'cts':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<span
class={counts().containers > 0 ? CTS_BADGE : ZERO_BADGE}
data-proxmox-container-count
>
{counts().containers}
</span>
</TableCell>
);
case 'cluster':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-base-content">
{cluster()}
</span>
</TableCell>
);
default:
column.id satisfies never;
return <></>;
}
};
</TableCell>
);
case 'vms':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<span
class={counts().vms > 0 ? VMS_BADGE : ZERO_BADGE}
data-proxmox-vm-count
>
{counts().vms}
</span>
</TableCell>
);
case 'cts':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<span
class={counts().containers > 0 ? CTS_BADGE : ZERO_BADGE}
data-proxmox-container-count
>
{counts().containers}
</span>
</TableCell>
);
case 'cluster':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-base-content">
{cluster()}
</span>
</TableCell>
);
default:
column.id satisfies never;
return <></>;
}
};
return (
<>
<TableRow
class={`host-row cursor-pointer text-[11px] outline-none sm:text-xs ${
isSelected() ? 'bg-surface-hover' : rowAlertBg()
} ${isOnline() ? '' : 'opacity-60'} focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface`}
aria-controls={isSelected() ? detailRowId() : undefined}
aria-expanded={isSelected() ? 'true' : 'false'}
data-proxmox-host-row={node.id}
data-workload-alert-accent={alertAccentTone()}
onClick={toggleNodeDrawer}
onKeyDown={handleActivationKey}
tabIndex={0}
>
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
</TableRow>
<Show when={isSelected() && drawerNode()}>
{(selectedNode) => (
<InlineDetailTableRow
cellId={detailRowId()}
colspan={visibleColumns().length}
data-inline-node-detail-for={node.id}
>
<NodeDrawer
node={selectedNode()}
disks={normalizeDiskArray(node.agent?.disks)}
temperatureThresholds={temperatureThresholds()}
discoveryTarget={(() => {
const config = toDiscoveryConfig(node);
return config
? { agentId: config.agentId, hostname: config.hostname }
: undefined;
})()}
/>
</InlineDetailTableRow>
)}
</Show>
</>
);
}}
</For>
}
/>
return (
<>
<TableRow
class={`host-row cursor-pointer text-[11px] outline-none sm:text-xs ${
isSelected() ? 'bg-surface-hover' : rowAlertBg()
} ${isOnline() ? '' : 'opacity-60'} focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface`}
aria-controls={isSelected() ? detailRowId() : undefined}
aria-expanded={isSelected() ? 'true' : 'false'}
data-proxmox-host-row={node.id}
data-workload-alert-accent={alertAccentTone()}
onClick={toggleNodeDrawer}
onKeyDown={handleActivationKey}
tabIndex={0}
>
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
</TableRow>
<Show when={isSelected() && drawerNode()}>
{(selectedNode) => (
<InlineDetailTableRow
cellId={detailRowId()}
colspan={visibleColumns().length}
data-inline-node-detail-for={node.id}
>
<NodeDrawer
node={selectedNode()}
disks={normalizeDiskArray(node.agent?.disks)}
temperatureThresholds={temperatureThresholds()}
discoveryTarget={(() => {
const config = toDiscoveryConfig(node);
return config
? { agentId: config.agentId, hostname: config.hostname }
: undefined;
})()}
/>
</InlineDetailTableRow>
)}
</Show>
</>
);
}}
</For>
}
/>
</Show>
</Show>
);
};
@@ -333,6 +333,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
<ProxmoxNodesTable
nodes={currentModel().pveNodes}
guests={currentModel().guests}
search={workloadsState.search}
metricDisplayMode={props.metricDisplayMode}
metricHistoryRange={props.metricHistoryRange}
layoutWidth={overviewWidth.width}
@@ -113,6 +113,24 @@ afterEach(() => {
});
describe('ProxmoxNodesTable', () => {
it('uses the shared workload search to narrow the host preview and count', () => {
render(() => (
<ProxmoxNodesTable
nodes={makeNodeResources(10)}
guests={[]}
search={() => 'pve-node-9'}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
expect(screen.getByText('Nodes').parentElement).toHaveTextContent('Nodes1of 10');
expect(screen.getByText('pve-node-9')).toBeInTheDocument();
expect(screen.queryByText('pve-node-1')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show all 10 nodes' })).not.toBeInTheDocument();
});
it('keeps large estates bounded until the operator expands the node preview', () => {
render(() => (
<ProxmoxNodesTable
@@ -312,6 +312,23 @@ describe('ProxmoxPageSurface contract', () => {
);
});
it('passes the shared workload search into the node table', () => {
mockWorkloadSearch.mockReturnValue('pve-1');
setResources([
makeResource({
id: 'agent:pve-1',
type: 'agent',
proxmox: { nodeName: 'pve-1', clusterName: 'homelab' },
}),
]);
renderSurface();
expect(mockNodesTableProps).toHaveBeenLastCalledWith(
expect.objectContaining({ search: mockWorkloadSearch }),
);
});
it('keeps Proxmox workload and backup filters free of a saved-views affordance', () => {
// Saved views persisted the page's URL query string to localStorage. The
// browser's own bookmarks already do that and survive a cleared cache, so
@@ -4,6 +4,7 @@ import {
PROXMOX_TAB_SPECS,
buildProxmoxPageModel,
buildVisibleProxmoxTabSpecs,
filterProxmoxNodes,
getResourceVersion,
resolveProxmoxPlatformScope,
} from '../proxmoxPageModel';
@@ -218,4 +219,80 @@ describe('proxmoxPageModel', () => {
),
).toBe('8.3.3');
});
it('filters nodes by native identity, cluster, aliases, and matching guests', () => {
const nodes = [
makeResource({
id: 'friendly-east',
type: 'agent',
displayName: 'East estate',
platformId: 'site-east',
proxmox: {
nodeName: 'pve-east-01',
nodeAliases: ['old-east-name'],
clusterName: 'production',
},
}),
makeResource({
id: 'friendly-west',
type: 'agent',
displayName: 'West estate',
platformId: 'site-west',
proxmox: { nodeName: 'pve-west-01', clusterName: 'production' },
}),
];
const guests = [
makeResource({
id: 'vm-database',
type: 'vm',
displayName: 'database-primary',
parentName: 'pve-east-01',
proxmox: { nodeName: 'pve-east-01', instance: 'site-east', vmid: 101 },
}),
];
expect(filterProxmoxNodes(nodes, guests, 'pve-east-01').map((node) => node.id)).toEqual([
'friendly-east',
]);
expect(filterProxmoxNodes(nodes, guests, 'old-east-name').map((node) => node.id)).toEqual([
'friendly-east',
]);
expect(filterProxmoxNodes(nodes, guests, 'database-primary').map((node) => node.id)).toEqual([
'friendly-east',
]);
expect(filterProxmoxNodes(nodes, guests, 'pve-east-01, pve-west-01')).toHaveLength(2);
});
it('preserves exclusion search semantics and does not cross provider boundaries', () => {
const nodes = [
makeResource({
id: 'site-a-node',
type: 'agent',
platformId: 'site-a',
proxmox: { instance: 'site-a', nodeName: 'pve-1', clusterName: 'production' },
}),
makeResource({
id: 'site-b-node',
type: 'agent',
platformId: 'site-b',
proxmox: { instance: 'site-b', nodeName: 'pve-1', clusterName: 'production' },
}),
];
const guests = [
makeResource({
id: 'site-b-vm',
type: 'vm',
displayName: 'unique-west-guest',
platformId: 'site-b',
proxmox: { instance: 'site-b', nodeName: 'pve-1', vmid: 202 },
}),
];
expect(
filterProxmoxNodes(nodes, guests, 'pve-1 -unique-west-guest').map((node) => node.id),
).toEqual(['site-a-node']);
expect(filterProxmoxNodes(nodes, guests, 'unique-west-guest').map((node) => node.id)).toEqual([
'site-b-node',
]);
});
});
@@ -1,5 +1,6 @@
import type { Resource, ResourceMetric, ResourceType } from '@/types/resource';
import { formatProxmoxVersion } from '@/utils/proxmoxVersion';
import { matchesSearchTermSplit, splitSearchExclusions } from '@/utils/searchQuery';
export type ProxmoxPageTabId = 'overview' | 'storage' | 'replication' | 'backups' | 'ceph' | 'mail';
@@ -209,6 +210,58 @@ export function getResourceVmid(resource: Resource): string {
return '';
}
const getProxmoxSearchValues = (resource: Resource): Array<string | number | undefined> => [
resource.name,
resource.displayName,
resource.id,
resource.parentName,
resource.platformId,
resource.status,
resource.identity?.hostname,
...(resource.identity?.ips ?? []),
...(resource.tags ?? []),
resource.proxmox?.node,
resource.proxmox?.nodeName,
resource.proxmox?.nodeIdentity,
resource.proxmox?.nodeDisplayName,
...(resource.proxmox?.nodeAliases ?? []),
resource.proxmox?.clusterName,
resource.proxmox?.instance,
resource.proxmox?.host,
resource.proxmox?.pveVersion,
getResourceVmid(resource),
];
/**
* Keeps the overview's host inventory aligned with its workload search.
* A matching guest makes its parent node searchable too, so searching for a
* VM/LXC does not leave the operator looking at an unrelated host preview.
*/
export const filterProxmoxNodes = (
nodes: Resource[],
guests: Resource[],
search: string,
): Resource[] => {
const split = splitSearchExclusions(search);
if (!split.needle && split.excludes.length === 0) return nodes;
return nodes.filter((node) => {
const values = getProxmoxSearchValues(node);
for (const guest of guests) {
if (isProxmoxChildOfNode(guest, node)) values.push(...getProxmoxSearchValues(guest));
}
const haystack = values
.filter(
(value): value is string | number => typeof value === 'string' || typeof value === 'number',
)
.map(String)
.join(' ')
.toLowerCase();
return matchesSearchTermSplit(haystack, split);
});
};
export function getResourceLastBackup(resource: Resource): string | number | null {
const platformProxmox = getPlatformData(resource).proxmox;
if (!isRecord(platformProxmox)) return null;