import/export devices
build-and-push / build (push) Failing after 17s

This commit is contained in:
2026-08-01 01:47:42 +05:00
parent 6fe7c69adb
commit a6d4fa1f6d
7 changed files with 501 additions and 9 deletions
+17 -2
View File
@@ -14,9 +14,11 @@
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@tailwindcss/vite": "^4.3.3",
"@types/papaparse": "^5.5.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.28.0",
"papaparse": "^5.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"sonner": "^2.0.7",
@@ -1946,12 +1948,20 @@
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"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": {
"version": "19.2.18",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2779,7 +2795,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/use-callback-ref": {
+2
View File
@@ -16,9 +16,11 @@
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@tailwindcss/vite": "^4.3.3",
"@types/papaparse": "^5.5.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.28.0",
"papaparse": "^5.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"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>
)
}
+29
View File
@@ -108,6 +108,28 @@ export interface DeviceEdit {
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 {
alias: string
vlanid: number
@@ -135,6 +157,13 @@ export const api = {
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
remove: (mac: string) =>
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: {
+2
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
import { normalizePhone, phoneError } from '@/lib/phone'
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'
@@ -76,6 +77,7 @@ export function Devices() {
<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>