refactor code, new db tables, show AP info on UI
build-and-push / build (push) Failing after 34s

This commit is contained in:
2026-08-01 02:44:50 +05:00
parent a6d4fa1f6d
commit db140912f5
13 changed files with 1103 additions and 660 deletions
+19 -9
View File
@@ -1,8 +1,9 @@
# radui — RADIUS Admin Portal
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **devices** and
**VLANs** without touching SQL. Auth is an **API key** entered on a login screen and
stored in the browser (`localStorage`), sent as `X-API-Key` on every request.
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **clients**
(customer MACs), **devices** (NAS/AP boxes seen in accounting), and **VLANs**
without touching SQL. Auth is an **API key** entered on a login screen and stored in
the browser (`localStorage`), sent as `X-API-Key` on every request.
## Stack
@@ -55,19 +56,28 @@ src/
main.tsx providers (router, auth, toaster)
App.tsx auth gate + nav + routes
auth/auth.tsx API-key auth context (login/logout, 401 handling)
lib/api.ts typed API client (device + vlan) + error handling
lib/api.ts typed API client (client + device + vlan) + error handling
lib/utils.ts cn() helper
pages/
Login.tsx API-key login screen
Devices.tsx device list + add/edit/delete dialogs
Clients.tsx client list + add/edit/delete + CSV import/export dialogs
Devices.tsx NAS device list + editable alias
Vlans.tsx VLAN list + add/rename/delete dialogs
components/ui/ button, input, label, dialog, select, table, card, badge, sonner
components/
ClientImportExport.tsx CSV import (preview/confirm) + export dialog
ui/ button, input, label, dialog, select, table, card, badge, sonner
```
## Notes
- **Add device** requires MAC, group (VLAN), name, phone; alias optional. The group
dropdown is populated from `GET /vlan/`.
- **Edit device** can change group, status, name, phone, alias (any subset).
- **Clients** — add requires MAC, group (VLAN), name, phone; alias optional. The
group dropdown is populated from `GET /vlan/`. Edit changes any subset of group,
status, name, phone, alias.
- **CSV import** parses the file client-side, does a dry-run against
`POST /client/import` to preview valid/error rows, and only writes on confirm.
Export downloads all clients as CSV.
- **Devices** — lists NAS/AP boxes from accounting, one row per **AP MAC** (with its
NAS IP and all SSIDs — extra SSIDs collapse to `SSID1 (and N more…)` on hover). The
only editable field is the human `alias` (`PUT /device/{ap_mac}`).
- Validation/`4xx` errors from the API surface as toasts with the server's message
(422 field errors are flattened to `field: message`).
+7 -2
View File
@@ -1,8 +1,9 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { LogOut, Moon, Router, Sun, Wifi } from 'lucide-react'
import { LogOut, Moon, Router, Sun, Users, Wifi } from 'lucide-react'
import { useAuth } from '@/auth/auth'
import { useTheme } from '@/lib/theme'
import { Login } from '@/pages/Login'
import { Clients } from '@/pages/Clients'
import { Devices } from '@/pages/Devices'
import { Vlans } from '@/pages/Vlans'
import { Button } from '@/components/ui/button'
@@ -39,6 +40,9 @@ function Nav() {
<Wifi className="h-5 w-5 text-primary" />
RADIUS Admin
</div>
<NavLink to="/clients" className={linkClass}>
<Users className="h-4 w-4" /> Clients
</NavLink>
<NavLink to="/devices" className={linkClass}>
<Router className="h-4 w-4" /> Devices
</NavLink>
@@ -66,9 +70,10 @@ export default function App() {
<Nav />
<main className="mx-auto max-w-6xl px-4 py-6">
<Routes>
<Route path="/clients" element={<Clients />} />
<Route path="/devices" element={<Devices />} />
<Route path="/vlans" element={<Vlans />} />
<Route path="*" element={<Navigate to="/devices" replace />} />
<Route path="*" element={<Navigate to="/clients" replace />} />
</Routes>
</main>
</div>
@@ -5,9 +5,9 @@ import { toast } from 'sonner'
import {
api,
ApiError,
type Device,
type DeviceImportResult,
type DeviceImportRow,
type Client,
type ClientImportResult,
type ClientImportRow,
} from '@/lib/api'
import { Button } from '@/components/ui/button'
import {
@@ -40,7 +40,7 @@ function downloadCsv(filename: string, csv: string) {
URL.revokeObjectURL(url)
}
function parseCsv(file: File): Promise<DeviceImportRow[]> {
function parseCsv(file: File): Promise<ClientImportRow[]> {
return new Promise((resolve, reject) => {
Papa.parse<Record<string, string>>(file, {
header: true,
@@ -64,10 +64,10 @@ function parseCsv(file: File): Promise<DeviceImportRow[]> {
})
}
export function DeviceImportExport({ onImported }: { onImported: () => void }) {
export function ClientImportExport({ onImported }: { onImported: () => void }) {
const [open, setOpen] = useState(false)
const [rows, setRows] = useState<DeviceImportRow[] | null>(null)
const [preview, setPreview] = useState<DeviceImportResult | null>(null)
const [rows, setRows] = useState<ClientImportRow[] | null>(null)
const [preview, setPreview] = useState<ClientImportResult | null>(null)
const [busy, setBusy] = useState(false)
const [exporting, setExporting] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
@@ -92,7 +92,7 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
toast.error('No rows found in that CSV')
return
}
const result = await api.devices.import(parsed, true) // dry run
const result = await api.clients.import(parsed, true) // dry run
setRows(parsed)
setPreview(result)
} catch (err) {
@@ -108,10 +108,10 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
if (!rows) return
setBusy(true)
try {
const res = await api.devices.import(rows, false)
const res = await api.clients.import(rows, false)
const skipped = res.errors.length
toast.success(
`Imported ${res.created} device${res.created === 1 ? '' : 's'}` +
`Imported ${res.created} client${res.created === 1 ? '' : 's'}` +
(skipped ? `, ${skipped} skipped` : ''),
)
onImported()
@@ -127,11 +127,11 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
async function exportAll() {
setExporting(true)
try {
const all: Device[] = []
const all: Client[] = []
const limit = 500
let offset = 0
for (;;) {
const page = await api.devices.list(limit, offset)
const page = await api.clients.list(limit, offset)
all.push(...page.items)
offset += page.items.length
if (page.items.length === 0 || all.length >= page.total) break
@@ -147,8 +147,8 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
d.status ?? '',
]),
})
downloadCsv('devices.csv', csv)
toast.success(`Exported ${all.length} device${all.length === 1 ? '' : 's'}`)
downloadCsv('clients.csv', csv)
toast.success(`Exported ${all.length} client${all.length === 1 ? '' : 's'}`)
} catch (err) {
if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Export failed')
@@ -166,9 +166,9 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
<Dialog open={open} onOpenChange={openChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import / Export devices</DialogTitle>
<DialogTitle>Import / Export clients</DialogTitle>
<DialogDescription>
Bulk-add devices from a CSV, or download all devices as a CSV.
Bulk-add clients from a CSV, or download all clients as a CSV.
</DialogDescription>
</DialogHeader>
@@ -194,7 +194,7 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
<Button
variant="outline"
size="sm"
onClick={() => downloadCsv('devices-template.csv', EXAMPLE_CSV)}
onClick={() => downloadCsv('clients-template.csv', EXAMPLE_CSV)}
>
<FileText className="h-4 w-4" /> Download
</Button>
@@ -231,8 +231,8 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
<section className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm">
<div className="font-medium">Export all devices</div>
<div className="text-muted-foreground">Download every device as a CSV file.</div>
<div className="font-medium">Export all clients</div>
<div className="text-muted-foreground">Download every client as a CSV file.</div>
</div>
<Button variant="outline" size="sm" onClick={exportAll} disabled={exporting}>
{exporting ? (
@@ -258,7 +258,7 @@ function ImportPreview({
onConfirm,
onBack,
}: {
preview: DeviceImportResult
preview: ClientImportResult
busy: boolean
onConfirm: () => void
onBack: () => void
@@ -310,7 +310,7 @@ function ImportPreview({
<p className="text-sm text-muted-foreground">
{hasErrors
? `Add the ${preview.valid} valid row${preview.valid === 1 ? '' : 's'} anyway, or go back and fix the file.`
: `All rows are valid. Import ${preview.valid} device${preview.valid === 1 ? '' : 's'}?`}
: `All rows are valid. Import ${preview.valid} client${preview.valid === 1 ? '' : 's'}?`}
</p>
)}
+39 -20
View File
@@ -80,18 +80,18 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
}
// ---- Types ----
export type DeviceStatus = 'new' | 'paid' | 'unpaid'
export type ClientStatus = 'new' | 'paid' | 'unpaid'
export interface Device {
export interface Client {
mac_address: string
group: string | null
status: DeviceStatus | null
status: ClientStatus | null
name: string | null
phone: string | null
alias: string | null
}
export interface DeviceCreate {
export interface ClientCreate {
mac_address: string
group: string
name: string
@@ -99,16 +99,16 @@ export interface DeviceCreate {
alias?: string | null
}
export interface DeviceEdit {
export interface ClientEdit {
mac_address: string
group?: string
status?: DeviceStatus
status?: ClientStatus
name?: string
phone?: string
alias?: string
}
export interface DeviceImportRow {
export interface ClientImportRow {
mac_address?: string
group?: string
name?: string
@@ -116,18 +116,26 @@ export interface DeviceImportRow {
alias?: string
}
export interface DeviceImportRowError {
export interface ClientImportRowError {
row: number
mac: string | null
detail: string
}
export interface DeviceImportResult {
export interface ClientImportResult {
total: number
valid: number
created: number
dry_run: boolean
errors: DeviceImportRowError[]
errors: ClientImportRowError[]
}
// A NAS/AP device seen in radacct (keyed by AP MAC), with an editable alias.
export interface Device {
ap_mac: string
nasipaddress: string | null
ssids: string[]
alias: string | null
}
export interface Vlan {
@@ -147,25 +155,36 @@ export const api = {
// health check to validate the API key on login
ping: () => request<unknown>('/vlan/'),
devices: {
clients: {
list: (limit = 50, offset = 0) =>
request<Page<Device>>(`/device/?limit=${limit}&offset=${offset}`),
get: (mac: string) => request<Device>(`/device/${encodeURIComponent(mac)}`),
add: (body: DeviceCreate) =>
request<Device>('/device/add', { method: 'POST', body: JSON.stringify(body) }),
edit: (body: DeviceEdit) =>
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
request<Page<Client>>(`/client/?limit=${limit}&offset=${offset}`),
get: (mac: string) => request<Client>(`/client/${encodeURIComponent(mac)}`),
add: (body: ClientCreate) =>
request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
edit: (body: ClientEdit) =>
request<Client>('/client/edit', { method: 'POST', body: JSON.stringify(body) }),
remove: (mac: string) =>
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
request<void>(`/client/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
// Bulk import. dryRun=true validates only (nothing written) so the UI can
// preview errors and confirm; dryRun=false inserts the valid rows.
import: (devices: DeviceImportRow[], dryRun: boolean) =>
request<DeviceImportResult>('/device/import', {
import: (devices: ClientImportRow[], dryRun: boolean) =>
request<ClientImportResult>('/client/import', {
method: 'POST',
body: JSON.stringify({ devices, dry_run: dryRun }),
}),
},
// NAS/AP boxes seen in radacct, keyed by AP MAC, with an editable human alias
// stored in radadmin_devices.
devices: {
list: () => request<Device[]>('/device/'),
setAlias: (apMac: string, alias: string | null) =>
request<Device>(`/device/${encodeURIComponent(apMac)}`, {
method: 'PUT',
body: JSON.stringify({ alias }),
}),
},
vlans: {
list: () => request<Vlan[]>('/vlan/'),
add: (vlanid: number, alias: string) =>
+474
View File
@@ -0,0 +1,474 @@
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 Client, type ClientStatus, type Vlan } from '@/lib/api'
import { normalizePhone, phoneError } from '@/lib/phone'
import { Button } from '@/components/ui/button'
import { ClientImportExport } from '@/components/ClientImportExport'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const STATUSES: ClientStatus[] = ['new', 'paid', 'unpaid']
function StatusBadge({ status }: { status: ClientStatus | null }) {
if (!status) return <span className="text-muted-foreground"></span>
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary'
return <Badge variant={variant}>{status}</Badge>
}
export function Clients() {
const [devices, setDevices] = useState<Client[]>([])
const [vlans, setVlans] = useState<Vlan[]>([])
const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<Client | null>(null)
const [deleting, setDeleting] = useState<Client | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const [d, v] = await Promise.all([api.clients.list(200), api.vlans.list()])
setDevices(d.items)
setVlans(v)
} catch (err) {
if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Failed to load devices')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Clients</h1>
<p className="text-sm text-muted-foreground">{devices.length} registered</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
<ClientImportExport onImported={load} />
<Button onClick={() => setAddOpen(true)}>
<Plus /> Add device
</Button>
</div>
</div>
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>MAC address</TableHead>
<TableHead>Name</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Group</TableHead>
<TableHead>Status</TableHead>
<TableHead>Alias</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && devices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : devices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
No devices yet. Add one to get started.
</TableCell>
</TableRow>
) : (
devices.map((d) => (
<TableRow key={d.mac_address}>
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell>
<TableCell>{d.name ?? '—'}</TableCell>
<TableCell>{d.phone ?? '—'}</TableCell>
<TableCell>{d.group ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell>
<StatusBadge status={d.status} />
</TableCell>
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(d)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddClientDialog
open={addOpen}
onOpenChange={setAddOpen}
vlans={vlans}
onSaved={load}
/>
<EditClientDialog
device={editing}
onClose={() => setEditing(null)}
vlans={vlans}
onSaved={load}
/>
<DeleteClientDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
</div>
)
}
// ---------- Add ----------
function AddClientDialog({
open,
onOpenChange,
vlans,
onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
vlans: Vlan[]
onSaved: () => void
}) {
const [mac, setMac] = useState('')
const [group, setGroup] = useState('')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (open) {
setMac('')
setGroup('')
setName('')
setPhone('')
setAlias('')
}
}, [open])
const phoneErr = phone.trim() ? phoneError(phone) : null
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
async function submit() {
if (!valid) return
setBusy(true)
try {
await api.clients.add({
mac_address: mac.trim(),
group,
name: name.trim(),
phone: normalizePhone(phone) ?? phone.trim(),
alias: alias.trim() || null,
})
toast.success(`Client ${mac.trim()} added`)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add device</DialogTitle>
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="MAC address" required>
<Input
placeholder="AA-BB-CC-DD-EE-FF"
className="font-mono"
value={mac}
onChange={(e) => setMac(e.target.value)}
/>
</Field>
<Field label="Group (VLAN)" required>
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" required error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias (optional)">
<Input 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 device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Edit ----------
function EditClientDialog({
device,
onClose,
vlans,
onSaved,
}: {
device: Client | null
onClose: () => void
vlans: Vlan[]
onSaved: () => void
}) {
const [group, setGroup] = useState('')
const [status, setStatus] = useState<ClientStatus>('new')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (device) {
setGroup(device.group ?? '')
setStatus(device.status ?? 'new')
setName(device.name ?? '')
setPhone(device.phone ?? '')
setAlias(device.alias ?? '')
}
}, [device])
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
async function submit() {
if (!device || phoneErr) return
setBusy(true)
try {
await api.clients.edit({
mac_address: device.mac_address,
group: group || undefined,
status,
name,
phone: normalizePhone(phone) ?? phone,
alias,
})
toast.success(`Client ${device.mac_address} updated`)
onClose()
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit device</DialogTitle>
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Group (VLAN)">
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<Field label="Status">
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Name">
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias">
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !!phoneErr}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Delete ----------
function DeleteClientDialog({
device,
onClose,
onDeleted,
}: {
device: Client | null
onClose: () => void
onDeleted: () => void
}) {
const [busy, setBusy] = useState(false)
async function confirm() {
if (!device) return
setBusy(true)
try {
await api.clients.remove(device.mac_address)
toast.success(`Client ${device.mac_address} deleted`)
onClose()
onDeleted()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete device?</DialogTitle>
<DialogDescription>
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
radusergroup and customers. This cannot be undone.
</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>
)
}
// ---------- shared bits ----------
function Field({
label,
required,
error,
children,
}: {
label: string
required?: boolean
error?: string | null
children: ReactNode
}) {
return (
<div className="space-y-1.5">
<Label>
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
)
}
function GroupSelect({
vlans,
value,
onChange,
}: {
vlans: Vlan[]
value: string
onChange: (v: string) => void
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a VLAN" />
</SelectTrigger>
<SelectContent>
{vlans.map((v) => (
<SelectItem key={v.vlanid} value={v.alias}>
{v.alias} (VLAN {v.vlanid})
</SelectItem>
))}
</SelectContent>
</Select>
)
}
+63 -349
View File
@@ -1,11 +1,8 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Loader2, Pencil, RefreshCw, Wifi } from 'lucide-react'
import { toast } from 'sonner'
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
import { normalizePhone, phoneError } from '@/lib/phone'
import { api, ApiError, type Device } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { DeviceImportExport } from '@/components/DeviceImportExport'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -24,36 +21,30 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const STATUSES: DeviceStatus[] = ['new', 'paid', 'unpaid']
function StatusBadge({ status }: { status: DeviceStatus | null }) {
if (!status) return <span className="text-muted-foreground"></span>
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary'
return <Badge variant={variant}>{status}</Badge>
function Ssids({ ssids }: { ssids: string[] }) {
if (ssids.length === 0) return <span className="text-muted-foreground"></span>
const extra = ssids.length - 1
return (
<span className="inline-flex items-center gap-1.5" title={ssids.join(', ')}>
<Wifi className="h-3.5 w-3.5 text-muted-foreground" />
{ssids[0]}
{extra > 0 && (
<span className="text-muted-foreground">(and {extra} more)</span>
)}
</span>
)
}
export function Devices() {
const [devices, setDevices] = useState<Device[]>([])
const [vlans, setVlans] = useState<Vlan[]>([])
const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<Device | null>(null)
const [deleting, setDeleting] = useState<Device | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const [d, v] = await Promise.all([api.devices.list(200), api.vlans.list()])
setDevices(d.items)
setVlans(v)
setDevices(await api.devices.list())
} catch (err) {
if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Failed to load devices')
@@ -71,71 +62,54 @@ export function Devices() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Devices</h1>
<p className="text-sm text-muted-foreground">{devices.length} registered</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
<DeviceImportExport onImported={load} />
<Button onClick={() => setAddOpen(true)}>
<Plus /> Add device
</Button>
<p className="text-sm text-muted-foreground">
{devices.length} NAS/AP {devices.length === 1 ? 'device' : 'devices'} seen in accounting
</p>
</div>
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
</div>
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>MAC address</TableHead>
<TableHead>Name</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Group</TableHead>
<TableHead>Status</TableHead>
<TableHead>AP MAC</TableHead>
<TableHead>IP</TableHead>
<TableHead>SSIDs</TableHead>
<TableHead>Alias</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
<TableHead className="w-16 text-right">Edit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && devices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : devices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
No devices yet. Add one to get started.
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
No devices have reported accounting yet.
</TableCell>
</TableRow>
) : (
devices.map((d) => (
<TableRow key={d.mac_address}>
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell>
<TableCell>{d.name ?? '—'}</TableCell>
<TableCell>{d.phone ?? '—'}</TableCell>
<TableCell>{d.group ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableRow key={d.ap_mac}>
<TableCell className="font-mono text-xs">{d.ap_mac}</TableCell>
<TableCell className="font-mono text-xs">{d.nasipaddress ?? '—'}</TableCell>
<TableCell>
<StatusBadge status={d.status} />
<Ssids ssids={d.ssids} />
</TableCell>
<TableCell>
{d.alias ?? <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(d)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit alias">
<Pencil className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
@@ -144,257 +118,38 @@ export function Devices() {
</Table>
</div>
<AddDeviceDialog
open={addOpen}
onOpenChange={setAddOpen}
vlans={vlans}
onSaved={load}
/>
<EditDeviceDialog
device={editing}
onClose={() => setEditing(null)}
vlans={vlans}
onSaved={load}
/>
<DeleteDeviceDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
<EditAliasDialog device={editing} onClose={() => setEditing(null)} onSaved={load} />
</div>
)
}
// ---------- Add ----------
function AddDeviceDialog({
open,
onOpenChange,
vlans,
onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
vlans: Vlan[]
onSaved: () => void
}) {
const [mac, setMac] = useState('')
const [group, setGroup] = useState('')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (open) {
setMac('')
setGroup('')
setName('')
setPhone('')
setAlias('')
}
}, [open])
const phoneErr = phone.trim() ? phoneError(phone) : null
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
async function submit() {
if (!valid) return
setBusy(true)
try {
await api.devices.add({
mac_address: mac.trim(),
group,
name: name.trim(),
phone: normalizePhone(phone) ?? phone.trim(),
alias: alias.trim() || null,
})
toast.success(`Device ${mac.trim()} added`)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add device</DialogTitle>
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="MAC address" required>
<Input
placeholder="AA-BB-CC-DD-EE-FF"
className="font-mono"
value={mac}
onChange={(e) => setMac(e.target.value)}
/>
</Field>
<Field label="Group (VLAN)" required>
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" required error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias (optional)">
<Input 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 device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Edit ----------
function EditDeviceDialog({
function EditAliasDialog({
device,
onClose,
vlans,
onSaved,
}: {
device: Device | null
onClose: () => void
vlans: Vlan[]
onSaved: () => void
}) {
const [group, setGroup] = useState('')
const [status, setStatus] = useState<DeviceStatus>('new')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (device) {
setGroup(device.group ?? '')
setStatus(device.status ?? 'new')
setName(device.name ?? '')
setPhone(device.phone ?? '')
setAlias(device.alias ?? '')
}
if (device) setAlias(device.alias ?? '')
}, [device])
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
async function submit() {
if (!device || phoneErr) return
setBusy(true)
try {
await api.devices.edit({
mac_address: device.mac_address,
group: group || undefined,
status,
name,
phone: normalizePhone(phone) ?? phone,
alias,
})
toast.success(`Device ${device.mac_address} updated`)
onClose()
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit device</DialogTitle>
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Group (VLAN)">
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<Field label="Status">
<Select value={status} onValueChange={(v) => setStatus(v as DeviceStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Name">
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias">
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !!phoneErr}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Delete ----------
function DeleteDeviceDialog({
device,
onClose,
onDeleted,
}: {
device: Device | null
onClose: () => void
onDeleted: () => void
}) {
const [busy, setBusy] = useState(false)
async function confirm() {
if (!device) return
setBusy(true)
try {
await api.devices.remove(device.mac_address)
toast.success(`Device ${device.mac_address} deleted`)
const value = alias.trim() || null
await api.devices.setAlias(device.ap_mac, value)
toast.success(`Alias ${value ? 'saved' : 'cleared'} for ${device.ap_mac}`)
onClose()
onDeleted()
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete device')
toast.error(err instanceof Error ? err.message : 'Failed to save alias')
} finally {
setBusy(false)
}
@@ -404,71 +159,30 @@ function DeleteDeviceDialog({
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete device?</DialogTitle>
<DialogDescription>
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
radusergroup and customers. This cannot be undone.
</DialogDescription>
<DialogTitle>Edit device alias</DialogTitle>
<DialogDescription className="font-mono">{device?.ap_mac}</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label>Alias</Label>
<Input
placeholder="e.g. Lobby AP"
value={alias}
onChange={(e) => setAlias(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
autoFocus
/>
<p className="text-xs text-muted-foreground">Leave empty to clear the alias.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<Button variant="outline" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button variant="destructive" onClick={confirm} disabled={busy}>
<Button onClick={submit} disabled={busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Delete
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- shared bits ----------
function Field({
label,
required,
error,
children,
}: {
label: string
required?: boolean
error?: string | null
children: ReactNode
}) {
return (
<div className="space-y-1.5">
<Label>
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
)
}
function GroupSelect({
vlans,
value,
onChange,
}: {
vlans: Vlan[]
value: string
onChange: (v: string) => void
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a VLAN" />
</SelectTrigger>
<SelectContent>
{vlans.map((v) => (
<SelectItem key={v.vlanid} value={v.alias}>
{v.alias} (VLAN {v.vlanid})
</SelectItem>
))}
</SelectContent>
</Select>
)
}