feat(14-03): add site dashboard components (health grid, sector view, links tab)

- SiteHealthGrid shows device cards with status dots, CPU/memory bars, uptime
- SectorFormDialog supports create and edit modes for sectors
- SiteSectorView groups APs by sector with collapsible sections, connected CPE lists, aggregate stats, sector assignment dropdown
- SiteLinksTab wraps WirelessLinksTable with siteId filtering
- Add sector_id and sector_name to DeviceResponse, site_id/sector_id to DeviceListParams
This commit is contained in:
Jason Staack
2026-03-19 06:53:22 -05:00
parent 3f7fa7d62c
commit d89233bcf5
8 changed files with 810 additions and 14 deletions

View File

@@ -45,9 +45,9 @@
### Wireless UI ### Wireless UI
- [ ] **WRUI-01**: Device detail page shows a per-station wireless table (connected clients with MAC, signal, CCQ, TX/RX rates, distance, uptime) - [x] **WRUI-01**: Device detail page shows a per-station wireless table (connected clients with MAC, signal, CCQ, TX/RX rates, distance, uptime)
- [ ] **WRUI-02**: Device detail page shows per-interface RF stats (noise floor, channel width, TX power) - [x] **WRUI-02**: Device detail page shows per-interface RF stats (noise floor, channel width, TX power)
- [ ] **WRUI-03**: Wireless links page shows all discovered AP-CPE relationships with signal quality and link state - [x] **WRUI-03**: Wireless links page shows all discovered AP-CPE relationships with signal quality and link state
### Signal Trending ### Signal Trending
@@ -117,9 +117,9 @@
| LINK-02 | Phase 13 | Complete | | LINK-02 | Phase 13 | Complete |
| LINK-03 | Phase 13 | Complete | | LINK-03 | Phase 13 | Complete |
| LINK-04 | Phase 13 | Complete | | LINK-04 | Phase 13 | Complete |
| WRUI-01 | Phase 14 | Pending | | WRUI-01 | Phase 14 | Complete |
| WRUI-02 | Phase 14 | Pending | | WRUI-02 | Phase 14 | Complete |
| WRUI-03 | Phase 14 | Pending | | WRUI-03 | Phase 14 | Complete |
| TRND-01 | Phase 15 | Pending | | TRND-01 | Phase 15 | Pending |
| TRND-02 | Phase 15 | Pending | | TRND-02 | Phase 15 | Pending |
| ALRT-01 | Phase 15 | Pending | | ALRT-01 | Phase 15 | Pending |

View File

@@ -101,7 +101,7 @@ Plans:
3. Site dashboard displays wireless link topology showing which CPEs connect to which APs with signal quality indicators 3. Site dashboard displays wireless link topology showing which CPEs connect to which APs with signal quality indicators
4. Device detail page shows a per-station wireless table (connected clients with MAC, signal, CCQ, TX/RX rates, distance, uptime) and per-interface RF stats 4. Device detail page shows a per-station wireless table (connected clients with MAC, signal, CCQ, TX/RX rates, distance, uptime) and per-interface RF stats
5. Operator can define sectors within a site, assign APs to sectors, and view aggregate stats per sector 5. Operator can define sectors within a site, assign APs to sectors, and view aggregate stats per sector
**Plans:** 1/3 plans executed **Plans:** 2/3 plans executed
Plans: Plans:
- [ ] 14-01-PLAN.md — Sector backend (migration, model, service, router), site_id device filter, wireless data APIs, frontend API clients - [ ] 14-01-PLAN.md — Sector backend (migration, model, service, router), site_id device filter, wireless data APIs, frontend API clients
@@ -128,7 +128,7 @@ Plans:
| Category | Requirements | Phase | Count | | Category | Requirements | Phase | Count |
|----------|-------------|-------|-------| |----------|-------------|-------|-------|
| Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 | | Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 |
| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 1/3 | In Progress| | SECT-01, SECT-02, SECT-03 | 14 | 3 | | Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 2/3 | In Progress| | SECT-01, SECT-02, SECT-03 | 14 | 3 |
| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 | | Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 |
| Signal Trending | TRND-01, TRND-02 | 15 | 2 | | Signal Trending | TRND-01, TRND-02 | 15 | 2 |
| Site Alerting | ALRT-01, ALRT-02 | 15 | 2 | | Site Alerting | ALRT-01, ALRT-02 | 15 | 2 |

View File

