mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 19:56:01 +00:00
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:
@@ -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 = (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user