340 lines
9.9 KiB
TypeScript
340 lines
9.9 KiB
TypeScript
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import { api, ApiError, type Vlan } from '@/lib/api'
|
||
import { TablePagination, usePageSize, usePagination } from '@/components/Pagination'
|
||
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,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
|
||
export function Vlans() {
|
||
const [vlans, setVlans] = useState<Vlan[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [addOpen, setAddOpen] = useState(false)
|
||
const [editing, setEditing] = useState<Vlan | null>(null)
|
||
const [deleting, setDeleting] = useState<Vlan | null>(null)
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
setVlans(await api.vlans.list())
|
||
} catch (err) {
|
||
if (!(err instanceof ApiError && err.status === 401))
|
||
toast.error(err instanceof Error ? err.message : 'Failed to load VLANs')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
load()
|
||
}, [load])
|
||
|
||
const [pageSize, setPageSize] = usePageSize()
|
||
const { page, setPage, pageCount, pageItems, total } = usePagination(vlans, pageSize)
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold">VLANs</h1>
|
||
<p className="text-sm text-muted-foreground">{vlans.length} configured</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||
</Button>
|
||
<Button onClick={() => setAddOpen(true)}>
|
||
<Plus /> Add VLAN
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border bg-background">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-32">VLAN ID</TableHead>
|
||
<TableHead>Alias</TableHead>
|
||
<TableHead className="w-24 text-right">Actions</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{loading && vlans.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||
</TableCell>
|
||
</TableRow>
|
||
) : vlans.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
||
No VLANs configured.
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
pageItems.map((v) => (
|
||
<TableRow key={v.vlanid}>
|
||
<TableCell>
|
||
<Badge variant="outline">VLAN {v.vlanid}</Badge>
|
||
</TableCell>
|
||
<TableCell className="font-medium">{v.alias}</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex justify-end gap-1">
|
||
<Button variant="ghost" size="icon" onClick={() => setEditing(v)} title="Rename">
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="text-destructive hover:text-destructive"
|
||
onClick={() => setDeleting(v)}
|
||
title="Delete"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
<TablePagination
|
||
page={page}
|
||
pageCount={pageCount}
|
||
total={total}
|
||
pageSize={pageSize}
|
||
onPageChange={setPage}
|
||
onPageSizeChange={setPageSize}
|
||
/>
|
||
</div>
|
||
|
||
<AddVlanDialog open={addOpen} onOpenChange={setAddOpen} onSaved={load} />
|
||
<RenameVlanDialog vlan={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||
<DeleteVlanDialog vlan={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AddVlanDialog({
|
||
open,
|
||
onOpenChange,
|
||
onSaved,
|
||
}: {
|
||
open: boolean
|
||
onOpenChange: (o: boolean) => void
|
||
onSaved: () => void
|
||
}) {
|
||
const [vlanid, setVlanid] = useState('')
|
||
const [alias, setAlias] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
setVlanid('')
|
||
setAlias('')
|
||
}
|
||
}, [open])
|
||
|
||
const id = Number(vlanid)
|
||
const valid = vlanid !== '' && Number.isInteger(id) && id >= 1 && id <= 4094 && alias.trim()
|
||
|
||
async function submit() {
|
||
if (!valid) return
|
||
setBusy(true)
|
||
try {
|
||
await api.vlans.add(id, alias.trim())
|
||
toast.success(`VLAN ${id} (${alias.trim()}) added`)
|
||
onOpenChange(false)
|
||
onSaved()
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Failed to add VLAN')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Add VLAN</DialogTitle>
|
||
<DialogDescription>
|
||
Creates the Tunnel-Type / Medium-Type / Private-Group-Id rows for this group.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3">
|
||
<Field label="VLAN ID (1–4094)" required>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={4094}
|
||
placeholder="55"
|
||
value={vlanid}
|
||
onChange={(e) => setVlanid(e.target.value)}
|
||
/>
|
||
</Field>
|
||
<Field label="Alias (group name)" required>
|
||
<Input placeholder="staff" value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||
</Field>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={submit} disabled={busy || !valid}>
|
||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
Add VLAN
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function RenameVlanDialog({
|
||
vlan,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
vlan: Vlan | null
|
||
onClose: () => void
|
||
onSaved: () => void
|
||
}) {
|
||
const [alias, setAlias] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (vlan) setAlias(vlan.alias)
|
||
}, [vlan])
|
||
|
||
async function submit() {
|
||
if (!vlan || !alias.trim()) return
|
||
setBusy(true)
|
||
try {
|
||
await api.vlans.edit(vlan.vlanid, alias.trim())
|
||
toast.success(`VLAN ${vlan.vlanid} renamed to ${alias.trim()}`)
|
||
onClose()
|
||
onSaved()
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Failed to rename VLAN')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={!!vlan} onOpenChange={(o) => !o && onClose()}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Rename VLAN {vlan?.vlanid}</DialogTitle>
|
||
<DialogDescription>
|
||
Updates the group name across its radgroupreply rows. Device group assignments in
|
||
radusergroup are not renamed automatically.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Field label="Alias (group name)" required>
|
||
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||
</Field>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={onClose}>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={submit} disabled={busy || !alias.trim()}>
|
||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
Save
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function DeleteVlanDialog({
|
||
vlan,
|
||
onClose,
|
||
onDeleted,
|
||
}: {
|
||
vlan: Vlan | null
|
||
onClose: () => void
|
||
onDeleted: () => void
|
||
}) {
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
async function confirm() {
|
||
if (!vlan) return
|
||
setBusy(true)
|
||
try {
|
||
await api.vlans.remove(vlan.vlanid)
|
||
toast.success(`VLAN ${vlan.vlanid} deleted`)
|
||
onClose()
|
||
onDeleted()
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Failed to delete VLAN')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={!!vlan} onOpenChange={(o) => !o && onClose()}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Delete VLAN {vlan?.vlanid}?</DialogTitle>
|
||
<DialogDescription>
|
||
Removes all radgroupreply rows for <span className="font-medium">{vlan?.alias}</span>.
|
||
Devices still assigned to this group will lose their reply attributes.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={onClose}>
|
||
Cancel
|
||
</Button>
|
||
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
Delete
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
required,
|
||
children,
|
||
}: {
|
||
label: string
|
||
required?: boolean
|
||
children: ReactNode
|
||
}) {
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<Label>
|
||
{label}
|
||
{required && <span className="text-destructive"> *</span>}
|
||
</Label>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|