import { useState } from 'react' import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { ClusterOutlined, PlusOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import ActionButtons from '../../components/ActionButtons' import apiClient, { isEnvelope } from '../../api/client' interface NetworkInterface { id: number name: string type: 'ethernet' | 'vlan' | 'bond' | 'bridge' | 'wireguard' parent?: string | null vlan_id?: number | null members: string[] role: string mtu?: number | null active: boolean description?: string | null created_at: string updated_at: string } interface IfaceFormValues { name: string type: NetworkInterface['type'] parent?: string vlan_id?: number members?: string[] role: string mtu?: number active: boolean description?: string } interface SystemInterface { ifname: string link_type?: string address?: string flags?: string[] addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }> rx_bytes: number; tx_bytes: number rx_packets: number; tx_packets: number rx_drop: number; tx_drop: number } function fmtBytes(n: number): string { if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB' if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB' if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' return n + ' B' } async function listInterfaces(): Promise { const r = await apiClient.get('/network-interfaces') if (!isEnvelope(r.data)) return [] return (r.data.data as { interfaces?: NetworkInterface[] }).interfaces ?? [] } async function listSystemInterfaces(): Promise { const r = await apiClient.get('/system/interfaces') if (!isEnvelope(r.data)) return [] return (r.data.data as { interfaces?: SystemInterface[] }).interfaces ?? [] } interface FwZone { id: number; name: string; description?: string | null; builtin: boolean } async function listZones(): Promise { const r = await apiClient.get('/firewall/zones') if (!isEnvelope(r.data)) return [] return (r.data.data as { zones?: FwZone[] }).zones ?? [] } export default function InterfacesTab() { const { t } = useTranslation() const qc = useQueryClient() const { data: ifs, isLoading } = useQuery({ queryKey: ['network-interfaces'], queryFn: listInterfaces }) const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces, refetchInterval: 10_000 }) const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones }) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const create = useMutation({ mutationFn: async (v: IfaceFormValues) => { await apiClient.post('/network-interfaces', v) }, onSuccess: () => { message.success(t('common.save')) setCreating(false); form.resetFields() void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) }, }) const update = useMutation({ mutationFn: async ({ id, v }: { id: number; v: IfaceFormValues }) => { await apiClient.put(`/network-interfaces/${id}`, v) }, onSuccess: () => { message.success(t('common.save')) setEditing(null); form.resetFields() void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) }, }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/network-interfaces/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) }, }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: NetworkInterface; checked: boolean }) => { const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row await apiClient.put(`/network-interfaces/${id}`, { ...body, active: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) }, onError: (e: Error) => message.error(e.message), }) // Stable colour palette for role tags. Builtin zones get a fixed // colour; custom zones cycle through the palette by name hash so // the same custom zone always shows up in the same shade. const PALETTE = ['blue', 'green', 'orange', 'purple', 'magenta', 'cyan', 'gold', 'volcano', 'geekblue'] const FIXED: Record = { wan: 'blue', lan: 'green', dmz: 'orange', mgmt: 'purple', cluster: 'magenta' } const roleColor = (r: string): string => { if (FIXED[r]) return FIXED[r] let h = 0 for (let i = 0; i < r.length; i++) h = (h * 31 + r.charCodeAt(i)) >>> 0 return PALETTE[h % PALETTE.length] } const columns: ColumnsType = [ { title: t('networks.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, { title: t('networks.type'), dataIndex: 'type', key: 'type' }, { title: t('networks.composition'), key: 'composition', render: (_, row) => { if (row.type === 'vlan') return {row.parent}.{row.vlan_id} if (row.type === 'bridge' || row.type === 'bond') { return {(row.members ?? []).map((m) => {m})} } return '—' }, }, { title: t('networks.role'), dataIndex: 'role', key: 'role', render: (r: string) => {r.toUpperCase()}, }, { title: t('networks.mtu'), dataIndex: 'mtu', key: 'mtu', render: (v?: number) => v ?? '—' }, { title: t('networks.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, row: NetworkInterface) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( { setEditing(row) form.setFieldsValue({ name: row.name, type: row.type, parent: row.parent ?? undefined, vlan_id: row.vlan_id ?? undefined, members: row.members ?? [], role: row.role, mtu: row.mtu ?? undefined, active: row.active, description: row.description ?? undefined, }) }} onDelete={() => del.mutate(row.id)} deleteConfirm={t('networks.deleteConfirm', { name: row.name })} /> ), }, ] const openCreate = () => { setCreating(true); form.resetFields() form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true }) } return (
i.ifname !== 'lo')} rowKey="ifname" pagination={false} columns={[ { title: t('networks.name'), dataIndex: 'ifname', key: 'ifname', width: 120, render: (s: string) => {s}, }, { title: t('networks.addresses'), key: 'addrs', render: (_, row: SystemInterface) => { const addrs = (row.addr_info ?? []).map(a => `${a.local}/${a.prefixlen}`) return addrs.length ? {addrs.map(a => {a})} : }, }, { title: '▼ RX', key: 'rx', width: 130, render: (_, row: SystemInterface) => ( {fmtBytes(row.rx_bytes ?? 0)} ), }, { title: '▲ TX', key: 'tx', width: 130, render: (_, row: SystemInterface) => ( {fmtBytes(row.tx_bytes ?? 0)} ), }, { title: t('networks.linkType'), dataIndex: 'link_type', key: 'link_type', width: 90, render: (v?: string) => v ? {v} : '—', }, ]} locale={{ emptyText: t('networks.systemEmpty') }} /> } onClick={openCreate}> {t('networks.addInterface')} } emptyContent={ } title={t('networks.emptyTitle')} description={t('networks.emptyDesc')} action={ } /> } /> { setEditing(null); setCreating(false) }} onOk={() => { void form.submit() }} confirmLoading={create.isPending || update.isPending} width={560} >
{ if (editing) update.mutate({ id: editing.id, v }) else create.mutate(v) }} > ) } if (tp === 'bridge' || tp === 'bond') { return ( ({ value: z.name, label: z.builtin ? z.name.toUpperCase() : `${z.name.toUpperCase()} (custom)`, }))} />
) }