proxmox(overview): restore v5 node-row signals

The v6 nodes table dropped four glanceable signals its v5 ancestor
(NodeSummaryTable) carried: alerting rows lost their red/yellow accent
and background tint, pending apt updates lost the escalating badge in
the name cell, uptime was rounded to whole days, and offline nodes
rendered at full opacity. Flagged by the Proxmox overview parity audit.

- Wire getAlertStyles over the websocket alert store (alerts key on the
  canonical unified resource id) and reuse the workload-row accent CSS
  through a host-row variant.
- Pending-updates badge (orange at >=10, yellow below) beside the name.
- Full '26d 4h' uptime via the shared formatUptime; <1h warning kept.
- opacity-60 on offline rows, matching guest-row stopped dimming.
This commit is contained in:
rcourtman
2026-06-11 18:51:10 +01:00
parent 61d75dfded
commit 37a22009b7
3 changed files with 114 additions and 17 deletions
@@ -8,6 +8,9 @@ import {
type JSX,
} from 'solid-js';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import { useWebSocket } from '@/contexts/appRuntime';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { getAlertStyles } from '@/utils/alerts';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { TableCard } from '@/components/shared/TableCard';
@@ -30,7 +33,7 @@ import {
import { getSimpleStatusIndicator } from '@/utils/status';
import { getNodeExternalUrl } from '@/utils/nodes';
import { asTrimmedString } from '@/utils/stringUtils';
import { normalizeDiskArray } from '@/utils/format';
import { formatUptime, normalizeDiskArray } from '@/utils/format';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import { useWorkloadTableMetricHistory } from '@/components/Workloads/useWorkloadTableMetricHistory';
import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel';
@@ -71,15 +74,11 @@ import {
// metricDisplayMode signal so the hosts table swaps to MetricMiniSparkline
// whenever the user flips the toggle.
const formatUptime = (seconds: number | undefined): { label: string; warn: boolean } => {
const formatNodeUptime = (seconds: number | undefined): { label: string; warn: boolean } => {
if (!seconds || seconds <= 0) return { label: '—', warn: false };
const warn = seconds < 3_600; // <1h matches v5 "recently restarted" highlight
const days = Math.floor(seconds / 86_400);
if (days > 0) return { label: `${days}d`, warn };
const hours = Math.floor(seconds / 3_600);
if (hours > 0) return { label: `${hours}h`, warn };
const mins = Math.floor(seconds / 60);
return { label: `${mins}m`, warn };
// Full "26d 4h" precision matches v5 and the guest rows below; <1h keeps
// the v5 "recently restarted" highlight.
return { label: formatUptime(seconds), warn: seconds < 3_600 };
};
type GuestCounts = { vms: number; containers: number };
@@ -142,6 +141,9 @@ export const ProxmoxNodesTable: Component<{
emptyDescription: string;
}> = (props) => {
const breakpoint = useBreakpoint();
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const [selectedNodeId, setSelectedNodeId] = createSignal<string | null>(null);
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode()));
@@ -199,7 +201,7 @@ export const ProxmoxNodesTable: Component<{
<For each={props.nodes}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const drawerNode = () => nodeFromResource(node);
const drawerNode = createMemo(() => nodeFromResource(node));
const detailRowId = () => `proxmox-host-drawer-${node.id}`;
const isSelected = () => selectedNodeId() === node.id;
const toggleNodeDrawer = () =>
@@ -216,7 +218,7 @@ export const ProxmoxNodesTable: Component<{
const counts = () => countGuestsForNode(props.guests, getResourceNodeName(node));
const indicator = () => getSimpleStatusIndicator(node.status);
const isOnline = () => indicator().variant === 'success';
const uptime = () => formatUptime(node.uptime);
const uptime = () => formatNodeUptime(node.uptime);
const metricsKey = () => buildMetricKeyForUnifiedResource(node);
const temperature = () => node.temperature;
const cpuPercent = () => node.cpu?.current ?? 0;
@@ -247,6 +249,26 @@ export const ProxmoxNodesTable: Component<{
const shimmed = drawerNode();
return shimmed ? getNodeExternalUrl(shimmed) : '';
};
const pendingUpdates = () => drawerNode()?.pendingUpdates ?? 0;
const alertStyles = createMemo(() =>
getAlertStyles(node.id, activeAlerts, alertsEnabled()),
);
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');
@@ -266,6 +288,18 @@ export const ProxmoxNodesTable: Component<{
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
<Show when={isOnline() && pendingUpdates() > 0}>
<span
class={`rounded px-1 py-0 text-[9px] font-medium whitespace-nowrap ${
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>
);
@@ -453,12 +487,13 @@ export const ProxmoxNodesTable: Component<{
return (
<>
<TableRow
class={`cursor-pointer text-[11px] outline-none sm:text-xs ${
isSelected() ? 'bg-surface-hover' : ''
} focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface`}
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}
@@ -5,6 +5,7 @@ import type { Resource } from '@/types/resource';
import { ProxmoxNodesTable } from '../ProxmoxNodesTable';
const nodeDrawerMock = vi.hoisted(() => vi.fn());
const activeAlertsMock = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
vi.mock('@/hooks/useBreakpoint', () => ({
useBreakpoint: () => ({
@@ -12,6 +13,14 @@ vi.mock('@/hooks/useBreakpoint', () => ({
}),
}));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: activeAlertsMock.value }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({ activationState: () => 'active' }),
}));
vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: () => <div data-testid="responsive-metric-cell" />,
}));
@@ -69,6 +78,7 @@ const makeNodeResource = (overrides: Partial<Resource> = {}): Resource => ({
afterEach(() => {
cleanup();
vi.clearAllMocks();
activeAlertsMock.value = {};
});
describe('ProxmoxNodesTable', () => {
@@ -114,6 +124,55 @@ describe('ProxmoxNodesTable', () => {
);
});
it('restores the v5 row signals: alert accent, pending updates badge, full uptime, offline dimming', () => {
activeAlertsMock.value = {
'alert-1': {
id: 'alert-1',
resourceId: 'agent:pve-node-1',
level: 'critical',
type: 'cpu',
acknowledged: false,
},
};
render(() => (
<ProxmoxNodesTable
nodes={[
makeNodeResource({
uptime: 187_200, // 2d 4h
// pendingUpdates rides the raw proxmox payload; the narrow
// ResourceProxmoxMeta type does not declare it yet.
proxmox: {
clusterName: 'homelab',
nodeName: 'pve-node-1',
pendingUpdates: 12,
} as Resource['proxmox'],
}),
makeNodeResource({
id: 'agent:pve-node-2',
name: 'pve-node-2',
displayName: 'pve-node-2',
status: 'offline',
proxmox: { clusterName: 'homelab', nodeName: 'pve-node-2' },
}),
]}
guests={[]}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
const alertedRow = screen.getByText('pve-node-1').closest('tr');
expect(alertedRow).toHaveAttribute('data-workload-alert-accent', 'critical');
expect(screen.getByTitle('12 pending apt updates')).toHaveTextContent('12 updates');
expect(screen.getByText('2d 4h')).toBeInTheDocument();
const offlineRow = screen.getByText('pve-node-2').closest('tr');
expect(offlineRow?.className).toContain('opacity-60');
expect(offlineRow).not.toHaveAttribute('data-workload-alert-accent');
});
it('opens the host details drawer from the host-owned top table row', async () => {
render(() => (
<ProxmoxNodesTable
+6 -3
View File
@@ -202,15 +202,18 @@
min-height: 32px;
}
.workload-row[data-workload-alert-accent='critical'] > td:first-child {
.workload-row[data-workload-alert-accent='critical'] > td:first-child,
.host-row[data-workload-alert-accent='critical'] > td:first-child {
box-shadow: inset 4px 0 0 0 #ef4444;
}
.workload-row[data-workload-alert-accent='warning'] > td:first-child {
.workload-row[data-workload-alert-accent='warning'] > td:first-child,
.host-row[data-workload-alert-accent='warning'] > td:first-child {
box-shadow: inset 4px 0 0 0 #eab308;
}
.workload-row[data-workload-alert-accent='acknowledged'] > td:first-child {
.workload-row[data-workload-alert-accent='acknowledged'] > td:first-child,
.host-row[data-workload-alert-accent='acknowledged'] > td:first-child {
box-shadow: inset 4px 0 0 0 #9ca3af;
}