feat: display per-VM/LXC guest IPs in table rows

- Fetch IPs from /status/guest API for running VMs/LXCs (requires guest agent)
- IPs displayed below the name column in the table
- Gracefully handles missing guest agent (quietly skips)
- Backlog: mark Per-VM IP Addresses as done

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:35:29 +01:00
parent 1a0d46a848
commit 15d0ea37f0
6 changed files with 83 additions and 16 deletions

View File

@@ -27,6 +27,11 @@
## Quick Wins (low effort, high value)
### Per-VM/LXC IP Addresses
- Fetches guest IPs from `/status/guest` for running VMs/LXCs (requires guest agent).
- Displays IPs below the name in the table.
- **Status: done (merged to main)**
### Search / Filter
- Client-side text input filtering table rows by name/node/status
- Uses `useMemo` for performant filtering, auto-expands single node and filter-matched nodes

View File

@@ -1,8 +1,7 @@
import { NextResponse } from 'next/server';
import { getNodes, getLXC } from '@/lib/proxmox';
import { getNodes, getLXC, getGuestIPs } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */
export async function GET(_req) {
@@ -13,13 +12,20 @@ export async function GET(_req) {
for (const node of nodes) {
try {
const lxcs = await getLXC(node.name);
allLXC.push(
...lxcs.map((lxc) => ({
for (const lxc of lxcs) {
let ips = [];
if (lxc.status === 'running') {
try {
ips = await getGuestIPs(node.name, lxc.vmid, 'lxc');
} catch { /* guest agent not available */ }
}
allLXC.push({
...lxc,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&vmid=${lxc.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&xtermjs=1`,
})),
);
ips,
});
}
} catch { /* skip node */ }
}

View File

@@ -1,8 +1,7 @@
import { NextResponse } from 'next/server';
import { getNodes, getVMs } from '@/lib/proxmox';
import { getNodes, getVMs, getGuestIPs } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */
export async function GET(_req) {
@@ -13,13 +12,20 @@ export async function GET(_req) {
for (const node of nodes) {
try {
const vms = await getVMs(node.name);
allVMs.push(
...vms.map((vm) => ({
for (const vm of vms) {
let ips = [];
if (vm.status === 'running') {
try {
ips = await getGuestIPs(node.name, vm.vmid, 'qemu');
} catch { /* guest agent not available */ }
}
allVMs.push({
...vm,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&vmid=${vm.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&novnc=1`,
})),
);
ips,
});
}
} catch { /* skip node */ }
}

View File

@@ -90,7 +90,14 @@ export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
{running.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{item.vmid}</td>
<td className="px-3 py-1.5 font-medium text-text-primary">{item.name}</td>
<td className="px-3 py-1.5">
<span className="font-medium text-text-primary">{item.name}</span>
{item.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{item.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<td className="px-3 py-1.5 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
@@ -126,7 +133,14 @@ export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
{stopped.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{item.vmid}</td>
<td className="px-3 py-1.5 font-medium text-text-primary">{item.name}</td>
<td className="px-3 py-1.5">
<span className="font-medium text-text-primary">{item.name}</span>
{item.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{item.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
<td className="px-3 py-1.5"><StatusBadge status={item.status} /></td>
<td className="px-3 py-1.5">

View File

@@ -90,7 +90,14 @@ export default function VMList({ vms, onPowerAction, onOpenConsole }) {
{running.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{vm.vmid}</td>
<td className="px-3 py-1.5 font-medium text-text-primary">{vm.name}</td>
<td className="px-3 py-1.5">
<span className="font-medium text-text-primary">{vm.name}</span>
{vm.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{vm.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<td className="px-3 py-1.5 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td>
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
@@ -126,7 +133,14 @@ export default function VMList({ vms, onPowerAction, onOpenConsole }) {
{stopped.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{vm.vmid}</td>
<td className="px-3 py-1.5 font-medium text-text-primary">{vm.name}</td>
<td className="px-3 py-1.5">
<span className="font-medium text-text-primary">{vm.name}</span>
{vm.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{vm.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
<td className="px-3 py-1.5"><StatusBadge status={vm.status} /></td>
<td className="px-3 py-1.5">

View File

@@ -184,3 +184,25 @@ export async function getSummary() {
totalMemory: { total: totalMemTotal, used: totalMemUsed },
};
}
/**
* Fetch guest IPs from Proxmox.
* Only available when guest agent is running on a live VM/LXC.
* @param {string} nodeName
* @param {number} vmid
* @param {'qemu'|'lxc'} type
* @returns {Promise<string[]>}
*/
export async function getGuestIPs(nodeName, vmid, type) {
const data = await proxmoxFetch(`/nodes/${nodeName}/${type}/${vmid}/status/guest`);
const ips = data.data?.ipconfig || [];
const addresses = [];
for (const ipcfg of ips) {
for (const addr of ipcfg?.addresses || []) {
if (addr?.ip && addr?.ip !== '127.0.0.1' && addr?.ip !== '::1') {
addresses.push(addr.ip);
}
}
}
return [...new Set(addresses)];
}