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:
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user