feat(nodes): add SDN management UI
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
Surface the existing SDN backend endpoints in a new SDNManager panel on the
node detail page: zones (CRUD with type-specific fields), VNets (CRUD), and
per-VNet subnets (create/delete, optional IPAM network link), plus an apply
action with a pending-changes indicator and an availability notice.
Fix subnet deletion to pass PVE's full subnet id ({zone}-{network}-{mask})
instead of just the CIDR, which the API rejected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
import NodeNetworks from './NodeNetworks';
|
||||
import HAResources from './HAResources';
|
||||
import SDNManager from './SDNManager';
|
||||
|
||||
export default function NodeDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -72,6 +73,11 @@ export default function NodeDetailPage() {
|
||||
<HAResources serverId={sid} vms={serverVMs} />
|
||||
)}
|
||||
|
||||
{/* Software Defined Networking */}
|
||||
{nodes.length > 0 && (
|
||||
<SDNManager serverId={sid} />
|
||||
)}
|
||||
|
||||
{/* VMs on this server */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
|
||||
|
||||
485
frontend/src/features/nodes/SDNManager.tsx
Normal file
485
frontend/src/features/nodes/SDNManager.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Check, RefreshCw, Waypoints, ChevronRight } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useSDNStatus, useSDNZones, useSDNVNets, useSDNSubnets,
|
||||
useCreateSDNZone, useUpdateSDNZone, useDeleteSDNZone,
|
||||
useCreateSDNVNet, useUpdateSDNVNet, useDeleteSDNVNet,
|
||||
useCreateSDNSubnet, useDeleteSDNSubnet, useApplySDN,
|
||||
} from '@/hooks/use-sdn';
|
||||
import type { SDNZone, SDNVNet } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const ZONE_TYPES = ['simple', 'vlan', 'qinq', 'vxlan', 'evpn'];
|
||||
|
||||
// ==================== Zone dialog ====================
|
||||
|
||||
interface ZoneForm {
|
||||
zone: string;
|
||||
type: string;
|
||||
bridge: string;
|
||||
peers: string;
|
||||
mtu: string;
|
||||
nodes: string;
|
||||
dns: string;
|
||||
}
|
||||
|
||||
const emptyZone: ZoneForm = { zone: '', type: 'simple', bridge: '', peers: '', mtu: '', nodes: '', dns: '' };
|
||||
|
||||
function ZoneDialog({ serverId, open, onOpenChange, editing }: {
|
||||
serverId: number; open: boolean; onOpenChange: (v: boolean) => void; editing: SDNZone | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const createZone = useCreateSDNZone(serverId);
|
||||
const updateZone = useUpdateSDNZone(serverId);
|
||||
// Dialog is freshly mounted on each open, so init state lazily from `editing`.
|
||||
const [form, setForm] = useState<ZoneForm>(() => editing ? {
|
||||
zone: editing.zone,
|
||||
type: editing.type || 'simple',
|
||||
bridge: (editing.bridge as string) || '',
|
||||
peers: (editing.peers as string) || '',
|
||||
mtu: editing.mtu != null ? String(editing.mtu) : '',
|
||||
nodes: (editing.nodes as string) || '',
|
||||
dns: editing.dns || '',
|
||||
} : emptyZone);
|
||||
const set = <K extends keyof ZoneForm>(k: K, v: ZoneForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
const submit = () => {
|
||||
if (!form.zone.trim()) { toast.error(t('sdn.zone_name_required')); return; }
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (form.mtu.trim()) payload.mtu = Number(form.mtu);
|
||||
if (form.nodes.trim()) payload.nodes = form.nodes.trim();
|
||||
if (form.dns.trim()) payload.dns = form.dns.trim();
|
||||
if ((form.type === 'vlan' || form.type === 'qinq') && form.bridge.trim()) payload.bridge = form.bridge.trim();
|
||||
if (form.type === 'vxlan' && form.peers.trim()) payload.peers = form.peers.trim();
|
||||
|
||||
const done = (msg: string) => { toast.success(msg); onOpenChange(false); setForm(emptyZone); };
|
||||
if (editing) {
|
||||
updateZone.mutate({ zone: editing.zone, ...payload }, {
|
||||
onSuccess: () => done(t('sdn.zone_saved')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
} else {
|
||||
createZone.mutate({ zone: form.zone.trim(), type: form.type, ...payload }, {
|
||||
onSuccess: () => done(t('sdn.zone_created')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) setForm(emptyZone); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editing ? `${t('sdn.edit_zone')} ${editing.zone}` : t('sdn.add_zone')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('sdn.zone_name')}</Label>
|
||||
<Input value={form.zone} onChange={e => set('zone', e.target.value)} disabled={!!editing} className="mt-1" placeholder="zone1" maxLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('sdn.type')}</Label>
|
||||
<Select value={form.type} onValueChange={v => { if (v) set('type', v); }} disabled={!!editing}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{ZONE_TYPES.map(z => <SelectItem key={z} value={z}>{z}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{(form.type === 'vlan' || form.type === 'qinq') && (
|
||||
<div>
|
||||
<Label>{t('sdn.bridge')}</Label>
|
||||
<Input value={form.bridge} onChange={e => set('bridge', e.target.value)} className="mt-1" placeholder="vmbr0" />
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'vxlan' && (
|
||||
<div>
|
||||
<Label>{t('sdn.peers')}</Label>
|
||||
<Input value={form.peers} onChange={e => set('peers', e.target.value)} className="mt-1" placeholder="10.0.0.1,10.0.0.2" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label>MTU</Label><Input type="number" value={form.mtu} onChange={e => set('mtu', e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('sdn.nodes')}</Label><Input value={form.nodes} onChange={e => set('nodes', e.target.value)} className="mt-1" placeholder="node1,node2" /></div>
|
||||
<div><Label>DNS</Label><Input value={form.dns} onChange={e => set('dns', e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={createZone.isPending || updateZone.isPending || !form.zone.trim()}>
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== VNet dialog ====================
|
||||
|
||||
interface VNetForm { vnet: string; zone: string; tag: string; alias: string; vlanaware: boolean; }
|
||||
const emptyVNet: VNetForm = { vnet: '', zone: '', tag: '', alias: '', vlanaware: false };
|
||||
|
||||
function VNetDialog({ serverId, open, onOpenChange, editing, zones }: {
|
||||
serverId: number; open: boolean; onOpenChange: (v: boolean) => void; editing: SDNVNet | null; zones: SDNZone[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const createVNet = useCreateSDNVNet(serverId);
|
||||
const updateVNet = useUpdateSDNVNet(serverId);
|
||||
const [form, setForm] = useState<VNetForm>(() => editing ? {
|
||||
vnet: editing.vnet,
|
||||
zone: editing.zone || '',
|
||||
tag: editing.tag != null ? String(editing.tag) : '',
|
||||
alias: editing.alias || '',
|
||||
vlanaware: editing.vlanaware === 1,
|
||||
} : emptyVNet);
|
||||
const set = <K extends keyof VNetForm>(k: K, v: VNetForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
const submit = () => {
|
||||
if (!form.vnet.trim()) { toast.error(t('sdn.vnet_name_required')); return; }
|
||||
if (!editing && !form.zone) { toast.error(t('sdn.zone_required')); return; }
|
||||
const done = (msg: string) => { toast.success(msg); onOpenChange(false); setForm(emptyVNet); };
|
||||
if (editing) {
|
||||
const payload: Record<string, unknown> = { alias: form.alias || '', vlanaware: form.vlanaware ? 1 : 0 };
|
||||
if (form.tag.trim()) payload.tag = Number(form.tag);
|
||||
updateVNet.mutate({ vnet: editing.vnet, ...payload }, {
|
||||
onSuccess: () => done(t('sdn.vnet_saved')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
} else {
|
||||
const payload: Record<string, unknown> = { vnet: form.vnet.trim(), zone: form.zone, vlanaware: form.vlanaware };
|
||||
if (form.tag.trim()) payload.tag = Number(form.tag);
|
||||
if (form.alias.trim()) payload.alias = form.alias.trim();
|
||||
createVNet.mutate(payload, {
|
||||
onSuccess: () => done(t('sdn.vnet_created')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) setForm(emptyVNet); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editing ? `${t('sdn.edit_vnet')} ${editing.vnet}` : t('sdn.add_vnet')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('sdn.vnet_name')}</Label>
|
||||
<Input value={form.vnet} onChange={e => set('vnet', e.target.value)} disabled={!!editing} className="mt-1" placeholder="vnet1" maxLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('sdn.zone')}</Label>
|
||||
<Select value={form.zone} onValueChange={v => { if (v) set('zone', v); }} disabled={!!editing}>
|
||||
<SelectTrigger className="mt-1"><SelectValue placeholder={t('sdn.zone')} /></SelectTrigger>
|
||||
<SelectContent>{zones.map(z => <SelectItem key={z.zone} value={z.zone}>{z.zone} ({z.type})</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('sdn.tag')}</Label><Input type="number" value={form.tag} onChange={e => set('tag', e.target.value)} className="mt-1" placeholder="100" /></div>
|
||||
<div><Label>{t('sdn.alias')}</Label><Input value={form.alias} onChange={e => set('alias', e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="sdn_vlanaware" type="checkbox" checked={form.vlanaware} onChange={e => set('vlanaware', e.target.checked)} className="h-4 w-4" />
|
||||
<Label htmlFor="sdn_vlanaware" className="cursor-pointer">{t('sdn.vlanaware')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={createVNet.isPending || updateVNet.isPending || !form.vnet.trim()}>
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Subnet section ====================
|
||||
|
||||
interface SubnetForm { subnet: string; gateway: string; snat: boolean; dnszoneprefix: string; create_ipam_network: boolean; }
|
||||
const emptySubnet: SubnetForm = { subnet: '', gateway: '', snat: false, dnszoneprefix: '', create_ipam_network: false };
|
||||
|
||||
function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useSDNSubnets(serverId, vnet);
|
||||
const createSubnet = useCreateSDNSubnet(serverId);
|
||||
const deleteSubnet = useDeleteSDNSubnet(serverId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState<SubnetForm>(emptySubnet);
|
||||
const set = <K extends keyof SubnetForm>(k: K, v: SubnetForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
const subnets = data?.subnets ?? [];
|
||||
|
||||
const cidrOf = (s: { cidr?: string; subnet: string }) => {
|
||||
if (s.cidr) return s.cidr;
|
||||
// PVE id form: "zone-10.0.0.0-24" → take last two dash segments
|
||||
const m = s.subnet.match(/(\d+\.\d+\.\d+\.\d+)-(\d+)$/);
|
||||
return m ? `${m[1]}/${m[2]}` : s.subnet;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!form.subnet.trim()) { toast.error(t('sdn.subnet_required')); return; }
|
||||
createSubnet.mutate({
|
||||
vnet,
|
||||
subnet: form.subnet.trim(),
|
||||
gateway: form.gateway.trim() || undefined,
|
||||
snat: form.snat,
|
||||
dnszoneprefix: form.dnszoneprefix.trim() || undefined,
|
||||
create_ipam_network: form.create_ipam_network,
|
||||
}, {
|
||||
onSuccess: () => { toast.success(t('sdn.subnet_created')); setOpen(false); setForm(emptySubnet); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (subnetId: string, cidr: string) => {
|
||||
if (!confirm(`${t('sdn.delete_subnet')} "${cidr}"?`)) return;
|
||||
deleteSubnet.mutate({ vnet, subnetId }, {
|
||||
onSuccess: () => toast.success(t('sdn.subnet_deleted')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t('sdn.subnets')} — {vnet}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setForm(emptySubnet); setOpen(true); }}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />{t('sdn.add_subnet')}
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">{t('common.loading')}</p>
|
||||
) : subnets.length === 0 ? (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">{t('common.no_data')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>CIDR</TableHead>
|
||||
<TableHead>{t('sdn.gateway')}</TableHead>
|
||||
<TableHead>SNAT</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subnets.map(s => {
|
||||
const cidr = cidrOf(s);
|
||||
return (
|
||||
<TableRow key={s.subnet}>
|
||||
<TableCell className="font-mono text-xs">{cidr}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{s.gateway || '—'}</TableCell>
|
||||
<TableCell>{s.snat ? t('common.yes') : t('common.no')}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => remove(s.subnet, cidr)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('sdn.add_subnet')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>CIDR</Label><Input value={form.subnet} onChange={e => set('subnet', e.target.value)} className="mt-1" placeholder="10.0.0.0/24" /></div>
|
||||
<div><Label>{t('sdn.gateway')}</Label><Input value={form.gateway} onChange={e => set('gateway', e.target.value)} className="mt-1" placeholder="10.0.0.1" /></div>
|
||||
<div><Label>{t('sdn.dnszoneprefix')}</Label><Input value={form.dnszoneprefix} onChange={e => set('dnszoneprefix', e.target.value)} className="mt-1" /></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="sdn_snat" type="checkbox" checked={form.snat} onChange={e => set('snat', e.target.checked)} className="h-4 w-4" />
|
||||
<Label htmlFor="sdn_snat" className="cursor-pointer">{t('sdn.snat')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="sdn_ipam" type="checkbox" checked={form.create_ipam_network} onChange={e => set('create_ipam_network', e.target.checked)} className="h-4 w-4" />
|
||||
<Label htmlFor="sdn_ipam" className="cursor-pointer">{t('sdn.create_ipam')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={createSubnet.isPending || !form.subnet.trim()}>{t('common.create')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Main ====================
|
||||
|
||||
export default function SDNManager({ serverId }: { serverId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: status, isLoading: statusLoading, isFetching, refetch } = useSDNStatus(serverId);
|
||||
const available = status?.sdn_available ?? false;
|
||||
|
||||
const { data: zonesData } = useSDNZones(serverId, available);
|
||||
const { data: vnetsData } = useSDNVNets(serverId, available);
|
||||
const deleteZone = useDeleteSDNZone(serverId);
|
||||
const deleteVNet = useDeleteSDNVNet(serverId);
|
||||
const applySDN = useApplySDN(serverId);
|
||||
|
||||
const zones = zonesData?.zones ?? [];
|
||||
const vnets = vnetsData?.vnets ?? [];
|
||||
|
||||
const [zoneDialog, setZoneDialog] = useState(false);
|
||||
const [editZone, setEditZone] = useState<SDNZone | null>(null);
|
||||
const [vnetDialog, setVNetDialog] = useState(false);
|
||||
const [editVNet, setEditVNet] = useState<SDNVNet | null>(null);
|
||||
const [expandedVNet, setExpandedVNet] = useState<string | null>(null);
|
||||
|
||||
const handleApply = () => {
|
||||
if (!confirm(t('sdn.apply_confirm'))) return;
|
||||
applySDN.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('sdn.applied')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const removeZone = (zone: string) => {
|
||||
if (!confirm(`${t('sdn.delete_zone')} "${zone}"?`)) return;
|
||||
deleteZone.mutate(zone, { onSuccess: () => toast.success(t('sdn.zone_deleted')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
const removeVNet = (vnet: string) => {
|
||||
if (!confirm(`${t('sdn.delete_vnet')} "${vnet}"?`)) return;
|
||||
deleteVNet.mutate(vnet, { onSuccess: () => toast.success(t('sdn.vnet_deleted')), onError: (e: Error) => toast.error(e.message) });
|
||||
};
|
||||
|
||||
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">
|
||||
<Waypoints className="h-5 w-5" />
|
||||
{t('sdn.title')}
|
||||
{status?.pending_changes && <Badge variant="secondary">{t('sdn.pending')}</Badge>}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
{available && (
|
||||
<Button size="sm" variant="outline" onClick={handleApply} disabled={applySDN.isPending}>
|
||||
<Check className="mr-1 h-4 w-4" />{t('sdn.apply')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{statusLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : !available ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('sdn.unavailable')}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="zones">
|
||||
<TabsList>
|
||||
<TabsTrigger value="zones">{t('sdn.zones')} ({zones.length})</TabsTrigger>
|
||||
<TabsTrigger value="vnets">{t('sdn.vnets')} ({vnets.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="zones" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => { setEditZone(null); setZoneDialog(true); }}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('sdn.add_zone')}
|
||||
</Button>
|
||||
</div>
|
||||
{zones.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('sdn.zone_name')}</TableHead>
|
||||
<TableHead>{t('sdn.type')}</TableHead>
|
||||
<TableHead>MTU</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{zones.map(z => (
|
||||
<TableRow key={z.zone}>
|
||||
<TableCell className="font-mono font-medium">{z.zone}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{z.type}</Badge></TableCell>
|
||||
<TableCell>{z.mtu ?? '—'}</TableCell>
|
||||
<TableCell>{z.pending ? <Badge variant="outline">{t('sdn.pending')}</Badge> : <span className="text-xs text-muted-foreground">{t('sdn.applied_state')}</span>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditZone(z); setZoneDialog(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeZone(z.zone)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vnets" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => { setEditVNet(null); setVNetDialog(true); }} disabled={zones.length === 0}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('sdn.add_vnet')}
|
||||
</Button>
|
||||
</div>
|
||||
{vnets.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vnets.map(v => (
|
||||
<div key={v.vnet} className="rounded-md border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
onClick={() => setExpandedVNet(prev => prev === v.vnet ? null : v.vnet)}
|
||||
>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expandedVNet === v.vnet ? 'rotate-90' : ''}`} />
|
||||
<span className="font-mono font-medium">{v.vnet}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{v.zone}</Badge>
|
||||
{v.tag != null && <span className="text-xs text-muted-foreground">tag {v.tag}</span>}
|
||||
{v.alias && <span className="text-xs text-muted-foreground">— {v.alias}</span>}
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditVNet(v); setVNetDialog(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeVNet(v.vnet)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{expandedVNet === v.vnet && (
|
||||
<div className="px-3 pb-3">
|
||||
<SubnetSection serverId={serverId} vnet={v.vnet} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{zoneDialog && (
|
||||
<ZoneDialog serverId={serverId} open={zoneDialog} onOpenChange={(v) => { setZoneDialog(v); if (!v) setEditZone(null); }} editing={editZone} />
|
||||
)}
|
||||
{vnetDialog && (
|
||||
<VNetDialog serverId={serverId} open={vnetDialog} onOpenChange={(v) => { setVNetDialog(v); if (!v) setEditVNet(null); }} editing={editVNet} zones={zones} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
136
frontend/src/hooks/use-sdn.ts
Normal file
136
frontend/src/hooks/use-sdn.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { SDNStatus, SDNZone, SDNVNet, SDNSubnet } from '@/types';
|
||||
|
||||
export const sdnKeys = {
|
||||
status: (serverId: number) => ['sdn-status', serverId] as const,
|
||||
zones: (serverId: number) => ['sdn-zones', serverId] as const,
|
||||
vnets: (serverId: number) => ['sdn-vnets', serverId] as const,
|
||||
subnets: (serverId: number, vnet: string) => ['sdn-subnets', serverId, vnet] as const,
|
||||
};
|
||||
|
||||
export function useSDNStatus(serverId: number) {
|
||||
return useQuery({
|
||||
queryKey: sdnKeys.status(serverId),
|
||||
queryFn: () => apiClient.get<SDNStatus>(`/proxmox/api/servers/${serverId}/sdn/status`),
|
||||
enabled: serverId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSDNZones(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: sdnKeys.zones(serverId),
|
||||
queryFn: () => apiClient.get<{ zones: SDNZone[] }>(`/proxmox/api/servers/${serverId}/sdn/zones`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSDNVNets(serverId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: sdnKeys.vnets(serverId),
|
||||
queryFn: () => apiClient.get<{ vnets: SDNVNet[] }>(`/proxmox/api/servers/${serverId}/sdn/vnets`),
|
||||
enabled: serverId > 0 && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSDNSubnets(serverId: number, vnet: string) {
|
||||
return useQuery({
|
||||
queryKey: sdnKeys.subnets(serverId, vnet),
|
||||
queryFn: () => apiClient.get<{ subnets: SDNSubnet[] }>(`/proxmox/api/servers/${serverId}/sdn/vnets/${vnet}/subnets`),
|
||||
enabled: serverId > 0 && !!vnet,
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate the SDN status (pending indicator) alongside the changed entity.
|
||||
function useSDNInvalidator(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return (...keys: readonly unknown[][]) => {
|
||||
qc.invalidateQueries({ queryKey: sdnKeys.status(serverId) });
|
||||
keys.forEach(k => qc.invalidateQueries({ queryKey: k }));
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateSDNZone(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
apiClient.post(`/proxmox/api/servers/${serverId}/sdn/zones`, data),
|
||||
onSuccess: () => invalidate(sdnKeys.zones(serverId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSDNZone(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: ({ zone, ...data }: { zone: string } & Record<string, unknown>) =>
|
||||
apiClient.put(`/proxmox/api/servers/${serverId}/sdn/zones/${zone}`, data),
|
||||
onSuccess: () => invalidate(sdnKeys.zones(serverId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSDNZone(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: (zone: string) =>
|
||||
apiClient.delete(`/proxmox/api/servers/${serverId}/sdn/zones/${zone}`),
|
||||
onSuccess: () => invalidate(sdnKeys.zones(serverId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSDNVNet(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
apiClient.post(`/proxmox/api/servers/${serverId}/sdn/vnets`, data),
|
||||
onSuccess: () => invalidate(sdnKeys.vnets(serverId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSDNVNet(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: ({ vnet, ...data }: { vnet: string } & Record<string, unknown>) =>
|
||||
apiClient.put(`/proxmox/api/servers/${serverId}/sdn/vnets/${vnet}`, data),
|
||||
onSuccess: () => invalidate(sdnKeys.vnets(serverId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSDNVNet(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: (vnet: string) =>
|
||||
apiClient.delete(`/proxmox/api/servers/${serverId}/sdn/vnets/${vnet}`),
|
||||
onSuccess: () => invalidate(sdnKeys.vnets(serverId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSDNSubnet(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: ({ vnet, ...data }: { vnet: string } & Record<string, unknown>) =>
|
||||
apiClient.post(`/proxmox/api/servers/${serverId}/sdn/vnets/${vnet}/subnets`, data),
|
||||
onSuccess: (_d, vars) => invalidate(sdnKeys.subnets(serverId, vars.vnet)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSDNSubnet(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
// PVE identifies a subnet by its full id "{zone}-{network}-{mask}" (dash-encoded),
|
||||
// e.g. "zone1-10.0.0.0-24" — pass that id, not just the CIDR, or the delete fails.
|
||||
mutationFn: ({ vnet, subnetId, deleteIpam }: { vnet: string; subnetId: string; deleteIpam?: boolean }) => {
|
||||
const dash = subnetId.replace('/', '-');
|
||||
const qs = deleteIpam ? '?delete_ipam_network=true' : '';
|
||||
return apiClient.delete(`/proxmox/api/servers/${serverId}/sdn/vnets/${vnet}/subnets/${dash}${qs}`);
|
||||
},
|
||||
onSuccess: (_d, vars) => invalidate(sdnKeys.subnets(serverId, vars.vnet)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplySDN(serverId: number) {
|
||||
const invalidate = useSDNInvalidator(serverId);
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post(`/proxmox/api/servers/${serverId}/sdn/apply`, {}),
|
||||
onSuccess: () => invalidate(sdnKeys.zones(serverId), sdnKeys.vnets(serverId)),
|
||||
});
|
||||
}
|
||||
@@ -326,6 +326,52 @@
|
||||
"added": "Added to HA",
|
||||
"removed": "Removed from HA"
|
||||
},
|
||||
"sdn": {
|
||||
"title": "SDN (Software Defined Networking)",
|
||||
"unavailable": "SDN is not available on this server (requires Proxmox VE 7.0+).",
|
||||
"pending": "Pending changes",
|
||||
"applied_state": "Applied",
|
||||
"apply": "Apply",
|
||||
"apply_confirm": "Apply pending SDN changes to all nodes?",
|
||||
"applied": "SDN changes applied",
|
||||
"zones": "Zones",
|
||||
"vnets": "VNets",
|
||||
"subnets": "Subnets",
|
||||
"type": "Type",
|
||||
"zone": "Zone",
|
||||
"zone_name": "Zone name",
|
||||
"add_zone": "Add zone",
|
||||
"edit_zone": "Edit zone",
|
||||
"delete_zone": "Delete zone",
|
||||
"zone_created": "Zone created",
|
||||
"zone_saved": "Zone updated",
|
||||
"zone_deleted": "Zone deleted",
|
||||
"zone_name_required": "Zone name is required",
|
||||
"zone_required": "Select a zone",
|
||||
"bridge": "Bridge",
|
||||
"peers": "Peers",
|
||||
"nodes": "Nodes",
|
||||
"vnet_name": "VNet name",
|
||||
"add_vnet": "Add VNet",
|
||||
"edit_vnet": "Edit VNet",
|
||||
"delete_vnet": "Delete VNet",
|
||||
"vnet_created": "VNet created",
|
||||
"vnet_saved": "VNet updated",
|
||||
"vnet_deleted": "VNet deleted",
|
||||
"vnet_name_required": "VNet name is required",
|
||||
"tag": "Tag",
|
||||
"alias": "Alias",
|
||||
"vlanaware": "VLAN aware",
|
||||
"add_subnet": "Add subnet",
|
||||
"delete_subnet": "Delete subnet",
|
||||
"subnet_created": "Subnet created",
|
||||
"subnet_deleted": "Subnet deleted",
|
||||
"subnet_required": "Subnet CIDR is required",
|
||||
"gateway": "Gateway",
|
||||
"snat": "SNAT",
|
||||
"dnszoneprefix": "DNS zone prefix",
|
||||
"create_ipam": "Also create a matching IPAM network"
|
||||
},
|
||||
"templates": {
|
||||
"title": "OS Templates",
|
||||
"search": "Search templates...",
|
||||
|
||||
@@ -326,6 +326,52 @@
|
||||
"added": "Добавлено в HA",
|
||||
"removed": "Удалено из HA"
|
||||
},
|
||||
"sdn": {
|
||||
"title": "SDN (программно-определяемые сети)",
|
||||
"unavailable": "SDN недоступен на этом сервере (требуется Proxmox VE 7.0+).",
|
||||
"pending": "Ожидающие изменения",
|
||||
"applied_state": "Применено",
|
||||
"apply": "Применить",
|
||||
"apply_confirm": "Применить ожидающие изменения SDN на всех узлах?",
|
||||
"applied": "Изменения SDN применены",
|
||||
"zones": "Зоны",
|
||||
"vnets": "VNet'ы",
|
||||
"subnets": "Подсети",
|
||||
"type": "Тип",
|
||||
"zone": "Зона",
|
||||
"zone_name": "Имя зоны",
|
||||
"add_zone": "Добавить зону",
|
||||
"edit_zone": "Изменить зону",
|
||||
"delete_zone": "Удалить зону",
|
||||
"zone_created": "Зона создана",
|
||||
"zone_saved": "Зона обновлена",
|
||||
"zone_deleted": "Зона удалена",
|
||||
"zone_name_required": "Укажите имя зоны",
|
||||
"zone_required": "Выберите зону",
|
||||
"bridge": "Мост",
|
||||
"peers": "Пиры",
|
||||
"nodes": "Узлы",
|
||||
"vnet_name": "Имя VNet",
|
||||
"add_vnet": "Добавить VNet",
|
||||
"edit_vnet": "Изменить VNet",
|
||||
"delete_vnet": "Удалить VNet",
|
||||
"vnet_created": "VNet создан",
|
||||
"vnet_saved": "VNet обновлён",
|
||||
"vnet_deleted": "VNet удалён",
|
||||
"vnet_name_required": "Укажите имя VNet",
|
||||
"tag": "Тег",
|
||||
"alias": "Псевдоним",
|
||||
"vlanaware": "VLAN-aware",
|
||||
"add_subnet": "Добавить подсеть",
|
||||
"delete_subnet": "Удалить подсеть",
|
||||
"subnet_created": "Подсеть создана",
|
||||
"subnet_deleted": "Подсеть удалена",
|
||||
"subnet_required": "Укажите CIDR подсети",
|
||||
"gateway": "Шлюз",
|
||||
"snat": "SNAT",
|
||||
"dnszoneprefix": "Префикс DNS-зоны",
|
||||
"create_ipam": "Также создать соответствующую IPAM-сеть"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблоны ОС",
|
||||
"search": "Поиск шаблонов...",
|
||||
|
||||
@@ -233,6 +233,52 @@ export interface HAStatus {
|
||||
groups: HAGroup[];
|
||||
}
|
||||
|
||||
export interface SDNZone {
|
||||
zone: string;
|
||||
type: string; // simple, vlan, qinq, vxlan, evpn
|
||||
pending?: string | number;
|
||||
state?: string;
|
||||
mtu?: number;
|
||||
dns?: string;
|
||||
ipam?: string;
|
||||
nodes?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SDNVNet {
|
||||
vnet: string;
|
||||
zone: string;
|
||||
tag?: number;
|
||||
alias?: string;
|
||||
vlanaware?: number;
|
||||
type?: string;
|
||||
pending?: string | number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SDNSubnet {
|
||||
subnet: string; // PVE id, e.g. "zone-10.0.0.0-24"
|
||||
cidr?: string;
|
||||
gateway?: string;
|
||||
snat?: number;
|
||||
vnet?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SDNPendingChange {
|
||||
type: string;
|
||||
id: string;
|
||||
pending: unknown;
|
||||
}
|
||||
|
||||
export interface SDNStatus {
|
||||
server_id: number;
|
||||
sdn_available: boolean;
|
||||
pending_changes: boolean;
|
||||
pending: SDNPendingChange[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ==================== IPAM Types ====================
|
||||
|
||||
export interface IPAMNetwork {
|
||||
|
||||
Reference in New Issue
Block a user