feat(ui): wire Wireless and Traffic pages to live data
Replace placeholder "coming soon" pages with functional implementations that query the fleet APIs and display real-time wireless issues and resource consumption data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,233 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { BarChart3 } from 'lucide-react'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { BarChart3, Inbox } from 'lucide-react'
|
||||||
|
import { metricsApi, type FleetDevice } from '@/lib/api'
|
||||||
|
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||||
|
import { useUIStore } from '@/lib/store'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/traffic')({
|
export const Route = createFileRoute('/_authenticated/traffic')({
|
||||||
component: TrafficPage,
|
component: TrafficPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function cpuColor(cpu: number | null): string {
|
||||||
|
if (cpu === null) return 'text-text-muted'
|
||||||
|
if (cpu < 50) return 'text-emerald-400'
|
||||||
|
if (cpu < 80) return 'text-yellow-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function memColor(mem: number | null): string {
|
||||||
|
if (mem === null) return 'text-text-muted'
|
||||||
|
if (mem < 60) return 'text-emerald-400'
|
||||||
|
if (mem < 85) return 'text-yellow-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusDot(status: string) {
|
||||||
|
const color =
|
||||||
|
status === 'online'
|
||||||
|
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]'
|
||||||
|
: status === 'degraded'
|
||||||
|
? 'bg-yellow-400 shadow-[0_0_6px_rgba(250,204,21,0.6)]'
|
||||||
|
: 'bg-red-400 shadow-[0_0_6px_rgba(248,113,113,0.6)]'
|
||||||
|
return <span className={cn('inline-block h-2 w-2 rounded-full', color)} />
|
||||||
|
}
|
||||||
|
|
||||||
function TrafficPage() {
|
function TrafficPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const selectedTenantId = useUIStore((s) => s.selectedTenantId)
|
||||||
|
const superAdmin = isSuperAdmin(user)
|
||||||
|
|
||||||
|
const tenantId = superAdmin ? selectedTenantId : user?.tenant_id
|
||||||
|
|
||||||
|
const { data: devices = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['fleet-summary', tenantId, superAdmin],
|
||||||
|
queryFn: () =>
|
||||||
|
superAdmin && !tenantId
|
||||||
|
? metricsApi.fleetSummaryAll()
|
||||||
|
: metricsApi.fleetSummary(tenantId!),
|
||||||
|
enabled: !!tenantId || superAdmin,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by CPU load descending, nulls last
|
||||||
|
const sorted = [...devices].sort((a, b) => {
|
||||||
|
const aCpu = a.last_cpu_load ?? -1
|
||||||
|
const bCpu = b.last_cpu_load ?? -1
|
||||||
|
return bCpu - aCpu
|
||||||
|
})
|
||||||
|
|
||||||
|
const top10 = sorted.slice(0, 10)
|
||||||
|
|
||||||
|
const avgCpu =
|
||||||
|
devices.length > 0
|
||||||
|
? devices.reduce((sum, d) => sum + (d.last_cpu_load ?? 0), 0) / devices.length
|
||||||
|
: null
|
||||||
|
|
||||||
|
const avgMem =
|
||||||
|
devices.length > 0
|
||||||
|
? devices.reduce((sum, d) => sum + (d.last_memory_used_pct ?? 0), 0) / devices.length
|
||||||
|
: null
|
||||||
|
|
||||||
|
const onlineCount = devices.filter((d) => d.status === 'online').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarChart3 className="h-5 w-5 text-text-muted" />
|
<BarChart3 className="h-5 w-5 text-text-muted" />
|
||||||
<h1 className="text-lg font-semibold text-text-primary">Traffic</h1>
|
<h1 className="text-lg font-semibold text-text-primary">Traffic</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
Bandwidth monitoring and traffic analysis — coming soon.
|
{/* KPI Cards */}
|
||||||
</p>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Fleet Avg CPU
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-2xl font-mono',
|
||||||
|
isLoading ? 'text-text-muted' : cpuColor(avgCpu),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? '--' : avgCpu !== null ? `${avgCpu.toFixed(1)}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Fleet Avg Memory
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-2xl font-mono',
|
||||||
|
isLoading ? 'text-text-muted' : memColor(avgMem),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? '--' : avgMem !== null ? `${avgMem.toFixed(1)}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Devices Online
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-mono text-text-secondary">
|
||||||
|
{isLoading ? '--' : `${onlineCount} / ${devices.length}`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Resource Consumers */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-text-muted">Loading fleet data...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-3 p-12">
|
||||||
|
<Inbox className="h-10 w-10 text-text-muted" />
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
No device data available
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">
|
||||||
|
Top Resource Consumers
|
||||||
|
</h2>
|
||||||
|
<Card className="border-border bg-surface overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Hostname
|
||||||
|
</th>
|
||||||
|
{superAdmin && !tenantId && (
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Tenant
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
IP Address
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||||
|
CPU Load
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||||
|
Memory %
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-center">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{top10.map((device) => (
|
||||||
|
<tr
|
||||||
|
key={device.id}
|
||||||
|
className="border-b border-border/50 hover:bg-surface-hover transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||||
|
{device.hostname}
|
||||||
|
</td>
|
||||||
|
{superAdmin && !tenantId && (
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-text-muted">
|
||||||
|
{device.tenant_name}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-text-secondary">
|
||||||
|
{device.ip_address}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 text-sm font-mono text-right',
|
||||||
|
cpuColor(device.last_cpu_load),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{device.last_cpu_load !== null
|
||||||
|
? `${device.last_cpu_load}%`
|
||||||
|
: '--'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 text-sm font-mono text-right',
|
||||||
|
memColor(device.last_memory_used_pct),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{device.last_memory_used_pct !== null
|
||||||
|
? `${device.last_memory_used_pct.toFixed(1)}%`
|
||||||
|
: '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{statusDot(device.status)}
|
||||||
|
<span className="text-sm text-text-secondary capitalize">
|
||||||
|
{device.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,197 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { Wifi } from 'lucide-react'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Wifi, CheckCircle2 } from 'lucide-react'
|
||||||
|
import { metricsApi } from '@/lib/api'
|
||||||
|
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||||
|
import { useUIStore } from '@/lib/store'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/wireless')({
|
export const Route = createFileRoute('/_authenticated/wireless')({
|
||||||
component: WirelessPage,
|
component: WirelessPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function signalColor(signal: number | null): string {
|
||||||
|
if (signal === null) return 'text-text-muted'
|
||||||
|
if (signal > -60) return 'text-emerald-400'
|
||||||
|
if (signal > -70) return 'text-yellow-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
function WirelessPage() {
|
function WirelessPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const selectedTenantId = useUIStore((s) => s.selectedTenantId)
|
||||||
|
const superAdmin = isSuperAdmin(user)
|
||||||
|
|
||||||
|
const tenantId = superAdmin ? selectedTenantId : user?.tenant_id
|
||||||
|
|
||||||
|
const { data: issues = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['wireless-issues', tenantId, superAdmin],
|
||||||
|
queryFn: () =>
|
||||||
|
superAdmin && !tenantId
|
||||||
|
? metricsApi.fleetWirelessIssues()
|
||||||
|
: metricsApi.wirelessIssues(tenantId!),
|
||||||
|
enabled: !!tenantId || superAdmin,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const worstSignal =
|
||||||
|
issues.length > 0
|
||||||
|
? issues.reduce<number | null>((worst, i) => {
|
||||||
|
if (i.signal === null) return worst
|
||||||
|
if (worst === null) return i.signal
|
||||||
|
return i.signal < worst ? i.signal : worst
|
||||||
|
}, null)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const totalClients = issues.reduce((sum, i) => sum + i.client_count, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wifi className="h-5 w-5 text-text-muted" />
|
<Wifi className="h-5 w-5 text-text-muted" />
|
||||||
<h1 className="text-lg font-semibold text-text-primary">Wireless</h1>
|
<h1 className="text-lg font-semibold text-text-primary">Wireless</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
Wireless monitoring and statistics — coming soon.
|
{/* KPI Cards */}
|
||||||
</p>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
APs with Issues
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-mono text-text-secondary">
|
||||||
|
{isLoading ? '--' : issues.length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Worst Signal
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-2xl font-mono',
|
||||||
|
isLoading ? 'text-text-muted' : signalColor(worstSignal),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? '--' : worstSignal !== null ? `${worstSignal} dBm` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Total Clients
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-mono text-text-secondary">
|
||||||
|
{isLoading ? '--' : totalClients}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues Table or All Clear */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-sm text-text-muted">Loading wireless data...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : issues.length === 0 ? (
|
||||||
|
<Card className="border-border bg-surface">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-3 p-12">
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-emerald-400" />
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
All Clear — no wireless issues detected
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="border-border bg-surface overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Hostname
|
||||||
|
</th>
|
||||||
|
{superAdmin && !tenantId && (
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Tenant
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Interface
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted">
|
||||||
|
Issue
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||||
|
Signal
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||||
|
CCQ
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||||
|
Clients
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||||
|
Frequency
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{issues.map((issue, idx) => (
|
||||||
|
<tr
|
||||||
|
key={`${issue.device_id}-${issue.interface}-${idx}`}
|
||||||
|
className="border-b border-border/50 hover:bg-surface-hover transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-red-400 shadow-[0_0_6px_rgba(248,113,113,0.6)]" />
|
||||||
|
{issue.hostname}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{superAdmin && !tenantId && (
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-text-muted">
|
||||||
|
{issue.tenant_name ?? '--'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-text-secondary">
|
||||||
|
{issue.interface}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||||
|
{issue.issue}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 text-sm font-mono text-right',
|
||||||
|
signalColor(issue.signal),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{issue.signal !== null ? `${issue.signal} dBm` : '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-text-secondary text-right">
|
||||||
|
{issue.ccq !== null ? `${issue.ccq}%` : '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-text-secondary text-right">
|
||||||
|
{issue.client_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-text-secondary text-right">
|
||||||
|
{issue.frequency ? `${issue.frequency} MHz` : '--'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user