@@ -3,13 +3,13 @@ gsd_state_version: 1.0
milestone: v9.7 milestone: v9.7
milestone_name: Tower & Site Management milestone_name: Tower & Site Management
status: unknown status: unknown
stopped_at: Completed 14-01-PLAN.md stopped_at: Completed 14-02-PLAN.md
last_updated: "2026-03-19T11:43:25.898Z" last_updated: "2026-03-19T11:48:58.364Z"
progress: progress:
total_phases: 5 total_phases: 5
completed_phases: 3 completed_phases: 3
total_plans: 11 total_plans: 11
completed_plans: 9 completed_plans: 10
--- ---
# Project State # Project State
@@ -24,7 +24,7 @@ See: .planning/PROJECT.md (updated 2026-03-18)
## Current Position ## Current Position
Phase: 14 (site-dashboard-sector-views-wireless-ui) — EXECUTING Phase: 14 (site-dashboard-sector-views-wireless-ui) — EXECUTING
Plan: 2 of 3 Plan: 3 of 3
## Performance Metrics ## Performance Metrics
@@ -44,6 +44,7 @@ Plan: 2 of 3
| Phase 13 P01 | 5min | 2 tasks | 4 files | | Phase 13 P01 | 5min | 2 tasks | 4 files |
| Phase 13 P03 | 3min | 2 tasks | 6 files | | Phase 13 P03 | 3min | 2 tasks | 6 files |
| Phase 14 P01 | 3min | 2 tasks | 15 files | | Phase 14 P01 | 3min | 2 tasks | 15 files |
| Phase 14 P02 | 3min | 2 tasks | 9 files |
## Accumulated Context ## Accumulated Context
@@ -78,6 +79,9 @@ Decisions are logged in PROJECT.md Key Decisions table.
- [Phase 14]: Sector CRUD nested under sites path (/sites/{sid}/sectors) matching REST hierarchy - [Phase 14]: Sector CRUD nested under sites path (/sites/{sid}/sectors) matching REST hierarchy
- [Phase 14]: Device sector assignment uses PUT /devices/{did}/sector with nullable sector_id for set/clear - [Phase 14]: Device sector assignment uses PUT /devices/{did}/sector with nullable sector_id for set/clear
- [Phase 14]: Wireless registration queries join device_interfaces for MAC-to-hostname resolution - [Phase 14]: Wireless registration queries join device_interfaces for MAC-to-hostname resolution
- [Phase 14]: Shared signalColor helper in separate module for reuse across wireless components
- [Phase 14]: Wireless links grouped by AP hostname with nested CPE rows for topology clarity
- [Phase 14]: Sidebar Wireless Links href is tenant-scoped for non-super_admin users
### Pending Todos ### Pending Todos
@@ -91,6 +95,6 @@ None yet.
## Session Continuity ## Session Continuity
Last session: 2026-03-19T11:43:25.894Z Last session: 2026-03-19T11:48:58.361Z
Stopped at: Completed 14-01-PLAN.md Stopped at: Completed 14-02-PLAN.md
Resume file: None Resume file: None

View File

