feat(nodes): add node network interface management UI
Wire up the existing node-network backend endpoints (list/create/update/ delete + apply/revert staged config) to a new NodeNetworks component on the node detail page. Supports bridge/bond/vlan/eth interface types with type-specific fields and surfaces IPAM linkage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { useServer, useNodes } from '@/hooks/use-nodes';
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
import NodeNetworks from './NodeNetworks';
|
||||
|
||||
export default function NodeDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -60,6 +61,11 @@ export default function NodeDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node network interfaces */}
|
||||
{nodes.length > 0 && (
|
||||
<NodeNetworks serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||
)}
|
||||
|
||||
{/* VMs on this server */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
|
||||
|
||||
350
frontend/src/features/nodes/NodeNetworks.tsx
Normal file
350
frontend/src/features/nodes/NodeNetworks.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Check, Undo2, Network, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useNodeNetworks,
|
||||
useCreateNodeNetwork,
|
||||
useUpdateNodeNetwork,
|
||||
useDeleteNodeNetwork,
|
||||
useApplyNodeNetwork,
|
||||
useRevertNodeNetwork,
|
||||
} from '@/hooks/use-nodes';
|
||||
import type { NodeNetworkInterface } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const IFACE_TYPES = ['bridge', 'bond', 'vlan', 'eth', 'alias', 'OVSBridge', 'OVSBond', 'OVSIntPort'];
|
||||
|
||||
interface FormState {
|
||||
type: string;
|
||||
iface: string;
|
||||
cidr: string;
|
||||
gateway: string;
|
||||
bridge_ports: string;
|
||||
bond_slaves: string;
|
||||
bond_mode: string;
|
||||
vlan_raw_device: string;
|
||||
vlan_id: string;
|
||||
autostart: boolean;
|
||||
comments: string;
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
type: 'bridge',
|
||||
iface: '',
|
||||
cidr: '',
|
||||
gateway: '',
|
||||
bridge_ports: '',
|
||||
bond_slaves: '',
|
||||
bond_mode: 'balance-rr',
|
||||
vlan_raw_device: '',
|
||||
vlan_id: '',
|
||||
autostart: true,
|
||||
comments: '',
|
||||
};
|
||||
|
||||
function ifaceToForm(iface: NodeNetworkInterface): FormState {
|
||||
return {
|
||||
type: iface.type || 'bridge',
|
||||
iface: iface.iface,
|
||||
cidr: iface.cidr || (iface.address && iface.netmask ? `${iface.address}` : ''),
|
||||
gateway: iface.gateway || '',
|
||||
bridge_ports: iface.bridge_ports || '',
|
||||
bond_slaves: iface.bond_slaves || '',
|
||||
bond_mode: iface.bond_mode || 'balance-rr',
|
||||
vlan_raw_device: iface['vlan-raw-device'] || '',
|
||||
vlan_id: iface['vlan-id'] != null ? String(iface['vlan-id']) : '',
|
||||
autostart: iface.autostart === 1,
|
||||
comments: iface.comments || '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form: FormState): Record<string, unknown> {
|
||||
const p: Record<string, unknown> = {
|
||||
type: form.type,
|
||||
iface: form.iface.trim(),
|
||||
autostart: form.autostart ? 1 : 0,
|
||||
};
|
||||
if (form.cidr.trim()) p.cidr = form.cidr.trim();
|
||||
if (form.gateway.trim()) p.gateway = form.gateway.trim();
|
||||
if (form.comments.trim()) p.comments = form.comments.trim();
|
||||
if (form.type === 'bridge' || form.type === 'OVSBridge') {
|
||||
if (form.bridge_ports.trim()) p.bridge_ports = form.bridge_ports.trim();
|
||||
}
|
||||
if (form.type === 'bond' || form.type === 'OVSBond') {
|
||||
if (form.bond_slaves.trim()) p.bond_slaves = form.bond_slaves.trim();
|
||||
if (form.bond_mode) p.bond_mode = form.bond_mode;
|
||||
}
|
||||
if (form.type === 'vlan') {
|
||||
if (form.vlan_raw_device.trim()) p['vlan-raw-device'] = form.vlan_raw_device.trim();
|
||||
if (form.vlan_id.trim()) p['vlan-id'] = Number(form.vlan_id);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export default function NodeNetworks({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [node, setNode] = useState(nodeNames[0] || '');
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = useNodeNetworks(serverId, node);
|
||||
const createNet = useCreateNodeNetwork(serverId, node);
|
||||
const updateNet = useUpdateNodeNetwork(serverId, node);
|
||||
const deleteNet = useDeleteNodeNetwork(serverId, node);
|
||||
const applyNet = useApplyNodeNetwork(serverId, node);
|
||||
const revertNet = useRevertNodeNetwork(serverId, node);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
const interfaces = data?.interfaces ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (iface: NodeNetworkInterface) => {
|
||||
setEditing(iface.iface);
|
||||
setForm(ifaceToForm(iface));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.iface.trim()) {
|
||||
toast.error(t('netif.name_required'));
|
||||
return;
|
||||
}
|
||||
const payload = buildPayload(form);
|
||||
if (editing) {
|
||||
const { iface, ...rest } = payload;
|
||||
void iface;
|
||||
updateNet.mutate(
|
||||
{ iface: editing, ...rest },
|
||||
{
|
||||
onSuccess: () => { toast.success(t('netif.saved')); setDialogOpen(false); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
createNet.mutate(payload, {
|
||||
onSuccess: () => { toast.success(t('netif.created')); setDialogOpen(false); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (iface: string) => {
|
||||
if (!confirm(`${t('common.confirm_delete')} "${iface}"?`)) return;
|
||||
deleteNet.mutate(iface, {
|
||||
onSuccess: () => toast.success(t('netif.deleted')),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!confirm(t('netif.apply_confirm'))) return;
|
||||
applyNet.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('netif.applied')),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevert = () => {
|
||||
if (!confirm(t('netif.revert_confirm'))) return;
|
||||
revertNet.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('netif.reverted')),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const saving = createNet.isPending || updateNet.isPending;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Network className="h-5 w-5" />
|
||||
{t('netif.title')}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeNames.length > 1 && (
|
||||
<Select value={node} onValueChange={v => { if (v) setNode(v); }}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodeNames.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}><Plus className="mr-1 h-4 w-4" />{t('netif.add')}</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleApply} disabled={applyNet.isPending}>
|
||||
<Check className="mr-1 h-4 w-4" />{t('netif.apply')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleRevert} disabled={revertNet.isPending}>
|
||||
<Undo2 className="mr-1 h-4 w-4" />{t('netif.revert')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t('netif.pending_hint')}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : interfaces.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('netif.name')}</TableHead>
|
||||
<TableHead>{t('netif.type')}</TableHead>
|
||||
<TableHead>{t('netif.address')}</TableHead>
|
||||
<TableHead>{t('netif.gateway')}</TableHead>
|
||||
<TableHead>{t('netif.ports')}</TableHead>
|
||||
<TableHead>{t('netif.autostart')}</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{interfaces.map(iface => (
|
||||
<TableRow key={iface.iface}>
|
||||
<TableCell className="font-mono font-medium">
|
||||
{iface.iface}
|
||||
{iface.ipam_name && (
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">IPAM: {iface.ipam_name}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Badge variant="secondary">{iface.type}</Badge></TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{iface.cidr || iface.address || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{iface.gateway || '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{iface.bridge_ports || iface.bond_slaves || iface['vlan-raw-device'] || '—'}
|
||||
</TableCell>
|
||||
<TableCell>{iface.autostart === 1 ? t('common.yes') : t('common.no')}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={iface.active === 1 ? 'default' : 'secondary'}>
|
||||
{iface.active === 1 ? t('netif.active') : t('netif.inactive')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(iface)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDelete(iface.iface)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? `${t('netif.edit')} ${editing}` : t('netif.add')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('netif.type')}</Label>
|
||||
<Select value={form.type} onValueChange={v => { if (v) set('type', v); }} disabled={!!editing}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{IFACE_TYPES.map(ty => <SelectItem key={ty} value={ty}>{ty}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('netif.name')}</Label>
|
||||
<Input value={form.iface} onChange={e => set('iface', e.target.value)} disabled={!!editing} className="mt-1" placeholder="vmbr1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>CIDR</Label>
|
||||
<Input value={form.cidr} onChange={e => set('cidr', e.target.value)} className="mt-1" placeholder="192.168.1.1/24" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('netif.gateway')}</Label>
|
||||
<Input value={form.gateway} onChange={e => set('gateway', e.target.value)} className="mt-1" placeholder="192.168.1.254" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.type === 'bridge' || form.type === 'OVSBridge') && (
|
||||
<div>
|
||||
<Label>{t('netif.bridge_ports')}</Label>
|
||||
<Input value={form.bridge_ports} onChange={e => set('bridge_ports', e.target.value)} className="mt-1" placeholder="eno1 eno2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(form.type === 'bond' || form.type === 'OVSBond') && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('netif.bond_slaves')}</Label>
|
||||
<Input value={form.bond_slaves} onChange={e => set('bond_slaves', e.target.value)} className="mt-1" placeholder="eno1 eno2" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('netif.bond_mode')}</Label>
|
||||
<Input value={form.bond_mode} onChange={e => set('bond_mode', e.target.value)} className="mt-1" placeholder="balance-rr" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.type === 'vlan' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('netif.vlan_raw_device')}</Label>
|
||||
<Input value={form.vlan_raw_device} onChange={e => set('vlan_raw_device', e.target.value)} className="mt-1" placeholder="vmbr0" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('netif.vlan_id')}</Label>
|
||||
<Input value={form.vlan_id} onChange={e => set('vlan_id', e.target.value)} className="mt-1" placeholder="100" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>{t('netif.comments')}</Label>
|
||||
<Input value={form.comments} onChange={e => set('comments', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="netif_autostart" type="checkbox" checked={form.autostart} onChange={e => set('autostart', e.target.checked)} className="h-4 w-4" />
|
||||
<Label htmlFor="netif_autostart" className="cursor-pointer">{t('netif.autostart')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving || !form.iface.trim()}>
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { ProxmoxServer, ProxmoxNode, ProxmoxServerCreate } from '@/types';
|
||||
import type { ProxmoxServer, ProxmoxNode, ProxmoxServerCreate, NodeNetworkInterface } from '@/types';
|
||||
import { vmKeys } from './use-instances';
|
||||
|
||||
export const nodeKeys = {
|
||||
@@ -12,6 +12,7 @@ export const nodeKeys = {
|
||||
clusterInfo: (serverId: number) => ['cluster-info', serverId] as const,
|
||||
topology: ['cluster-topology'] as const,
|
||||
storages: (serverId: number, node: string) => ['storages', serverId, node] as const,
|
||||
networks: (serverId: number, node: string) => ['node-networks', serverId, node] as const,
|
||||
};
|
||||
|
||||
export function useServers() {
|
||||
@@ -127,3 +128,61 @@ export function useTestServerCredentials() {
|
||||
apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/test`, data),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Node network interfaces ====================
|
||||
|
||||
export function useNodeNetworks(serverId: number, node: string) {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.networks(serverId, node),
|
||||
queryFn: () =>
|
||||
apiClient.get<{ node: string; interfaces: NodeNetworkInterface[] }>(
|
||||
`/proxmox/api/servers/${serverId}/nodes/${node}/networks`,
|
||||
),
|
||||
enabled: serverId > 0 && !!node,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateNodeNetwork(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
apiClient.post(`/proxmox/api/servers/${serverId}/nodes/${node}/networks`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.networks(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNodeNetwork(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ iface, ...data }: { iface: string } & Record<string, unknown>) =>
|
||||
apiClient.put(`/proxmox/api/servers/${serverId}/nodes/${node}/networks/${iface}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.networks(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNodeNetwork(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (iface: string) =>
|
||||
apiClient.delete(`/proxmox/api/servers/${serverId}/nodes/${node}/networks/${iface}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.networks(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyNodeNetwork(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.post(`/proxmox/api/servers/${serverId}/nodes/${node}/networks/apply`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.networks(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevertNodeNetwork(serverId: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.post(`/proxmox/api/servers/${serverId}/nodes/${node}/networks/revert`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.networks(serverId, node) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,6 +279,36 @@
|
||||
"connection_ok": "Connection successful",
|
||||
"connection_failed": "Connection failed"
|
||||
},
|
||||
"netif": {
|
||||
"title": "Network Interfaces",
|
||||
"add": "Add Interface",
|
||||
"edit": "Edit",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"address": "Address",
|
||||
"gateway": "Gateway",
|
||||
"ports": "Ports / Slaves",
|
||||
"bridge_ports": "Bridge ports",
|
||||
"bond_slaves": "Bond slaves",
|
||||
"bond_mode": "Bond mode",
|
||||
"vlan_raw_device": "VLAN raw device",
|
||||
"vlan_id": "VLAN tag",
|
||||
"autostart": "Autostart",
|
||||
"comments": "Comments",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"apply": "Apply Configuration",
|
||||
"revert": "Revert",
|
||||
"apply_confirm": "Apply pending network changes on this node? Active connections may be interrupted.",
|
||||
"revert_confirm": "Discard all pending network changes on this node?",
|
||||
"pending_hint": "Changes are staged until applied.",
|
||||
"name_required": "Interface name is required",
|
||||
"created": "Interface created",
|
||||
"saved": "Interface updated",
|
||||
"deleted": "Interface deleted",
|
||||
"applied": "Network configuration applied",
|
||||
"reverted": "Pending changes reverted"
|
||||
},
|
||||
"templates": {
|
||||
"title": "OS Templates",
|
||||
"search": "Search templates...",
|
||||
|
||||
@@ -279,6 +279,36 @@
|
||||
"connection_ok": "Соединение успешно",
|
||||
"connection_failed": "Ошибка соединения"
|
||||
},
|
||||
"netif": {
|
||||
"title": "Сетевые интерфейсы",
|
||||
"add": "Добавить интерфейс",
|
||||
"edit": "Изменить",
|
||||
"name": "Имя",
|
||||
"type": "Тип",
|
||||
"address": "Адрес",
|
||||
"gateway": "Шлюз",
|
||||
"ports": "Порты / слейвы",
|
||||
"bridge_ports": "Порты моста",
|
||||
"bond_slaves": "Слейвы бонда",
|
||||
"bond_mode": "Режим бонда",
|
||||
"vlan_raw_device": "Базовый интерфейс VLAN",
|
||||
"vlan_id": "Метка VLAN",
|
||||
"autostart": "Автозапуск",
|
||||
"comments": "Комментарий",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"apply": "Применить конфигурацию",
|
||||
"revert": "Отменить изменения",
|
||||
"apply_confirm": "Применить ожидающие изменения сети на узле? Активные соединения могут быть прерваны.",
|
||||
"revert_confirm": "Отменить все ожидающие изменения сети на узле?",
|
||||
"pending_hint": "Изменения сохраняются как ожидающие до применения.",
|
||||
"name_required": "Укажите имя интерфейса",
|
||||
"created": "Интерфейс создан",
|
||||
"saved": "Интерфейс обновлён",
|
||||
"deleted": "Интерфейс удалён",
|
||||
"applied": "Конфигурация сети применена",
|
||||
"reverted": "Ожидающие изменения отменены"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблоны ОС",
|
||||
"search": "Поиск шаблонов...",
|
||||
|
||||
@@ -182,6 +182,30 @@ export interface ProxmoxNode {
|
||||
ct_count?: number;
|
||||
}
|
||||
|
||||
export interface NodeNetworkInterface {
|
||||
iface: string;
|
||||
type: string; // bridge, bond, eth, vlan, alias, OVSBridge, ...
|
||||
method?: string; // static, manual, dhcp
|
||||
method6?: string;
|
||||
address?: string;
|
||||
netmask?: string;
|
||||
cidr?: string;
|
||||
gateway?: string;
|
||||
bridge_ports?: string;
|
||||
bond_slaves?: string;
|
||||
bond_mode?: string;
|
||||
'vlan-id'?: number | string;
|
||||
'vlan-raw-device'?: string;
|
||||
autostart?: number;
|
||||
active?: number;
|
||||
exists?: number;
|
||||
comments?: string;
|
||||
// Enriched server-side with IPAM linkage when a matching network exists
|
||||
ipam_network_id?: number;
|
||||
ipam_cidr?: string;
|
||||
ipam_name?: string;
|
||||
}
|
||||
|
||||
// ==================== IPAM Types ====================
|
||||
|
||||
export interface IPAMNetwork {
|
||||
|
||||
Reference in New Issue
Block a user