feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from 'recharts'
import type { HealthMetricPoint } from '@/lib/api'
interface HealthChartProps {
data: HealthMetricPoint[]
metric: 'avg_cpu' | 'avg_mem_pct' | 'avg_disk_pct' | 'avg_temp'
label: string
color: string
unit: string // "%" or "C"
maxY?: number // 100 for percentages
}
function formatBucket(bucket: string): string {
const d = new Date(bucket)
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${min}`
}
function CustomTooltip({
active,
payload,
label,
unit,
}: { active?: boolean; payload?: Array<{ value?: number }>; label?: string; unit: string }) {
if (!active || !payload?.length) return null
return (
<div className="rounded border border-border bg-surface px-2 py-1.5 text-xs text-text-primary shadow-lg">
<div className="mb-1 text-text-muted">{label}</div>
<div>
{(payload[0].value ?? 0).toFixed(1)}
{unit}
</div>
</div>
)
}
export function HealthChart({ data, metric, label, color, unit, maxY }: HealthChartProps) {
const gradId = `hc-grad-${metric}`
const chartData = data.map((point) => ({
bucket: formatBucket(point.bucket),
value: point[metric] ?? 0,
}))
const domain: [number | string, number | string] = maxY !== undefined ? [0, maxY] : ['auto', 'auto']
return (
<div>
<div className="mb-1 text-xs font-medium text-text-secondary">{label}</div>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 9, fill: '#94a3b8' }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={domain}
tickFormatter={(v: number) => `${v}${unit}`}
tick={{ fontSize: 9, fill: '#94a3b8' }}
axisLine={false}
tickLine={false}
width={40}
/>
<Tooltip content={<CustomTooltip unit={unit} />} />
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.5}
fill={`url(#${gradId})`}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { metricsApi } from '@/lib/api'
import { TimeRangeSelector, getTimeRange, shouldAutoRefresh } from './TimeRangeSelector'
import { HealthChart } from './HealthChart'
interface HealthTabProps {
tenantId: string
deviceId: string
active?: boolean
}
export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps) {
const [timeRange, setTimeRange] = useState('6h')
const [customStart, setCustomStart] = useState('')
const [customEnd, setCustomEnd] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['metrics', 'health', deviceId, timeRange, customStart, customEnd],
queryFn: () => {
const { start, end } = getTimeRange(timeRange, customStart, customEnd)
return metricsApi.health(tenantId, deviceId, start, end)
},
refetchInterval: shouldAutoRefresh(timeRange),
enabled: active,
})
const handleCustomRangeChange = (start: string, end: string) => {
setCustomStart(start)
setCustomEnd(end)
}
return (
<div className="space-y-4 mt-4">
<TimeRangeSelector
value={timeRange}
onChange={setTimeRange}
customStart={customStart}
customEnd={customEnd}
onCustomRangeChange={handleCustomRangeChange}
/>
{isLoading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-44 animate-pulse" />
))}
</div>
) : !data || data.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
No health metrics data available for the selected time range.
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-lg border border-border bg-surface p-4">
<HealthChart
data={data}
metric="avg_cpu"
label="CPU Load"
color="#38BDF8"
unit="%"
maxY={100}
/>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<HealthChart
data={data}
metric="avg_mem_pct"
label="Memory Usage"
color="#4ADE80"
unit="%"
maxY={100}
/>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<HealthChart
data={data}
metric="avg_disk_pct"
label="Disk Usage"
color="#FBBF24"
unit="%"
maxY={100}
/>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<HealthChart
data={data}
metric="avg_temp"
label="Temperature"
color="#F87171"
unit="C"
/>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { metricsApi } from '@/lib/api'
import { TimeRangeSelector, getTimeRange, shouldAutoRefresh } from './TimeRangeSelector'
import { TrafficChart } from './TrafficChart'
interface InterfacesTabProps {
tenantId: string
deviceId: string
active?: boolean
}
export function InterfacesTab({ tenantId, deviceId, active = true }: InterfacesTabProps) {
const [timeRange, setTimeRange] = useState('6h')
const [selectedInterface, setSelectedInterface] = useState<string | null>(null)
const [customStart, setCustomStart] = useState('')
const [customEnd, setCustomEnd] = useState('')
const { data: interfaces } = useQuery({
queryKey: ['metrics', 'interface-list', deviceId],
queryFn: () => metricsApi.interfaceList(tenantId, deviceId),
enabled: active,
})
const { data: trafficData, isLoading } = useQuery({
queryKey: ['metrics', 'traffic', deviceId, timeRange, selectedInterface, customStart, customEnd],
queryFn: () => {
const { start, end } = getTimeRange(timeRange, customStart, customEnd)
return metricsApi.interfaces(tenantId, deviceId, start, end, selectedInterface ?? undefined)
},
refetchInterval: shouldAutoRefresh(timeRange),
enabled: active,
})
const handleCustomRangeChange = (start: string, end: string) => {
setCustomStart(start)
setCustomEnd(end)
}
// Group traffic data by interface name
const byInterface = new Map<string, typeof trafficData>()
if (trafficData) {
for (const point of trafficData) {
const key = point.interface
if (!byInterface.has(key)) byInterface.set(key, [])
byInterface.get(key)!.push(point)
}
}
const interfaceNames = selectedInterface
? [selectedInterface]
: [...byInterface.keys()]
return (
<div className="space-y-4 mt-4">
{/* Controls row */}
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
<div className="flex-1">
<TimeRangeSelector
value={timeRange}
onChange={setTimeRange}
customStart={customStart}
customEnd={customEnd}
onCustomRangeChange={handleCustomRangeChange}
/>
</div>
{/* Interface filter */}
{interfaces && interfaces.length > 0 && (
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-text-muted">Interface:</span>
<select
value={selectedInterface ?? ''}
onChange={(e) => setSelectedInterface(e.target.value || null)}
className="text-xs rounded border border-border bg-elevated/50 text-text-primary px-2 py-1 [color-scheme:dark]"
>
<option value="">All interfaces</option>
{interfaces.map((iface) => (
<option key={iface} value={iface}>
{iface}
</option>
))}
</select>
</div>
)}
</div>
{/* Charts */}
{isLoading ? (
<div className="space-y-4">
{[0, 1, 2].map((i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-56 animate-pulse" />
))}
</div>
) : !trafficData || trafficData.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
{interfaces && interfaces.length === 0
? 'No interfaces discovered for this device.'
: 'No traffic data available for the selected time range.'}
</div>
) : (
<div className="space-y-4">
{interfaceNames.map((ifaceName) => {
const ifaceData = byInterface.get(ifaceName) ?? []
return (
<div key={ifaceName} className="rounded-lg border border-border bg-surface p-4">
<TrafficChart data={ifaceData} interfaceName={ifaceName} />
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { cn } from '@/lib/utils'
interface SignalBarProps {
signal: number
label?: string
}
/**
* Map dBm signal strength to a 0100 quality percentage.
* Range: -30 dBm (best) to -90 dBm (worst).
*/
function signalToPercent(signal: number): number {
const clamped = Math.max(-90, Math.min(-30, signal))
return ((clamped - -90) / (-30 - -90)) * 100
}
/**
* Returns color class based on signal strength thresholds per plan:
* - Green: -30 to -67 dBm
* - Yellow: -67 to -70 dBm
* - Red: below -70 dBm
*/
function signalColor(signal: number): string {
if (signal >= -67) return 'bg-success'
if (signal >= -70) return 'bg-warning'
return 'bg-error'
}
function signalTextColor(signal: number): string {
if (signal >= -67) return 'text-success'
if (signal >= -70) return 'text-warning'
return 'text-error'
}
export function SignalBar({ signal, label }: SignalBarProps) {
const pct = signalToPercent(signal)
const barColor = signalColor(signal)
const textColor = signalTextColor(signal)
return (
<div className="space-y-1">
{label && <div className="text-xs text-text-muted">{label}</div>}
<div className="flex items-center gap-3">
{/* Horizontal bar */}
<div className="relative h-2 flex-1 rounded-full bg-elevated overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', barColor)}
style={{ width: `${pct}%` }}
/>
</div>
{/* dBm value */}
<span className={cn('text-sm font-mono font-medium tabular-nums', textColor)}>
{signal} dBm
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { LineChart, Line } from 'recharts'
interface SparklineProps {
data: number[]
color?: string
width?: number
height?: number
}
export function Sparkline({ data, color = '#38BDF8', width = 60, height = 24 }: SparklineProps) {
const chartData = data.map((v, i) => ({ v, i }))
return (
<LineChart width={width} height={height} data={chartData}>
<Line
type="monotone"
dataKey="v"
stroke={color}
dot={false}
strokeWidth={1.5}
isAnimationActive={false}
/>
</LineChart>
)
}

View File

@@ -0,0 +1,153 @@
import { cn } from '@/lib/utils'
export interface TimeRangeSelectorProps {
value: string // '1h' | '6h' | '24h' | '7d' | '30d' | '90d' | 'custom'
onChange: (range: string) => void
customStart?: string // ISO string, only used when value === 'custom'
customEnd?: string // ISO string, only used when value === 'custom'
onCustomRangeChange?: (start: string, end: string) => void
}
const PRESETS = ['1h', '6h', '24h', '7d', '30d', '90d'] as const
/**
* Convert an ISO string to the format required by datetime-local inputs (YYYY-MM-DDTHH:MM).
*/
function toDatetimeLocal(iso: string): string {
if (!iso) return ''
// datetime-local inputs accept YYYY-MM-DDTHH:MM (no seconds/Z)
return iso.slice(0, 16)
}
/**
* Returns start/end ISO strings for a given preset range or custom range.
*/
export function getTimeRange(
range: string,
customStart?: string,
customEnd?: string,
): { start: string; end: string } {
if (range === 'custom' && customStart && customEnd) {
return { start: customStart, end: customEnd }
}
const end = new Date()
const start = new Date(end)
switch (range) {
case '1h':
start.setHours(start.getHours() - 1)
break
case '6h':
start.setHours(start.getHours() - 6)
break
case '24h':
start.setHours(start.getHours() - 24)
break
case '7d':
start.setDate(start.getDate() - 7)
break
case '30d':
start.setDate(start.getDate() - 30)
break
case '90d':
start.setDate(start.getDate() - 90)
break
default:
start.setHours(start.getHours() - 6)
}
return { start: start.toISOString(), end: end.toISOString() }
}
/**
* Returns refetchInterval (ms) for short ranges, false for longer ones.
* Per user decision: 1h and 6h auto-refresh every 60 seconds.
*/
export function shouldAutoRefresh(range: string): number | false {
if (range === '1h' || range === '6h') return 60_000
return false
}
export function TimeRangeSelector({
value,
onChange,
customStart = '',
customEnd = '',
onCustomRangeChange,
}: TimeRangeSelectorProps) {
const handleCustomStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = e.target.value ? new Date(e.target.value).toISOString() : ''
onChange('custom')
onCustomRangeChange?.(newStart, customEnd)
}
const handleCustomEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = e.target.value ? new Date(e.target.value).toISOString() : ''
onChange('custom')
onCustomRangeChange?.(customStart, newEnd)
}
return (
<div className="space-y-2">
{/* Preset buttons row */}
<div className="flex flex-wrap gap-1">
{PRESETS.map((preset) => (
<button
key={preset}
onClick={() => onChange(preset)}
className={cn(
'px-2.5 py-1 text-xs rounded border transition-colors',
value === preset
? 'bg-elevated border-border-bright text-text-primary'
: 'bg-transparent border-border/50 text-text-primary/40 hover:text-text-primary/60 hover:border-border',
)}
>
{preset}
</button>
))}
<button
onClick={() => onChange('custom')}
className={cn(
'px-2.5 py-1 text-xs rounded border transition-colors',
value === 'custom'
? 'bg-elevated border-border-bright text-text-primary'
: 'bg-transparent border-border/50 text-text-primary/40 hover:text-text-primary/60 hover:border-border',
)}
>
Custom
</button>
</div>
{/* Custom date picker inputs */}
{value === 'custom' && (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs text-text-primary/40">Start</span>
<input
type="datetime-local"
value={toDatetimeLocal(customStart)}
onChange={handleCustomStartChange}
className={cn(
'text-xs rounded border border-border bg-elevated/50 text-text-primary px-2 py-1',
'[color-scheme:dark]',
)}
/>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-text-primary/40">End</span>
<input
type="datetime-local"
value={toDatetimeLocal(customEnd)}
onChange={handleCustomEndChange}
className={cn(
'text-xs rounded border border-border bg-elevated/50 text-text-primary px-2 py-1',
'[color-scheme:dark]',
)}
/>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,127 @@
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from 'recharts'
import type { InterfaceMetricPoint } from '@/lib/api'
interface TrafficChartProps {
data: InterfaceMetricPoint[]
interfaceName: string
}
function formatBps(bps: number): string {
if (bps >= 1_000_000_000) return `${(bps / 1_000_000_000).toFixed(1)} Gbps`
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`
if (bps >= 1_000) return `${(bps / 1_000).toFixed(1)} Kbps`
return `${bps} bps`
}
function formatBucket(bucket: string, useDate: boolean): string {
const d = new Date(bucket)
if (useDate) {
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}/${dd}`
}
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${min}`
}
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ value?: number; dataKey?: string; name?: string; color?: string }>; label?: string }) {
if (!active || !payload?.length) return null
return (
<div className="rounded border border-border bg-surface px-2 py-1.5 text-xs text-text-primary shadow-lg">
<div className="mb-1 text-text-muted">{label}</div>
{payload.map((entry) => (
<div key={entry.dataKey} className="flex items-center gap-2">
<span style={{ color: entry.color }}>&#9632;</span>
<span>{entry.name === 'avg_rx_bps' ? 'RX' : 'TX'}</span>
<span className="ml-auto pl-4">{formatBps(entry.value ?? 0)}</span>
</div>
))}
</div>
)
}
export function TrafficChart({ data, interfaceName }: TrafficChartProps) {
// Determine if we should show dates vs times based on data span
const useDate =
data.length >= 2
? new Date(data[data.length - 1].bucket).getTime() - new Date(data[0].bucket).getTime() >
2 * 24 * 60 * 60 * 1000
: false
const chartData = data.map((point) => ({
bucket: formatBucket(point.bucket, useDate),
avg_rx_bps: point.avg_rx_bps ?? 0,
avg_tx_bps: point.avg_tx_bps ?? 0,
}))
return (
<div>
<div className="mb-1 text-xs text-text-muted">{interfaceName}</div>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`rx-grad-${interfaceName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#38BDF8" stopOpacity={0.3} />
<stop offset="100%" stopColor="#38BDF8" stopOpacity={0} />
</linearGradient>
<linearGradient id={`tx-grad-${interfaceName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#4ADE80" stopOpacity={0.3} />
<stop offset="100%" stopColor="#4ADE80" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 10, fill: '#94a3b8' }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={formatBps}
tick={{ fontSize: 10, fill: '#94a3b8' }}
axisLine={false}
tickLine={false}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="avg_rx_bps"
name="avg_rx_bps"
stroke="#38BDF8"
strokeWidth={1.5}
fill={`url(#rx-grad-${interfaceName})`}
/>
<Area
type="monotone"
dataKey="avg_tx_bps"
name="avg_tx_bps"
stroke="#4ADE80"
strokeWidth={1.5}
fill={`url(#tx-grad-${interfaceName})`}
/>
</AreaChart>
</ResponsiveContainer>
<div className="mt-1 flex gap-4 text-xs text-text-muted">
<span>
<span className="mr-1 inline-block h-2 w-2 rounded-sm bg-chart-1" />
RX
</span>
<span>
<span className="mr-1 inline-block h-2 w-2 rounded-sm bg-chart-2" />
TX
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,180 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
CartesianGrid,
} from 'recharts'
import { metricsApi, type WirelessLatest, type WirelessMetricPoint } from '@/lib/api'
import { TimeRangeSelector, getTimeRange, shouldAutoRefresh } from './TimeRangeSelector'
import { SignalBar } from './SignalBar'
interface WirelessTabProps {
tenantId: string
deviceId: string
active?: boolean
}
interface WirelessInterfaceSection {
interfaceName: string
latest: WirelessLatest | undefined
history: WirelessMetricPoint[]
}
function ClientCountMiniChart({ data }: { data: WirelessMetricPoint[] }) {
const chartData = data.map((p) => ({
bucket: p.bucket,
clients: p.avg_clients ?? 0,
}))
return (
<ResponsiveContainer width="100%" height={80}>
<AreaChart data={chartData} margin={{ top: 2, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="client-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#A78BFA" stopOpacity={0.3} />
<stop offset="100%" stopColor="#A78BFA" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="bucket" hide />
<Area
type="monotone"
dataKey="clients"
stroke="#A78BFA"
strokeWidth={1.5}
fill="url(#client-grad)"
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
)
}
function WirelessInterfaceCard({ section }: { section: WirelessInterfaceSection }) {
const { interfaceName, latest, history } = section
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
{/* Interface name header */}
<h3 className="text-sm font-medium text-text-primary">{interfaceName}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Signal strength */}
<div className="space-y-1">
<div className="text-xs text-text-muted">Signal Strength</div>
{latest?.avg_signal != null ? (
<SignalBar signal={latest.avg_signal} />
) : (
<div className="text-sm text-text-muted"></div>
)}
</div>
{/* CCQ */}
<div className="space-y-1">
<div className="text-xs text-text-muted">CCQ</div>
<div className="text-sm text-text-primary">
{latest?.ccq != null ? `${latest.ccq}%` : '—'}
</div>
</div>
{/* Frequency */}
<div className="space-y-1">
<div className="text-xs text-text-muted">Frequency</div>
<div className="text-sm text-text-primary">
{latest?.frequency != null ? `${latest.frequency} MHz` : '—'}
</div>
</div>
{/* Client count */}
<div className="space-y-1">
<div className="text-xs text-text-muted">Connected Clients</div>
<div className="flex items-baseline gap-3">
<span className="text-2xl font-semibold tabular-nums text-text-primary">
{latest?.client_count ?? '—'}
</span>
</div>
{history.length > 0 && (
<div className="mt-1">
<ClientCountMiniChart data={history} />
</div>
)}
</div>
</div>
</div>
)
}
export function WirelessTab({ tenantId, deviceId, active = true }: WirelessTabProps) {
const [timeRange, setTimeRange] = useState('6h')
const [customStart, setCustomStart] = useState('')
const [customEnd, setCustomEnd] = useState('')
const { data: latestWireless } = useQuery({
queryKey: ['metrics', 'wireless-latest', deviceId],
queryFn: () => metricsApi.wirelessLatest(tenantId, deviceId),
refetchInterval: 60_000,
enabled: active,
})
const { data: historicalWireless, isLoading } = useQuery({
queryKey: ['metrics', 'wireless', deviceId, timeRange, customStart, customEnd],
queryFn: () => {
const { start, end } = getTimeRange(timeRange, customStart, customEnd)
return metricsApi.wireless(tenantId, deviceId, start, end)
},
refetchInterval: shouldAutoRefresh(timeRange),
enabled: active,
})
const handleCustomRangeChange = (start: string, end: string) => {
setCustomStart(start)
setCustomEnd(end)
}
// Gather all wireless interface names from latest and historical data
const interfaceNames = new Set<string>()
latestWireless?.forEach((w) => interfaceNames.add(w.interface))
historicalWireless?.forEach((w) => interfaceNames.add(w.interface))
const sections: WirelessInterfaceSection[] = [...interfaceNames].map((ifaceName) => ({
interfaceName: ifaceName,
latest: latestWireless?.find((w) => w.interface === ifaceName),
history: historicalWireless?.filter((w) => w.interface === ifaceName) ?? [],
}))
const hasNoWireless =
!isLoading && latestWireless?.length === 0 && (!historicalWireless || historicalWireless.length === 0)
return (
<div className="space-y-4 mt-4">
<TimeRangeSelector
value={timeRange}
onChange={setTimeRange}
customStart={customStart}
customEnd={customEnd}
onCustomRangeChange={handleCustomRangeChange}
/>
{isLoading ? (
<div className="space-y-4">
{[0, 1].map((i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-48 animate-pulse" />
))}
</div>
) : hasNoWireless ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
No wireless interfaces detected on this device.
</div>
) : (
<div className="space-y-4">
{sections.map((section) => (
<WirelessInterfaceCard key={section.interfaceName} section={section} />
))}
</div>
)}
</div>
)
}