@@ -0,0 +1,136 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { sectorsApi, type SectorResponse, type SectorCreate, type SectorUpdate } from '@/lib/api'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
interface SectorFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
tenantId: string
siteId: string
sector?: SectorResponse | null
}
export function SectorFormDialog({ open, onOpenChange, tenantId, siteId, sector }: SectorFormDialogProps) {
const queryClient = useQueryClient()
const isEdit = !!sector
const [name, setName] = useState('')
const [azimuth, setAzimuth] = useState('')
const [description, setDescription] = useState('')
useEffect(() => {
if (sector) {
setName(sector.name)
setAzimuth(sector.azimuth != null ? String(sector.azimuth) : '')
setDescription(sector.description ?? '')
} else {
setName('')
setAzimuth('')
setDescription('')
}
}, [sector, open])
const createMutation = useMutation({
mutationFn: (data: SectorCreate) => sectorsApi.create(tenantId, siteId, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['sectors', tenantId, siteId] })
onOpenChange(false)
},
})
const updateMutation = useMutation({
mutationFn: (data: SectorUpdate) => sectorsApi.update(tenantId, siteId, sector!.id, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['sectors', tenantId, siteId] })
onOpenChange(false)
},
})
const isPending = createMutation.isPending || updateMutation.isPending
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const data = {
name: name.trim(),
azimuth: azimuth ? parseFloat(azimuth) : null,
description: description.trim() || null,
}
if (isEdit) {
updateMutation.mutate(data)
} else {
createMutation.mutate(data)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Sector' : 'Add Sector'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update sector details.' : 'Create a new sector to organize APs by direction.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sector-name">Name *</Label>
<Input
id="sector-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="North Sector"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sector-azimuth">Azimuth</Label>
<Input
id="sector-azimuth"
type="number"
min={0}
max={360}
value={azimuth}
onChange={(e) => setAzimuth(e.target.value)}
placeholder="0-360"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sector-description">Description</Label>
<textarea
id="sector-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Details about this sector..."
rows={3}
className="flex w-full rounded-md border border-border bg-elevated/50 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted transition-colors focus:border-accent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || isPending}>
{isEdit ? 'Save Changes' : 'Create Sector'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,158 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { devicesApi, metricsApi, type DeviceResponse } from '@/lib/api'
import { cn } from '@/lib/utils'
import { formatUptime } from '@/lib/utils'
interface SiteHealthGridProps {
tenantId: string
siteId: string
}
function cpuColor(pct: number | null): string {
if (pct == null) return 'bg-elevated'
if (pct >= 90) return 'bg-error'
if (pct >= 70) return 'bg-warning'
return 'bg-success'
}
function memColor(pct: number | null): string {
if (pct == null) return 'bg-elevated'
if (pct >= 90) return 'bg-error'
if (pct >= 70) return 'bg-warning'
return 'bg-success'
}
function StatusDot({ status }: { status: string }) {
const styles: Record<string, string> = {
online: 'bg-online shadow-[0_0_6px_hsl(var(--online)/0.3)]',
offline: 'bg-offline shadow-[0_0_6px_hsl(var(--offline)/0.3)]',
unknown: 'bg-unknown',
}
return (
<span
className={cn('inline-block w-2 h-2 rounded-full flex-shrink-0', styles[status] ?? styles.unknown)}
title={status}
/>
)
}
function borderColor(status: string): string {
if (status === 'online') return 'border-success/50'
if (status === 'offline') return 'border-error/50'
return 'border-warning/50'
}
export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
const { data: deviceData, isLoading: devicesLoading } = useQuery({
queryKey: ['site-devices', tenantId, siteId],
queryFn: () => devicesApi.list(tenantId, { site_id: siteId, page_size: 100 }),
})
// Fleet summary has CPU/memory data
const { data: fleetData } = useQuery({
queryKey: ['fleet-summary', tenantId],
queryFn: () => metricsApi.fleetSummary(tenantId),
})
if (devicesLoading) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 space-y-3 animate-pulse">
<div className="h-4 w-24 bg-elevated rounded" />
<div className="h-1.5 w-full bg-elevated rounded-full" />
<div className="h-1.5 w-full bg-elevated rounded-full" />
<div className="h-3 w-16 bg-elevated rounded" />
</div>
))}
</div>
)
}
const devices = deviceData?.items ?? []
if (devices.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-sm text-text-muted">
No devices assigned to this site. Assign devices from the fleet page.
</p>
</div>
)
}
// Build a map of device metrics from fleet summary
const metricsMap = new Map<string, { cpu: number | null; mem: number | null }>()
if (fleetData) {
for (const fd of fleetData) {
metricsMap.set(fd.id, { cpu: fd.last_cpu_load, mem: fd.last_memory_used_pct })
}
}
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
{devices.map((device: DeviceResponse) => {
const metrics = metricsMap.get(device.id)
const cpu = metrics?.cpu ?? null
const mem = metrics?.mem ?? null
return (
<Link
key={device.id}
to="/tenants/$tenantId/devices/$deviceId"
params={{ tenantId, deviceId: device.id }}
className={cn(
'rounded-lg border bg-surface p-4 space-y-2 hover:bg-elevated/50 transition-colors block',
borderColor(device.status),
)}
>
<div className="flex items-center gap-2">
<StatusDot status={device.status} />
<span className="font-semibold text-sm text-text-primary truncate">
{device.hostname}
</span>
</div>
{/* CPU bar */}
<div className="space-y-0.5">
<div className="flex items-center justify-between text-[10px] text-text-muted">
<span>CPU</span>
<span>{cpu != null ? `${Math.round(cpu)}%` : '--'}</span>
</div>
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
{cpu != null && (
<div
className={cn('h-full rounded-full transition-all', cpuColor(cpu))}
style={{ width: `${Math.min(cpu, 100)}%` }}
/>
)}
</div>
</div>
{/* Memory bar */}
<div className="space-y-0.5">
<div className="flex items-center justify-between text-[10px] text-text-muted">
<span>Memory</span>
<span>{mem != null ? `${Math.round(mem)}%` : '--'}</span>
</div>
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
{mem != null && (
<div
className={cn('h-full rounded-full transition-all', memColor(mem))}
style={{ width: `${Math.min(mem, 100)}%` }}
/>
)}
</div>
</div>
{/* Uptime */}
<div className="text-[10px] text-text-muted">
Uptime: {formatUptime(device.uptime_seconds)}
</div>
</Link>
)
})}
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { WirelessLinksTable } from '@/components/wireless/WirelessLinksTable'
interface SiteLinksTabProps {
tenantId: string
siteId: string
}
export function SiteLinksTab({ tenantId, siteId }: SiteLinksTabProps) {
return <WirelessLinksTable tenantId={tenantId} siteId={siteId} showUnknownClients />
}

