feat(instances): add/remove network interface from Networking tab
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m28s
Deploy to Production / Trigger production update (push) Successful in 8s

- AddNetworkInterfaceDialog: bridge dropdown (node networks), model
  select for VM, VLAN tag, firewall, optional MAC; LXC extras: eth name,
  dhcp/static IP with CIDR + gateway; picks next free netN slot
- NetworkingTab: Add interface button, per-NIC delete with confirmation
  (config.put delete=netN)
- new useNodeNetworks hook; netif i18n keys (en/ru)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 02:36:20 +05:00
parent 161182b392
commit 052f9afb65
5 changed files with 467 additions and 29 deletions

View File

@@ -0,0 +1,283 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Network } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useNodeNetworks, useUpdateConfig } from '@/hooks/use-instances';
import { toast } from 'sonner';
const VM_MODELS = ['virtio', 'e1000', 'rtl8139', 'vmxnet3'] as const;
const MAC_RE = /^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$/;
const CIDR_RE = /^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/;
const IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
interface Props {
open: boolean;
onClose: () => void;
serverId: number;
vmid: number;
type: string; // 'qemu' | 'lxc'
node: string;
config?: Record<string, unknown>;
}
export default function AddNetworkInterfaceDialog({ open, onClose, serverId, vmid, type, node, config }: Props) {
const { t } = useTranslation();
const isLxc = type === 'lxc';
const [model, setModel] = useState<string>('virtio');
const [bridge, setBridge] = useState('');
const [vlanTag, setVlanTag] = useState('');
const [firewall, setFirewall] = useState(true);
const [mac, setMac] = useState('');
const [ifName, setIfName] = useState('');
const [ipMode, setIpMode] = useState<'dhcp' | 'static'>('dhcp');
const [ipCidr, setIpCidr] = useState('');
const [gateway, setGateway] = useState('');
const { data: networks } = useNodeNetworks(serverId, node, open);
const bridges = useMemo(
() => (networks?.interfaces ?? []).filter((i) => i.type === 'bridge'),
[networks],
);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
// Next free netN slot from current config keys
const netKey = useMemo(() => {
const used = new Set(
Object.keys(config ?? {})
.filter((k) => /^net\d+$/.test(k))
.map((k) => Number(k.slice(3))),
);
let n = 0;
while (used.has(n)) n++;
return `net${n}`;
}, [config]);
useEffect(() => {
if (open) {
setModel('virtio');
setBridge('');
setVlanTag('');
setFirewall(true);
setMac('');
setIfName('');
setIpMode('dhcp');
setIpCidr('');
setGateway('');
}
}, [open]);
const validationError = (): string | null => {
if (!bridge) return t('netif.err_bridge_required', 'Выберите сетевой мост');
if (mac && !MAC_RE.test(mac)) return t('netif.err_bad_mac', 'Некорректный MAC-адрес (формат AA:BB:CC:DD:EE:FF)');
if (vlanTag && (!/^\d+$/.test(vlanTag) || +vlanTag < 1 || +vlanTag > 4094)) {
return t('netif.err_bad_vlan', 'VLAN tag должен быть числом 1–4094');
}
if (isLxc && ipMode === 'static') {
if (!CIDR_RE.test(ipCidr)) return t('netif.err_bad_cidr', 'Укажите IP в формате CIDR, например 10.0.0.5/24');
if (gateway && !IP_RE.test(gateway)) return t('netif.err_bad_gateway', 'Некорректный адрес шлюза');
}
return null;
};
const buildValue = (): string => {
const parts: string[] = [];
if (isLxc) {
const idx = netKey.slice(3);
parts.push(`name=${ifName.trim() || `eth${idx}`}`);
parts.push(`bridge=${bridge}`);
if (mac) parts.push(`hwaddr=${mac.toUpperCase()}`);
if (vlanTag) parts.push(`tag=${vlanTag}`);
if (firewall) parts.push('firewall=1');
if (ipMode === 'dhcp') {
parts.push('ip=dhcp');
} else {
parts.push(`ip=${ipCidr}`);
if (gateway) parts.push(`gw=${gateway}`);
}
} else {
parts.push(mac ? `${model}=${mac.toUpperCase()}` : model);
parts.push(`bridge=${bridge}`);
if (vlanTag) parts.push(`tag=${vlanTag}`);
if (firewall) parts.push('firewall=1');
}
return parts.join(',');
};
const submit = () => {
const err = validationError();
if (err) {
toast.error(err);
return;
}
updateConfig.mutate(
{ [netKey]: buildValue() },
{
onSuccess: () => {
toast.success(t('netif.added', 'Интерфейс {{key}} добавлен', { key: netKey }));
onClose();
},
onError: (e) => toast.error((e as Error).message),
},
);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Network className="h-4 w-4" />
{t('netif.add_title', 'Добавить сетевой интерфейс')}
</DialogTitle>
<DialogDescription>
{t('netif.add_description', 'Будет добавлен как {{key}}', { key: netKey })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{isLxc ? (
<div className="space-y-1.5">
<Label htmlFor="nic-name">{t('netif.if_name', 'Имя интерфейса')}</Label>
<Input
id="nic-name"
value={ifName}
onChange={(e) => setIfName(e.target.value)}
placeholder={`eth${netKey.slice(3)}`}
/>
</div>
) : (
<div className="space-y-1.5">
<Label>{t('netif.model', 'Модель')}</Label>
<Select value={model} onValueChange={setModel}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{VM_MODELS.map((m) => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label>{t('netif.bridge', 'Мост')}</Label>
<Select value={bridge} onValueChange={setBridge}>
<SelectTrigger>
<SelectValue placeholder={t('netif.select_bridge', 'Выберите мост')} />
</SelectTrigger>
<SelectContent>
{bridges.map((b) => {
const name = b.iface || b.name || '';
return (
<SelectItem key={name} value={name}>
{name}{b.ipam_cidr ? ` (${b.ipam_cidr})` : b.cidr ? ` (${b.cidr})` : ''}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="nic-vlan">{t('netif.vlan_tag', 'VLAN tag')}</Label>
<Input
id="nic-vlan"
value={vlanTag}
onChange={(e) => setVlanTag(e.target.value)}
placeholder={t('netif.optional', 'необязательно')}
inputMode="numeric"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nic-mac">{t('netif.mac', 'MAC-адрес')}</Label>
<Input
id="nic-mac"
value={mac}
onChange={(e) => setMac(e.target.value)}
placeholder={t('netif.mac_auto', 'автоматически')}
className="font-mono"
/>
</div>
</div>
{isLxc && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('netif.ip_mode', 'Режим IP')}</Label>
<Select value={ipMode} onValueChange={(v) => setIpMode(v as 'dhcp' | 'static')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dhcp">DHCP</SelectItem>
<SelectItem value="static">{t('netif.static', 'Статический')}</SelectItem>
</SelectContent>
</Select>
</div>
{ipMode === 'static' && (
<>
<div className="space-y-1.5">
<Label htmlFor="nic-ip">{t('netif.ip_cidr', 'IP/CIDR')}</Label>
<Input
id="nic-ip"
value={ipCidr}
onChange={(e) => setIpCidr(e.target.value)}
placeholder="10.0.0.5/24"
className="font-mono"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nic-gw">{t('netif.gateway', 'Шлюз')}</Label>
<Input
id="nic-gw"
value={gateway}
onChange={(e) => setGateway(e.target.value)}
placeholder="10.0.0.1"
className="font-mono"
/>
</div>
</>
)}
</div>
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={firewall} onChange={(e) => setFirewall(e.target.checked)} />
{t('netif.firewall', 'Firewall')}
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
<Button onClick={submit} disabled={updateConfig.isPending || !bridge}>
{t('netif.add_submit', 'Добавить')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,9 +1,21 @@
import { Globe, Wifi } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe, Wifi, Plus, Trash2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useVMInterfaces, useVMConfig } from '@/hooks/use-instances';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { useVMInterfaces, useVMConfig, useVMStatus, useUpdateConfig } from '@/hooks/use-instances';
import { formatBytes } from '@/lib/format';
import { useVMStatus } from '@/hooks/use-instances';
import { toast } from 'sonner';
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
interface Props {
serverId: number;
@@ -13,9 +25,14 @@ interface Props {
}
export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: status } = useVMStatus(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const [showAdd, setShowAdd] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// Extract network configs from VM config (net0, net1, ...)
const netDevices = config
@@ -24,6 +41,20 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
.sort(([a], [b]) => a.localeCompare(b))
: [];
const removeNic = () => {
if (!deleteTarget) return;
updateConfig.mutate(
{ delete: deleteTarget },
{
onSuccess: () => {
toast.success(t('netif.removed', 'Интерфейс {{key}} удалён', { key: deleteTarget }));
setDeleteTarget(null);
},
onError: (e) => toast.error((e as Error).message),
},
);
};
return (
<div className="space-y-4">
{/* Traffic summary */}
@@ -43,15 +74,25 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
</div>
{/* Configured network devices */}
{netDevices.length > 0 && (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
Network Devices
</CardTitle>
<Button size="sm" variant="outline" onClick={() => setShowAdd(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('netif.add_interface', 'Добавить интерфейс')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{netDevices.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
{t('netif.no_devices', 'Нет сетевых устройств')}
</p>
)}
{netDevices.map(([key, value]) => {
const parts = String(value).split(',');
const bridgePart = parts.find((p) => p.includes('bridge='));
@@ -66,9 +107,20 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
<Badge variant="outline">{key}</Badge>
<span className="text-sm font-medium">{model}</span>
</div>
<div className="flex items-center gap-1.5">
{bridge && (
<Badge variant="secondary" className="text-xs">{bridge}</Badge>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(key)}
title={t('netif.remove_interface', 'Удалить интерфейс')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{mac && (
<p className="mt-1 font-mono text-xs text-muted-foreground">{mac}</p>
@@ -78,7 +130,6 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
})}
</CardContent>
</Card>
)}
{/* Live interfaces */}
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
@@ -115,6 +166,36 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
</CardContent>
</Card>
)}
<AddNetworkInterfaceDialog
open={showAdd}
onClose={() => setShowAdd(false)}
serverId={serverId}
vmid={vmid}
type={type}
node={node}
config={config as Record<string, unknown> | undefined}
/>
{/* Delete NIC confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('netif.remove_title', 'Удалить сетевой интерфейс?')}</DialogTitle>
<DialogDescription>
{t('netif.remove_description', 'Интерфейс {{key}} будет удалён из конфигурации. ВМ может потерять сетевое подключение.', { key: deleteTarget ?? '' })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t('common.cancel', 'Отмена')}
</Button>
<Button variant="destructive" onClick={removeNic} disabled={updateConfig.isPending}>
{t('common.delete', 'Удалить')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -645,6 +645,26 @@ export function useExecuteCommand(serverId: number, vmid: number) {
});
}
// ==================== Node networks (bridges) ====================
export interface NodeNetworkIface {
iface?: string;
name?: string;
type?: string;
active?: number;
cidr?: string;
ipam_network_id?: number;
ipam_cidr?: string;
ipam_name?: string;
}
export function useNodeNetworks(serverId: number, node: string, enabled = true) {
return useQuery<{ node: string; interfaces: NodeNetworkIface[] }>({
queryKey: ['node-networks', serverId, node],
queryFn: () => apiClient.get(`/proxmox/api/servers/${serverId}/nodes/${node}/networks`),
enabled: enabled && !!node,
});
}
// ==================== ISO mount/unmount (KVM only) ====================
export interface IsoItem {
volid: string;

View File

@@ -378,6 +378,33 @@
"netif": {
"title": "Network Interfaces",
"add": "Add Interface",
"add_interface": "Add interface",
"add_submit": "Add",
"add_title": "Add network interface",
"add_description": "Will be added as {{key}}",
"if_name": "Interface name",
"model": "Model",
"bridge": "Bridge",
"select_bridge": "Select a bridge",
"vlan_tag": "VLAN tag",
"mac": "MAC address",
"mac_auto": "auto",
"optional": "optional",
"ip_mode": "IP mode",
"static": "Static",
"ip_cidr": "IP/CIDR",
"firewall": "Firewall",
"no_devices": "No network devices",
"remove_interface": "Remove interface",
"remove_title": "Remove network interface?",
"remove_description": "Interface {{key}} will be removed from the configuration. The instance may lose network connectivity.",
"added": "Interface {{key}} added",
"removed": "Interface {{key}} removed",
"err_bridge_required": "Select a network bridge",
"err_bad_mac": "Invalid MAC address (format AA:BB:CC:DD:EE:FF)",
"err_bad_vlan": "VLAN tag must be a number between 1 and 4094",
"err_bad_cidr": "Enter the IP in CIDR format, e.g. 10.0.0.5/24",
"err_bad_gateway": "Invalid gateway address",
"edit": "Edit",
"name": "Name",
"type": "Type",

View File

@@ -378,6 +378,33 @@
"netif": {
"title": "Сетевые интерфейсы",
"add": "Добавить интерфейс",
"add_interface": "Добавить интерфейс",
"add_submit": "Добавить",
"add_title": "Добавить сетевой интерфейс",
"add_description": "Будет добавлен как {{key}}",
"if_name": "Имя интерфейса",
"model": "Модель",
"bridge": "Мост",
"select_bridge": "Выберите мост",
"vlan_tag": "VLAN tag",
"mac": "MAC-адрес",
"mac_auto": "автоматически",
"optional": "необязательно",
"ip_mode": "Режим IP",
"static": "Статический",
"ip_cidr": "IP/CIDR",
"firewall": "Firewall",
"no_devices": "Нет сетевых устройств",
"remove_interface": "Удалить интерфейс",
"remove_title": "Удалить сетевой интерфейс?",
"remove_description": "Интерфейс {{key}} будет удалён из конфигурации. Инстанс может потерять сетевое подключение.",
"added": "Интерфейс {{key}} добавлен",
"removed": "Интерфейс {{key}} удалён",
"err_bridge_required": "Выберите сетевой мост",
"err_bad_mac": "Некорректный MAC-адрес (формат AA:BB:CC:DD:EE:FF)",
"err_bad_vlan": "VLAN tag должен быть числом 1–4094",
"err_bad_cidr": "Укажите IP в формате CIDR, например 10.0.0.5/24",
"err_bad_gateway": "Некорректный адрес шлюза",
"edit": "Изменить",
"name": "Имя",
"type": "Тип",