proxmox(overview): restore the node web-interface link end-to-end

The v6 unified-resource node shim never read the PVE URLs the backend
ships (proxmox.guestUrl / proxmox.host): guestURL came from a
nonexistent customURL field and host carried the bare node name, so the
workloads group-header link resolved relative to the SPA
(/proxmox/pve1 in a new tab) and the nodes table had no link at all.
v5's dedicated link column (NodeSummaryTable) was silently dropped in
the rebuild. Flagged by the Proxmox overview parity audit.

- Map proxmox.guestUrl into guestURL with the proxmox.host API URL as
  fallback in nodeFromResource; host stays a hostname label because the
  node drawer renders it as one.
- Add getNodeExternalUrl: only URL-shaped values become link targets,
  bare hostnames fall through to the canonical https://<node>:8006.
- Point NodeGroupHeader at the helper.
- Add a Web column (Machines-table pattern) to the Proxmox nodes table
  at compact and wide layouts.
This commit is contained in:
rcourtman
2026-06-11 18:43:26 +01:00
parent 5ba53f681d
commit 61d75dfded
10 changed files with 162 additions and 6 deletions
@@ -1,6 +1,6 @@
import { Component, For, Show, type JSX } from 'solid-js';
import type { Node } from '@/types/api';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { getNodeDisplayName, getNodeExternalUrl, hasAlternateDisplayName } from '@/utils/nodes';
import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator } from '@/utils/status';
import { formatUptime } from '@/utils/format';
@@ -29,7 +29,7 @@ interface NodeGroupHeaderProps {
export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
const nodeStatus = () => getNodeStatusIndicator(props.node);
const isOnline = () => nodeStatus().variant === 'success';
const nodeUrl = () => props.node.guestURL || props.node.host || `https://${props.node.name}:8006`;
const nodeUrl = () => getNodeExternalUrl(props.node);
const displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node);
const pveVersion = () => formatProxmoxVersion(props.node.pveVersion);
@@ -7,6 +7,7 @@ import {
type Component,
type JSX,
} from 'solid-js';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { TableCard } from '@/components/shared/TableCard';
@@ -27,6 +28,7 @@ import {
TableRow,
} from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getNodeExternalUrl } from '@/utils/nodes';
import { asTrimmedString } from '@/utils/stringUtils';
import { normalizeDiskArray } from '@/utils/format';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
@@ -241,6 +243,10 @@ export const ProxmoxNodesTable: Component<{
} as Disk)
: undefined;
const legacyNode = () => projectResourceToLegacyNode(node);
const externalUrl = () => {
const shimmed = drawerNode();
return shimmed ? getNodeExternalUrl(shimmed) : '';
};
const cpuSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'cpu');
const memorySeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'memory');
@@ -416,6 +422,28 @@ export const ProxmoxNodesTable: Component<{
</span>
</TableCell>
);
case 'web':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show when={externalUrl()} fallback={<span class="text-muted">—</span>}>
{(href) => (
<a
data-proxmox-host-web-link
href={href()}
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 dark:text-blue-400 dark:hover:bg-blue-950 dark:hover:text-blue-300"
title={`Open ${name()} web interface`}
aria-label={`Open ${name()} web interface`}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<ExternalLinkIcon class="h-3.5 w-3.5" />
</a>
)}
</Show>
</TableCell>
);
default:
column.id satisfies never;
return <></>;
@@ -72,6 +72,48 @@ afterEach(() => {
});
describe('ProxmoxNodesTable', () => {
it('links each node to its PVE web interface without hijacking the row click', () => {
render(() => (
<ProxmoxNodesTable
nodes={[
makeNodeResource({
proxmox: {
clusterName: 'homelab',
nodeName: 'pve-node-1',
guestUrl: 'https://pve.example.com:8006',
},
}),
]}
guests={[]}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
const link = screen.getByRole('link', { name: 'Open pve-node-1 web interface' });
expect(link).toHaveAttribute('href', 'https://pve.example.com:8006');
expect(link).toHaveAttribute('target', '_blank');
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
});
it('builds the canonical :8006 link when no URL metadata is present', () => {
render(() => (
<ProxmoxNodesTable
nodes={[makeNodeResource()]}
guests={[]}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
expect(screen.getByRole('link', { name: 'Open pve-node-1 web interface' })).toHaveAttribute(
'href',
'https://pve-node-1:8006',
);
});
it('opens the host details drawer from the host-owned top table row', async () => {
render(() => (
<ProxmoxNodesTable
@@ -46,9 +46,10 @@ describe('proxmoxHostTableModel', () => {
'vms',
'cts',
'cluster',
'web',
]);
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
width: '10.7527%',
width: '10.3093%',
});
});
@@ -13,7 +13,8 @@ export type ProxmoxHostTableColumnId =
| 'temp'
| 'vms'
| 'cts'
| 'cluster';
| 'cluster'
| 'web';
export type ProxmoxHostTableColumn = {
id: ProxmoxHostTableColumnId;
@@ -39,6 +40,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
version: 'compact',
uptime: 'compact',
cluster: 'compact',
web: 'compact',
};
// CPU, memory, and disk render the same kind of usage bar, so they share
@@ -58,6 +60,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
vms: 5,
cts: 5,
cluster: 10,
web: 4,
};
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
@@ -99,6 +102,7 @@ export const PROXMOX_HOST_TABLE_COLUMNS: ProxmoxHostTableColumn[] = [
{ id: 'vms', label: 'VMs', kind: 'numeric-value' },
{ id: 'cts', label: 'CTs', kind: 'numeric-value' },
{ id: 'cluster', label: 'Cluster', kind: 'text' },
{ id: 'web', label: 'Web', kind: 'badge' },
];
export const getProxmoxHostVisibleColumnsForLayout = (
+4
View File
@@ -607,6 +607,10 @@ export interface ResourceProxmoxMeta {
pool?: string;
instance?: string;
clusterName?: string;
/** PVE API connection URL (json `host` on the backend ProxmoxData). */
host?: string;
/** Operator-set web interface URL override. */
guestUrl?: string;
cpus?: number;
template?: boolean;
disks?: Disk[];
@@ -1,5 +1,30 @@
import { describe, expect, it } from 'vitest';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { getNodeDisplayName, getNodeExternalUrl, hasAlternateDisplayName } from '@/utils/nodes';
describe('getNodeExternalUrl', () => {
it('prefers a URL-shaped guestURL', () => {
const node = { name: 'pve1', guestURL: 'https://pve.example.com:8006', host: 'pve1' };
expect(getNodeExternalUrl(node)).toBe('https://pve.example.com:8006');
});
it('falls back to a URL-shaped host for legacy node payloads', () => {
const node = { name: 'pve1', guestURL: '', host: 'https://192.168.0.5:8006' };
expect(getNodeExternalUrl(node)).toBe('https://192.168.0.5:8006');
});
it('never treats a bare hostname label as a link target', () => {
const node = { name: 'pve1', guestURL: '', host: 'pve1' };
expect(getNodeExternalUrl(node)).toBe('https://pve1:8006');
});
it('builds the canonical PVE default from the node name', () => {
expect(getNodeExternalUrl({ name: 'pve1' })).toBe('https://pve1:8006');
});
it('returns empty when no name is available', () => {
expect(getNodeExternalUrl({ name: '' })).toBe('');
});
});
describe('getNodeDisplayName', () => {
it('returns displayName when present and non-empty', () => {
@@ -116,6 +116,34 @@ describe('resourceStateAdapters nodeFromResource', () => {
expect(node?.host).toBe('tower.local');
});
it('maps the PVE web interface URL from proxmox metadata', () => {
const node = nodeFromResource(
createNodeResource({
proxmox: {
nodeName: 'pve-node-1',
guestUrl: 'https://pve.example.com:8006',
host: 'https://192.168.0.5:8006',
},
}),
);
expect(node?.guestURL).toBe('https://pve.example.com:8006');
expect(node?.host).toBe('pve-node-1');
});
it('falls back to the PVE API connection URL when no guest URL override exists', () => {
const node = nodeFromResource(
createNodeResource({
proxmox: {
nodeName: 'pve-node-1',
host: 'https://192.168.0.5:8006',
},
}),
);
expect(node?.guestURL).toBe('https://192.168.0.5:8006');
});
it('projects the canonical cluster name through the shared helper', () => {
const node = nodeFromResource(
createNodeResource({
+20
View File
@@ -19,6 +19,26 @@ const extractHostname = (value: string): string => {
return hostPart.replace(/:\d+$/, '');
};
const HTTP_URL_PATTERN = /^https?:\/\//i;
/**
* Resolve the external management URL for a PVE node. `guestURL` carries the
* operator-set link override (and, via the resource adapters, the PVE API
* connection URL). `host` is only honored when it is URL-shaped: on the
* unified-resource node shape it holds a bare hostname label for the drawer,
* which must not become a relative link target.
*/
export function getNodeExternalUrl<
T extends Pick<Node, 'name'> & Partial<Pick<Node, 'guestURL' | 'host'>>,
>(node: T): string {
const guestUrl = typeof node.guestURL === 'string' ? node.guestURL.trim() : '';
if (HTTP_URL_PATTERN.test(guestUrl)) return guestUrl;
const host = typeof node.host === 'string' ? node.host.trim() : '';
if (HTTP_URL_PATTERN.test(host)) return host;
const name = typeof node.name === 'string' ? node.name.trim() : '';
return name ? `https://${name}:8006` : '';
}
export function getNodeDisplayName<T extends DisplayableNode>(node: T): string {
const display = typeof node.displayName === 'string' ? node.displayName.trim() : '';
if (display) return display;
@@ -923,9 +923,13 @@ export const nodeFromResource = (resource: Resource): Node | null => {
displayName: getPreferredInfrastructureDisplayName(resource),
instance,
host: name || preferredHostLabel,
// proxmox.guestUrl is the operator-set link override and proxmox.host the
// PVE API connection URL; `host` above stays a hostname label for display.
guestURL:
asString(proxmox?.guestUrl) ||
asString((resource as unknown as Record<string, unknown>).customURL) ||
asString((resource as unknown as Record<string, unknown>).customUrl),
asString((resource as unknown as Record<string, unknown>).customUrl) ||
asString(proxmox?.host),
status: resource.status || 'unknown',
type: resource.type,
cpu: resource.cpu?.current ?? 0,