View File

@@ -0,0 +1,484 @@
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronDown, ChevronRight, Plus, Pencil, Trash2 } from 'lucide-react'
import {
sectorsApi,
devicesApi,
wirelessApi,
type SectorResponse,
type DeviceResponse,
type LinkResponse,
} from '@/lib/api'
import { useAuth, canWrite, canDelete } from '@/lib/auth'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { TableSkeleton } from '@/components/ui/page-skeleton'
import { signalColor } from '@/components/wireless/signal-color'
import { SectorFormDialog } from './SectorFormDialog'
interface SiteSectorViewProps {
tenantId: string
siteId: string
}
function StatusDot({ status }: { status: string }) {
const styles: Record<string, string> = {
online: 'bg-online shadow-[0_0_6px_hsl(var(--online)/0.3)]',
offline: 'bg-offline shadow-[0_0_6px_hsl(var(--offline)/0.3)]',
unknown: 'bg-unknown',
}
return (
<span
className={cn('inline-block w-2 h-2 rounded-full flex-shrink-0', styles[status] ?? styles.unknown)}
title={status}
/>
)
}
const STATE_STYLES: Record<string, string> = {
active: 'bg-success/20 text-success border-success/40',
degraded: 'bg-warning/20 text-warning border-warning/40',
down: 'bg-error/20 text-error border-error/40',
stale: 'bg-elevated text-text-muted border-border',
discovered: 'bg-info/20 text-info border-info/40',
}
function StateBadge({ state }: { state: string }) {
return (
<span
className={cn(
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
STATE_STYLES[state] ?? STATE_STYLES.stale,
)}
>
{state}
</span>
)
}
export function SiteSectorView({ tenantId, siteId }: SiteSectorViewProps) {
const { user } = useAuth()
const queryClient = useQueryClient()
const [formOpen, setFormOpen] = useState(false)
const [editSector, setEditSector] = useState<SectorResponse | null>(null)
const [deleteTarget, setDeleteTarget] = useState<SectorResponse | null>(null)
const { data: sectorData, isLoading: sectorsLoading } = useQuery({
queryKey: ['sectors', tenantId, siteId],
queryFn: () => sectorsApi.list(tenantId, siteId),
})
const { data: deviceData, isLoading: devicesLoading } = useQuery({
queryKey: ['site-devices', tenantId, siteId],
queryFn: () => devicesApi.list(tenantId, { site_id: siteId, page_size: 100 }),
})
const { data: linksData } = useQuery({
queryKey: ['site-links', tenantId, siteId],
queryFn: () => wirelessApi.getSiteLinks(tenantId, siteId),
})
const deleteMutation = useMutation({
mutationFn: (sectorId: string) => sectorsApi.delete(tenantId, siteId, sectorId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['sectors', tenantId, siteId] })
setDeleteTarget(null)
},
})
const assignMutation = useMutation({
mutationFn: ({ deviceId, sectorId }: { deviceId: string; sectorId: string | null }) =>
sectorsApi.assignDevice(tenantId, deviceId, sectorId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['site-devices', tenantId, siteId] })
void queryClient.invalidateQueries({ queryKey: ['sectors', tenantId, siteId] })
},
})
// Group devices by sector_id
const { sectorDevices, unassignedDevices } = useMemo(() => {
const devices = deviceData?.items ?? []
const grouped = new Map<string, DeviceResponse[]>()
const unassigned: DeviceResponse[] = []
for (const device of devices) {
if (device.sector_id) {
const list = grouped.get(device.sector_id) ?? []
list.push(device)
grouped.set(device.sector_id, list)
} else {
unassigned.push(device)
}
}
return { sectorDevices: grouped, unassignedDevices: unassigned }
}, [deviceData])
// Build map of links by AP device ID
const linksByAP = useMemo(() => {
const map = new Map<string, LinkResponse[]>()
if (linksData?.items) {
for (const link of linksData.items) {
const list = map.get(link.ap_device_id) ?? []
list.push(link)
map.set(link.ap_device_id, list)
}
}
return map
}, [linksData])
if (sectorsLoading || devicesLoading) {
return <TableSkeleton rows={6} />
}
const sectors = sectorData?.items ?? []
const allSectorOptions = sectors.map((s) => ({ id: s.id, name: s.name }))
if (sectors.length === 0 && unassignedDevices.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-3">
<p className="text-sm text-text-muted">
No sectors defined. Create sectors to organize APs by direction.
</p>
{canWrite(user) && (
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Sector
</Button>
)}
<SectorFormDialog
open={formOpen}
onOpenChange={setFormOpen}
tenantId={tenantId}
siteId={siteId}
/>
</div>
)
}
function computeSectorStats(sectorId: string) {
const devices = sectorDevices.get(sectorId) ?? []
const deviceIds = new Set(devices.map((d) => d.id))
let clientCount = 0
let signalSum = 0
let signalCount = 0
let linkCount = 0
for (const [apId, links] of linksByAP) {
if (deviceIds.has(apId)) {
for (const link of links) {
clientCount++
linkCount++
if (link.signal_strength != null) {
signalSum += link.signal_strength
signalCount++
}
}
}
}
return {
clientCount,
avgSignal: signalCount > 0 ? Math.round(signalSum / signalCount) : null,
linkCount,
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">
{sectors.length} Sector{sectors.length !== 1 ? 's' : ''}
</h3>
{canWrite(user) && (
<Button size="sm" onClick={() => { setEditSector(null); setFormOpen(true) }}>
<Plus className="h-4 w-4 mr-1" /> Add Sector
</Button>
)}
</div>
{/* Sector sections */}
{sectors.map((sector) => (
<SectorSection
key={sector.id}
sector={sector}
tenantId={tenantId}
devices={sectorDevices.get(sector.id) ?? []}
linksByAP={linksByAP}
stats={computeSectorStats(sector.id)}
allSectors={allSectorOptions}
user={user}
onEdit={() => { setEditSector(sector); setFormOpen(true) }}
onDelete={() => setDeleteTarget(sector)}
onAssign={(deviceId, sectorId) => assignMutation.mutate({ deviceId, sectorId })}
/>
))}
{/* Unassigned devices */}
{unassignedDevices.length > 0 && (
<SectorSection
sector={null}
tenantId={tenantId}
devices={unassignedDevices}
linksByAP={linksByAP}
stats={{ clientCount: 0, avgSignal: null, linkCount: 0 }}
allSectors={allSectorOptions}
user={user}
onEdit={() => {}}
onDelete={() => {}}
onAssign={(deviceId, sectorId) => assignMutation.mutate({ deviceId, sectorId })}
/>
)}
{/* Form dialog */}
<SectorFormDialog
open={formOpen}
onOpenChange={setFormOpen}
tenantId={tenantId}
siteId={siteId}
sector={editSector}
/>
{/* Delete confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Sector</DialogTitle>
<DialogDescription>
Delete sector &ldquo;{deleteTarget?.name}&rdquo;? Devices will be moved to unassigned.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── Sector Section ──────────────────────────────────────────────────────────
interface SectorSectionProps {
sector: SectorResponse | null
tenantId: string
devices: DeviceResponse[]
linksByAP: Map<string, LinkResponse[]>
stats: { clientCount: number; avgSignal: number | null; linkCount: number }
allSectors: Array<{ id: string; name: string }>
user: ReturnType<typeof useAuth>['user']
onEdit: () => void
onDelete: () => void
onAssign: (deviceId: string, sectorId: string | null) => void
}
function SectorSection({
sector,
tenantId,
devices,
linksByAP,
stats,
allSectors,
user,
onEdit,
onDelete,
onAssign,
}: SectorSectionProps) {
const [expanded, setExpanded] = useState(true)
const isUnassigned = !sector
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
{/* Section header */}
<button
className="w-full flex items-center gap-2 px-4 py-3 hover:bg-elevated/50 transition-colors text-left"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-text-muted flex-shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted flex-shrink-0" />
)}
<span className="font-semibold text-sm text-text-primary">
{isUnassigned ? 'Unassigned' : sector.name}
</span>
{!isUnassigned && sector.azimuth != null && (
<Badge variant="secondary" className="text-[10px]">
{sector.azimuth}&deg;
</Badge>
)}
<span className="text-[10px] text-text-muted">
{devices.length} device{devices.length !== 1 ? 's' : ''}
</span>
{!isUnassigned && (
<span className="flex items-center gap-3 ml-auto text-[10px] text-text-muted">
<span>{stats.clientCount} client{stats.clientCount !== 1 ? 's' : ''}</span>
{stats.avgSignal != null && (
<span className={signalColor(stats.avgSignal)}>
avg {stats.avgSignal} dBm
</span>
)}
<span>{stats.linkCount} link{stats.linkCount !== 1 ? 's' : ''}</span>
</span>
)}
{/* Edit / Delete actions -- stop propagation to prevent toggle */}
{!isUnassigned && (
<span className="flex items-center gap-1 ml-2" onClick={(e) => e.stopPropagation()}>
{canWrite(user) && (
<button
className="p-1 rounded hover:bg-elevated text-text-muted hover:text-text-primary transition-colors"
onClick={onEdit}
title="Edit sector"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{canDelete(user) && (
<button
className="p-1 rounded hover:bg-elevated text-text-muted hover:text-error transition-colors"
onClick={onDelete}
title="Delete sector"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</span>
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border divide-y divide-border/50">
{devices.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted">
No devices in this sector
</div>
) : (
devices.map((device) => (
<APCard
key={device.id}
device={device}
tenantId={tenantId}
links={linksByAP.get(device.id) ?? []}
allSectors={allSectors}
currentSectorId={device.sector_id}
user={user}
onAssign={onAssign}
/>
))
)}
</div>
)}
</div>
)
}
// ─── AP Card ──────────────────────────────────────────────────────────────────
interface APCardProps {
device: DeviceResponse
tenantId: string
links: LinkResponse[]
allSectors: Array<{ id: string; name: string }>
currentSectorId: string | null
user: ReturnType<typeof useAuth>['user']
onAssign: (deviceId: string, sectorId: string | null) => void
}
function APCard({ device, tenantId, links, allSectors, currentSectorId, user, onAssign }: APCardProps) {
return (
<div className="px-4 py-3 space-y-2">
<div className="flex items-center gap-2">
<StatusDot status={device.status} />
<Link
to="/tenants/$tenantId/devices/$deviceId"
params={{ tenantId, deviceId: device.id }}
className="font-semibold text-sm text-text-primary hover:text-accent transition-colors"
>
{device.hostname}
</Link>
{/* Sector assignment dropdown */}
{canWrite(user) && (
<div className="ml-auto">
<Select
value={currentSectorId ?? '__unassigned__'}
onValueChange={(val) =>
onAssign(device.id, val === '__unassigned__' ? null : val)
}
>
<SelectTrigger className="h-7 text-xs w-36">
<SelectValue placeholder="Assign sector" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{allSectors.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Connected CPEs */}
{links.length > 0 ? (
<div className="ml-4 space-y-1">
{links.map((link) => (
<div key={link.id} className="flex items-center gap-3 text-xs">
<Link
to="/tenants/$tenantId/devices/$deviceId"
params={{ tenantId, deviceId: link.cpe_device_id }}
className="text-text-primary hover:text-accent transition-colors truncate min-w-0"
>
{link.cpe_hostname ?? link.client_mac}
</Link>
<span className={cn('font-medium', signalColor(link.signal_strength))}>
{link.signal_strength != null ? `${link.signal_strength} dBm` : '--'}
</span>
<span className="text-text-secondary">
{link.tx_ccq != null ? `${link.tx_ccq}%` : '--'}
</span>
<StateBadge state={link.state} />
</div>
))}
</div>
) : (
<p className="ml-4 text-xs text-text-muted">No connected clients</p>
)}
</div>
)
}

View File

@@ -308,6 +308,8 @@ export interface DeviceResponse {
groups: DeviceGroupRef[] groups: DeviceGroupRef[]
site_id: string | null site_id: string | null
site_name: string | null site_name: string | null
sector_id: string | null
sector_name: string | null
created_at: string created_at: string
} }
@@ -348,6 +350,8 @@ export interface DeviceListParams {
status?: string status?: string
model?: string model?: string
tag?: string tag?: string
site_id?: string
sector_id?: string
} }
export const devicesApi = { export const devicesApi = {