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
|
### 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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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[]
|
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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user