mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Improve Proxmox node search coverage
Contract-Neutral: Behavioral search fix; no public subsystem contract delta
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user