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
- [ ] **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)
- [ ] **WRUI-03**: Wireless links page shows all discovered AP-CPE relationships with signal quality and link state
- [x] **WRUI-01**: Device detail page shows a per-station wireless table (connected clients with MAC, signal, CCQ, TX/RX rates, distance, uptime)
- [x] **WRUI-02**: Device detail page shows per-interface RF stats (noise floor, channel width, TX power)
- [x] **WRUI-03**: Wireless links page shows all discovered AP-CPE relationships with signal quality and link state
### Signal Trending
@@ -117,9 +117,9 @@
| LINK-02 | Phase 13 | Complete |
| LINK-03 | Phase 13 | Complete |
| LINK-04 | Phase 13 | Complete |
| WRUI-01 | Phase 14 | Pending |
| WRUI-02 | Phase 14 | Pending |
| WRUI-03 | Phase 14 | Pending |
| WRUI-01 | Phase 14 | Complete |
| WRUI-02 | Phase 14 | Complete |
| WRUI-03 | Phase 14 | Complete |
| TRND-01 | Phase 15 | Pending |
| TRND-02 | 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
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
**Plans:** 1/3 plans executed
**Plans:** 2/3 plans executed
Plans:
- [ ] 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 |
|----------|-------------|-------|-------|
| 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 |
| Signal Trending | TRND-01, TRND-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_name: Tower & Site Management
status: unknown
stopped_at: Completed 14-01-PLAN.md
last_updated: "2026-03-19T11:43:25.898Z"
stopped_at: Completed 14-02-PLAN.md
last_updated: "2026-03-19T11:48:58.364Z"
progress:
total_phases: 5
completed_phases: 3
total_plans: 11
completed_plans: 9
completed_plans: 10
---
# Project State
@@ -24,7 +24,7 @@ See: .planning/PROJECT.md (updated 2026-03-18)
## Current Position
Phase: 14 (site-dashboard-sector-views-wireless-ui) — EXECUTING
Plan: 2 of 3
Plan: 3 of 3
## Performance Metrics
@@ -44,6 +44,7 @@ Plan: 2 of 3
| Phase 13 P01 | 5min | 2 tasks | 4 files |
| Phase 13 P03 | 3min | 2 tasks | 6 files |
| Phase 14 P01 | 3min | 2 tasks | 15 files |
| Phase 14 P02 | 3min | 2 tasks | 9 files |
## 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]: 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]: 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
@@ -91,6 +95,6 @@ None yet.
## Session Continuity
Last session: 2026-03-19T11:43:25.894Z
Stopped at: Completed 14-01-PLAN.md
Last session: 2026-03-19T11:48:58.361Z
Stopped at: Completed 14-02-PLAN.md
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[]
site_id: string | null
site_name: string | null
sector_id: string | null
sector_name: string | null
created_at: string
}
@@ -348,6 +350,8 @@ export interface DeviceListParams {
status?: string
model?: string
tag?: string
site_id?: string
sector_id?: string
}
export const devicesApi = {