feat(instances): add/remove network interface from Networking tab
- 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:
283
frontend/src/features/instances/AddNetworkInterfaceDialog.tsx
Normal file
283
frontend/src/features/instances/AddNetworkInterfaceDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { formatBytes } from '@/lib/format';
|
||||||
import { useVMStatus } from '@/hooks/use-instances';
|
import { toast } from 'sonner';
|
||||||
|
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: number;
|
serverId: number;
|
||||||
@@ -13,9 +25,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
||||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||||
const { data: status } = useVMStatus(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, ...)
|
// Extract network configs from VM config (net0, net1, ...)
|
||||||
const netDevices = config
|
const netDevices = config
|
||||||
@@ -24,6 +41,20 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
|||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Traffic summary */}
|
{/* Traffic summary */}
|
||||||
@@ -43,15 +74,25 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configured network devices */}
|
{/* Configured network devices */}
|
||||||
{netDevices.length > 0 && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
Network Devices
|
Network Devices
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<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]) => {
|
{netDevices.map(([key, value]) => {
|
||||||
const parts = String(value).split(',');
|
const parts = String(value).split(',');
|
||||||
const bridgePart = parts.find((p) => p.includes('bridge='));
|
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>
|
<Badge variant="outline">{key}</Badge>
|
||||||
<span className="text-sm font-medium">{model}</span>
|
<span className="text-sm font-medium">{model}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
{bridge && (
|
{bridge && (
|
||||||
<Badge variant="secondary" className="text-xs">{bridge}</Badge>
|
<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>
|
</div>
|
||||||
{mac && (
|
{mac && (
|
||||||
<p className="mt-1 font-mono text-xs text-muted-foreground">{mac}</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Live interfaces */}
|
{/* Live interfaces */}
|
||||||
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
|
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
|
||||||
@@ -115,6 +166,36 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) ====================
|
// ==================== ISO mount/unmount (KVM only) ====================
|
||||||
export interface IsoItem {
|
export interface IsoItem {
|
||||||
volid: string;
|
volid: string;
|
||||||
|
|||||||
@@ -378,6 +378,33 @@
|
|||||||
"netif": {
|
"netif": {
|
||||||
"title": "Network Interfaces",
|
"title": "Network Interfaces",
|
||||||
"add": "Add Interface",
|
"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",
|
"edit": "Edit",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
|||||||
@@ -378,6 +378,33 @@
|
|||||||
"netif": {
|
"netif": {
|
||||||
"title": "Сетевые интерфейсы",
|
"title": "Сетевые интерфейсы",
|
||||||
"add": "Добавить интерфейс",
|
"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": "Изменить",
|
"edit": "Изменить",
|
||||||
"name": "Имя",
|
"name": "Имя",
|
||||||
"type": "Тип",
|
"type": "Тип",
|
||||||
|
|||||||
Reference in New Issue
Block a user