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)
|
## 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
|
### Search / Filter
|
||||||
- Client-side text input filtering table rows by name/node/status
|
- Client-side text input filtering table rows by name/node/status
|
||||||
- Uses `useMemo` for performant filtering, auto-expands single node and filter-matched nodes
|
- Uses `useMemo` for performant filtering, auto-expands single node and filter-matched nodes
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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_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 */
|
/** @param {import('next').Request} _req */
|
||||||
export async function GET(_req) {
|
export async function GET(_req) {
|
||||||
@@ -13,13 +12,20 @@ export async function GET(_req) {
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
try {
|
try {
|
||||||
const lxcs = await getLXC(node.name);
|
const lxcs = await getLXC(node.name);
|
||||||
allLXC.push(
|
for (const lxc of lxcs) {
|
||||||
...lxcs.map((lxc) => ({
|
let ips = [];
|
||||||
|
if (lxc.status === 'running') {
|
||||||
|
try {
|
||||||
|
ips = await getGuestIPs(node.name, lxc.vmid, 'lxc');
|
||||||
|
} catch { /* guest agent not available */ }
|
||||||
|
}
|
||||||
|
allLXC.push({
|
||||||
...lxc,
|
...lxc,
|
||||||
node: node.name,
|
node: node.name,
|
||||||
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&vmid=${lxc.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&xtermjs=1`,
|
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&vmid=${lxc.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&xtermjs=1`,
|
||||||
})),
|
ips,
|
||||||
);
|
});
|
||||||
|
}
|
||||||
} catch { /* skip node */ }
|
} catch { /* skip node */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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_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 */
|
/** @param {import('next').Request} _req */
|
||||||
export async function GET(_req) {
|
export async function GET(_req) {
|
||||||
@@ -13,13 +12,20 @@ export async function GET(_req) {
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
try {
|
try {
|
||||||
const vms = await getVMs(node.name);
|
const vms = await getVMs(node.name);
|
||||||
allVMs.push(
|
for (const vm of vms) {
|
||||||
...vms.map((vm) => ({
|
let ips = [];
|
||||||
|
if (vm.status === 'running') {
|
||||||
|
try {
|
||||||
|
ips = await getGuestIPs(node.name, vm.vmid, 'qemu');
|
||||||
|
} catch { /* guest agent not available */ }
|
||||||
|
}
|
||||||
|
allVMs.push({
|
||||||
...vm,
|
...vm,
|
||||||
node: node.name,
|
node: node.name,
|
||||||
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&vmid=${vm.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&novnc=1`,
|
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&vmid=${vm.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&novnc=1`,
|
||||||
})),
|
ips,
|
||||||
);
|
});
|
||||||
|
}
|
||||||
} catch { /* skip node */ }
|
} catch { /* skip node */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,14 @@ export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
|
|||||||
{running.map((item) => (
|
{running.map((item) => (
|
||||||
<tr key={item.vmid} className="border-b border-border-card last:border-0">
|
<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 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">{(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.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>
|
<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) => (
|
{stopped.map((item) => (
|
||||||
<tr key={item.vmid} className="border-b border-border-card last:border-0">
|
<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 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 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"><StatusBadge status={item.status} /></td>
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
|
|||||||
@@ -90,7 +90,14 @@ export default function VMList({ vms, onPowerAction, onOpenConsole }) {
|
|||||||
{running.map((vm) => (
|
{running.map((vm) => (
|
||||||
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
|
<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 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">{(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.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>
|
<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) => (
|
{stopped.map((vm) => (
|
||||||
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
|
<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 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 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"><StatusBadge status={vm.status} /></td>
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
|
|||||||
@@ -184,3 +184,25 @@ export async function getSummary() {
|
|||||||
totalMemory: { total: totalMemTotal, used: totalMemUsed },
|
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