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:
95
frontend/src/components/monitoring/HealthChart.tsx
Normal file
95
frontend/src/components/monitoring/HealthChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/monitoring/HealthTab.tsx
Normal file
98
frontend/src/components/monitoring/HealthTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/monitoring/InterfacesTab.tsx
Normal file
115
frontend/src/components/monitoring/InterfacesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/monitoring/SignalBar.tsx
Normal file
58
frontend/src/components/monitoring/SignalBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SignalBarProps {
|
||||
signal: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map dBm signal strength to a 0–100 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/monitoring/Sparkline.tsx
Normal file
24
frontend/src/components/monitoring/Sparkline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
frontend/src/components/monitoring/TimeRangeSelector.tsx
Normal file
153
frontend/src/components/monitoring/TimeRangeSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
frontend/src/components/monitoring/TrafficChart.tsx
Normal file
127
frontend/src/components/monitoring/TrafficChart.tsx
Normal 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 }}>■</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>
|
||||
)
|
||||
}
|
||||
180
frontend/src/components/monitoring/WirelessTab.tsx
Normal file
180
frontend/src/components/monitoring/WirelessTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user