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 { 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')({
|
||||
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() {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-text-muted" />
|
||||
<h1 className="text-lg font-semibold text-text-primary">Traffic</h1>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Bandwidth monitoring and traffic analysis — coming soon.
|
||||
|
||||
{/* KPI Cards */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,197 @@
|
||||
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')({
|
||||
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() {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="h-5 w-5 text-text-muted" />
|
||||
<h1 className="text-lg font-semibold text-text-primary">Wireless</h1>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Wireless monitoring and statistics — coming soon.
|
||||
|
||||
{/* KPI Cards */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user