@@ -10,14 +10,23 @@ the RADIUS ``username``. One device touches three tables:
|
|||||||
This router hides that fan-out behind mac_address + group + status.
|
This router hides that fan-out behind mac_address + group + status.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import ValidationError
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..errors import APIError
|
from ..errors import APIError
|
||||||
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup
|
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup
|
||||||
from ..pagination import Page, PageParams
|
from ..pagination import Page, PageParams
|
||||||
from ..schemas import DeviceCreate, DeviceEdit, DeviceOut
|
from ..schemas import (
|
||||||
|
DeviceCreate,
|
||||||
|
DeviceEdit,
|
||||||
|
DeviceImportError,
|
||||||
|
DeviceImportRequest,
|
||||||
|
DeviceImportResult,
|
||||||
|
DeviceOut,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/device", tags=["device"])
|
router = APIRouter(prefix="/device", tags=["device"])
|
||||||
|
|
||||||
@@ -32,6 +41,20 @@ def _device_exists(db: Session, mac: str) -> bool:
|
|||||||
return db.execute(stmt).first() is not None
|
return db.execute(stmt).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_device(db: Session, dev: DeviceCreate) -> None:
|
||||||
|
"""Add a device's three rows (radcheck, radusergroup, customers) to the session.
|
||||||
|
|
||||||
|
Does not commit — the caller controls the transaction boundary.
|
||||||
|
"""
|
||||||
|
mac = dev.mac_address
|
||||||
|
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
||||||
|
db.add(RadUserGroup(username=mac, groupname=dev.group, priority=1))
|
||||||
|
db.add(Customer(
|
||||||
|
username=mac, mac_address=mac, status="paid",
|
||||||
|
name=dev.name, phone=dev.phone, device_alias=dev.alias,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=Page[DeviceOut])
|
@router.get("/", response_model=Page[DeviceOut])
|
||||||
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
|
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
|
||||||
"""List devices — MAC, group and status, joined from customers + radusergroup."""
|
"""List devices — MAC, group and status, joined from customers + radusergroup."""
|
||||||
@@ -78,12 +101,7 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
|
|||||||
if _device_exists(db, mac):
|
if _device_exists(db, mac):
|
||||||
raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
|
raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
|
||||||
|
|
||||||
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
_stage_device(db, payload)
|
||||||
db.add(RadUserGroup(username=mac, groupname=payload.group, priority=1))
|
|
||||||
db.add(Customer(
|
|
||||||
username=mac, mac_address=mac, status="paid",
|
|
||||||
name=payload.name, phone=payload.phone, device_alias=payload.alias,
|
|
||||||
))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return DeviceOut(
|
return DeviceOut(
|
||||||
mac_address=mac, group=payload.group, status="paid",
|
mac_address=mac, group=payload.group, status="paid",
|
||||||
@@ -91,6 +109,70 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_validation_error(exc: ValidationError) -> str:
|
||||||
|
"""Turn a pydantic ValidationError into a short, human message."""
|
||||||
|
parts = []
|
||||||
|
for err in exc.errors():
|
||||||
|
loc = ".".join(str(p) for p in err["loc"])
|
||||||
|
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
|
||||||
|
return "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=DeviceImportResult)
|
||||||
|
def import_devices(payload: DeviceImportRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Bulk-import devices from parsed CSV rows.
|
||||||
|
|
||||||
|
Every row is validated with the same rules as ``/device/add`` (MAC/phone
|
||||||
|
normalization, required fields, group-exists, duplicate MAC — both against the
|
||||||
|
DB and within the file). Errors are collected per row rather than failing the
|
||||||
|
batch. With ``dry_run`` nothing is written, so the UI can preview and confirm;
|
||||||
|
otherwise the valid rows are inserted best-effort (each in its own commit).
|
||||||
|
"""
|
||||||
|
errors: list[DeviceImportError] = []
|
||||||
|
valid: list[tuple[int, DeviceCreate]] = []
|
||||||
|
seen_macs: set[str] = set()
|
||||||
|
|
||||||
|
for idx, raw in enumerate(payload.devices, start=1):
|
||||||
|
try:
|
||||||
|
dev = DeviceCreate(**raw.model_dump())
|
||||||
|
except ValidationError as exc:
|
||||||
|
errors.append(DeviceImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
mac = dev.mac_address
|
||||||
|
if mac in seen_macs:
|
||||||
|
errors.append(DeviceImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
|
||||||
|
continue
|
||||||
|
if not _group_exists(db, dev.group):
|
||||||
|
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Group '{dev.group}' not found"))
|
||||||
|
continue
|
||||||
|
if _device_exists(db, mac):
|
||||||
|
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Device '{mac}' already exists"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_macs.add(mac)
|
||||||
|
valid.append((idx, dev))
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
if not payload.dry_run:
|
||||||
|
for idx, dev in valid:
|
||||||
|
_stage_device(db, dev)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
created += 1
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
errors.append(DeviceImportError(row=idx, mac=dev.mac_address, detail="Insert failed (integrity error)"))
|
||||||
|
|
||||||
|
return DeviceImportResult(
|
||||||
|
total=len(payload.devices),
|
||||||
|
valid=len(valid),
|
||||||
|
created=created,
|
||||||
|
dry_run=payload.dry_run,
|
||||||
|
errors=sorted(errors, key=lambda e: e.row),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/edit", response_model=DeviceOut)
|
@router.post("/edit", response_model=DeviceOut)
|
||||||
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
|
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
|
||||||
"""Edit a device — any subset of group, status, name, phone, alias."""
|
"""Edit a device — any subset of group, status, name, phone, alias."""
|
||||||
|
|||||||
@@ -209,6 +209,40 @@ class DeviceEdit(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- device CSV import ----------
|
||||||
|
class DeviceImportRow(BaseModel):
|
||||||
|
"""A single CSV row — lenient so one bad row never 422s the whole batch.
|
||||||
|
|
||||||
|
Each row is re-validated with ``DeviceCreate`` inside the router so failures
|
||||||
|
are collected per-row instead of rejecting the entire request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mac_address: str | None = None
|
||||||
|
group: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
alias: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceImportRequest(BaseModel):
|
||||||
|
devices: list[DeviceImportRow]
|
||||||
|
dry_run: bool = Field(default=False, description="Validate only; commit nothing")
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceImportError(BaseModel):
|
||||||
|
row: int # 1-based index within the submitted rows
|
||||||
|
mac: str | None = None
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceImportResult(BaseModel):
|
||||||
|
total: int # rows submitted
|
||||||
|
valid: int # rows that passed validation
|
||||||
|
created: int # rows actually inserted (0 on dry_run)
|
||||||
|
dry_run: bool
|
||||||
|
errors: list[DeviceImportError]
|
||||||
|
|
||||||
|
|
||||||
# ---------- radusergroup ----------
|
# ---------- radusergroup ----------
|
||||||
class UserGroupBase(BaseModel):
|
class UserGroupBase(BaseModel):
|
||||||
username: str = Field(max_length=64)
|
username: str = Field(max_length=64)
|
||||||
|
|||||||
Generated
+17
-2
@@ -14,9 +14,11 @@
|
|||||||
"@radix-ui/react-select": "^2.3.7",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
|
"papaparse": "^5.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -1946,12 +1948,20 @@
|
|||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/papaparse": {
|
||||||
|
"version": "5.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
||||||
|
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
@@ -2469,6 +2479,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/papaparse": {
|
||||||
|
"version": "5.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz",
|
||||||
|
"integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2779,7 +2795,6 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/use-callback-ref": {
|
"node_modules/use-callback-ref": {
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
"@radix-ui/react-select": "^2.3.7",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
|
"papaparse": "^5.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import Papa from 'papaparse'
|
||||||
|
import { AlertTriangle, ArrowDownUp, Download, FileText, Loader2, Upload } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type Device,
|
||||||
|
type DeviceImportResult,
|
||||||
|
type DeviceImportRow,
|
||||||
|
} from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const IMPORT_COLUMNS = ['mac_address', 'group', 'name', 'phone', 'alias'] as const
|
||||||
|
const EXPORT_COLUMNS = [...IMPORT_COLUMNS, 'status'] as const
|
||||||
|
|
||||||
|
const EXAMPLE_CSV = Papa.unparse({
|
||||||
|
fields: [...IMPORT_COLUMNS],
|
||||||
|
data: [
|
||||||
|
['AA-BB-CC-DD-EE-FF', 'customer', 'Ali Hassan', '7712345', 'Living Room TV'],
|
||||||
|
['11-22-33-44-55-66', 'staff', 'Sara Ibrahim', '9998887', ''],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
function downloadCsv(filename: string, csv: string) {
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(file: File): Promise<DeviceImportRow[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Papa.parse<Record<string, string>>(file, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: 'greedy',
|
||||||
|
transformHeader: (h) => h.trim().toLowerCase(),
|
||||||
|
complete: (res) => {
|
||||||
|
const rows = res.data.map((r) => {
|
||||||
|
const pick = (k: string) => (r[k] ?? '').trim() || undefined
|
||||||
|
return {
|
||||||
|
mac_address: pick('mac_address'),
|
||||||
|
group: pick('group'),
|
||||||
|
name: pick('name'),
|
||||||
|
phone: pick('phone'),
|
||||||
|
alias: pick('alias'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resolve(rows)
|
||||||
|
},
|
||||||
|
error: (err) => reject(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [rows, setRows] = useState<DeviceImportRow[] | null>(null)
|
||||||
|
const [preview, setPreview] = useState<DeviceImportResult | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setRows(null)
|
||||||
|
setPreview(null)
|
||||||
|
setBusy(false)
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChange(o: boolean) {
|
||||||
|
setOpen(o)
|
||||||
|
if (!o) reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFile(file: File) {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const parsed = await parseCsv(file)
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
toast.error('No rows found in that CSV')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await api.devices.import(parsed, true) // dry run
|
||||||
|
setRows(parsed)
|
||||||
|
setPreview(result)
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to read CSV')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImport() {
|
||||||
|
if (!rows) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await api.devices.import(rows, false)
|
||||||
|
const skipped = res.errors.length
|
||||||
|
toast.success(
|
||||||
|
`Imported ${res.created} device${res.created === 1 ? '' : 's'}` +
|
||||||
|
(skipped ? `, ${skipped} skipped` : ''),
|
||||||
|
)
|
||||||
|
onImported()
|
||||||
|
openChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAll() {
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
const all: Device[] = []
|
||||||
|
const limit = 500
|
||||||
|
let offset = 0
|
||||||
|
for (;;) {
|
||||||
|
const page = await api.devices.list(limit, offset)
|
||||||
|
all.push(...page.items)
|
||||||
|
offset += page.items.length
|
||||||
|
if (page.items.length === 0 || all.length >= page.total) break
|
||||||
|
}
|
||||||
|
const csv = Papa.unparse({
|
||||||
|
fields: [...EXPORT_COLUMNS],
|
||||||
|
data: all.map((d) => [
|
||||||
|
d.mac_address,
|
||||||
|
d.group ?? '',
|
||||||
|
d.name ?? '',
|
||||||
|
d.phone ?? '',
|
||||||
|
d.alias ?? '',
|
||||||
|
d.status ?? '',
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
downloadCsv('devices.csv', csv)
|
||||||
|
toast.success(`Exported ${all.length} device${all.length === 1 ? '' : 's'}`)
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Export failed')
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<ArrowDownUp className="h-4 w-4" /> Import / Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import / Export devices</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Bulk-add devices from a CSV, or download all devices as a CSV.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<ImportPreview
|
||||||
|
preview={preview}
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={confirmImport}
|
||||||
|
onBack={reset}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Example */}
|
||||||
|
<section className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">Example CSV</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Columns: <span className="font-mono text-xs">{IMPORT_COLUMNS.join(', ')}</span>{' '}
|
||||||
|
— <span className="font-mono text-xs">alias</span> optional.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadCsv('devices-template.csv', EXAMPLE_CSV)}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" /> Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Import */}
|
||||||
|
<section className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">Import from CSV</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
You'll see a preview and confirm before anything is saved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
Choose file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
if (f) onFile(f)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<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>
|
||||||
|
<Button variant="outline" size="sm" onClick={exportAll} disabled={exporting}>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportPreview({
|
||||||
|
preview,
|
||||||
|
busy,
|
||||||
|
onConfirm,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
preview: DeviceImportResult
|
||||||
|
busy: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
const hasErrors = preview.errors.length > 0
|
||||||
|
const nothingValid = preview.valid === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
|
<span className="rounded-md bg-muted px-2 py-1">{preview.total} rows</span>
|
||||||
|
<span className="rounded-md bg-emerald-500/10 px-2 py-1 text-emerald-600 dark:text-emerald-400">
|
||||||
|
{preview.valid} valid
|
||||||
|
</span>
|
||||||
|
{hasErrors && (
|
||||||
|
<span className="rounded-md bg-destructive/10 px-2 py-1 text-destructive">
|
||||||
|
{preview.errors.length} with errors
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasErrors && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||||
|
These rows will be skipped:
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{preview.errors.map((e) => (
|
||||||
|
<tr key={`${e.row}-${e.mac ?? ''}`} className="align-top">
|
||||||
|
<td className="w-14 px-3 py-2 text-muted-foreground">#{e.row}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{e.mac || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-destructive">{e.detail}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nothingValid ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No valid rows to import. Fix the CSV and try again.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<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'}?`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onBack} disabled={busy}>
|
||||||
|
{hasErrors ? 'Cancel & retry' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} disabled={busy || nothingValid}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{hasErrors ? `Add anyway (${preview.valid})` : `Import ${preview.valid}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -108,6 +108,28 @@ export interface DeviceEdit {
|
|||||||
alias?: string
|
alias?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceImportRow {
|
||||||
|
mac_address?: string
|
||||||
|
group?: string
|
||||||
|
name?: string
|
||||||
|
phone?: string
|
||||||
|
alias?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceImportRowError {
|
||||||
|
row: number
|
||||||
|
mac: string | null
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceImportResult {
|
||||||
|
total: number
|
||||||
|
valid: number
|
||||||
|
created: number
|
||||||
|
dry_run: boolean
|
||||||
|
errors: DeviceImportRowError[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Vlan {
|
export interface Vlan {
|
||||||
alias: string
|
alias: string
|
||||||
vlanid: number
|
vlanid: number
|
||||||
@@ -135,6 +157,13 @@ export const api = {
|
|||||||
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
|
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
remove: (mac: string) =>
|
remove: (mac: string) =>
|
||||||
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
|
request<void>(`/device/${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', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ devices, dry_run: dryRun }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
vlans: {
|
vlans: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
|||||||
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
|
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
|
||||||
import { normalizePhone, phoneError } from '@/lib/phone'
|
import { normalizePhone, phoneError } from '@/lib/phone'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DeviceImportExport } from '@/components/DeviceImportExport'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -76,6 +77,7 @@ export function Devices() {
|
|||||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<DeviceImportExport onImported={load} />
|
||||||
<Button onClick={() => setAddOpen(true)}>
|
<Button onClick={() => setAddOpen(true)}>
|
||||||
<Plus /> Add device
|
<Plus /> Add device
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user