Files
radadmin/frontend/src/pages/Vlans.tsx
T
shihaam 1af12891a7
build-and-push / build (push) Failing after 34s
paginate views
2026-08-01 03:02:35 +05:00

340 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}