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:
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
136
frontend/src/components/sites/SectorFormDialog.tsx
Normal file
136
frontend/src/components/sites/SectorFormDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
frontend/src/components/sites/SiteHealthGrid.tsx
Normal file
158
frontend/src/components/sites/SiteHealthGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
frontend/src/components/sites/SiteLinksTab.tsx
Normal file
10
frontend/src/components/sites/SiteLinksTab.tsx
Normal 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 />
|
||||
}
|
||||
484
frontend/src/components/sites/SiteSectorView.tsx
Normal file
484
frontend/src/components/sites/SiteSectorView.tsx
Normal 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 “{deleteTarget?.name}”? 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}°
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user