diff --git a/frontend-modern/src/components/shared/NodeGroupHeader.tsx b/frontend-modern/src/components/shared/NodeGroupHeader.tsx index e0bf1a8cf8..34ce7d6fc0 100644 --- a/frontend-modern/src/components/shared/NodeGroupHeader.tsx +++ b/frontend-modern/src/components/shared/NodeGroupHeader.tsx @@ -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 = (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); diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index c0e9f5cd41..ec954a2748 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -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<{ ); + case 'web': + return ( + + —}> + {(href) => ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + )} + + + ); default: column.id satisfies never; return <>; diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx index f9dc680879..dec2be0c51 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx @@ -72,6 +72,48 @@ afterEach(() => { }); describe('ProxmoxNodesTable', () => { + it('links each node to its PVE web interface without hijacking the row click', () => { + render(() => ( + } + 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(() => ( + } + 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(() => ( { 'vms', 'cts', 'cluster', + 'web', ]); expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({ - width: '10.7527%', + width: '10.3093%', }); }); diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index 6bee175ca8..b1780b9a50 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -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 = { 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 = ( diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index bcc091082d..6e5fdf551e 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -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[]; diff --git a/frontend-modern/src/utils/__tests__/nodes.test.ts b/frontend-modern/src/utils/__tests__/nodes.test.ts index 2a8c713fda..8ffb3f5c95 100644 --- a/frontend-modern/src/utils/__tests__/nodes.test.ts +++ b/frontend-modern/src/utils/__tests__/nodes.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index b6dacc0e47..eea0d8d863 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -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({ diff --git a/frontend-modern/src/utils/nodes.ts b/frontend-modern/src/utils/nodes.ts index 917ab299ac..7bb7cb9350 100644 --- a/frontend-modern/src/utils/nodes.ts +++ b/frontend-modern/src/utils/nodes.ts @@ -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 & Partial>, +>(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(node: T): string { const display = typeof node.displayName === 'string' ? node.displayName.trim() : ''; if (display) return display; diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index f751f25347..f878586fa3 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -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).customURL) || - asString((resource as unknown as Record).customUrl), + asString((resource as unknown as Record).customUrl) || + asString(proxmox?.host), status: resource.status || 'unknown', type: resource.type, cpu: resource.cpu?.current ?? 0,