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:
459
frontend/src/components/network/ClientsTab.tsx
Normal file
459
frontend/src/components/network/ClientsTab.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* ClientsTab -- Connected client device table with ARP/DHCP merge and
|
||||
* expandable wireless detail rows.
|
||||
*
|
||||
* Displays all client devices discovered on a MikroTik device via ARP + DHCP +
|
||||
* wireless registration tables. Supports column sorting, text search filtering,
|
||||
* and expandable rows for wireless clients showing signal/tx/rx/uptime.
|
||||
*
|
||||
* Props follow the active-guard pattern: data is only fetched when `active`
|
||||
* is true (i.e. the tab is visible). Auto-refreshes every 30 seconds.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Search,
|
||||
Wifi,
|
||||
Cable,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Signal,
|
||||
Users,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { networkApi, type ClientDevice } from '@/lib/networkApi'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ClientsTabProps {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
/** Active guard -- only fetch when the tab is visible */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type SortField = 'ip' | 'mac' | 'hostname' | 'interface' | 'status'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal strength helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSignalDbm(signal: string | null): number | null {
|
||||
if (!signal) return null
|
||||
// RouterOS formats signal as e.g. "-65dBm" or "-65"
|
||||
const match = signal.match(/-?\d+/)
|
||||
return match ? parseInt(match[0], 10) : null
|
||||
}
|
||||
|
||||
function signalColor(dbm: number): string {
|
||||
if (dbm > -65) return 'text-success'
|
||||
if (dbm >= -75) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
|
||||
function signalLabel(dbm: number): string {
|
||||
if (dbm > -65) return 'Good'
|
||||
if (dbm >= -75) return 'Fair'
|
||||
return 'Poor'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SortIcon({ field, currentField, currentDir }: {
|
||||
field: SortField
|
||||
currentField: SortField
|
||||
currentDir: SortDir
|
||||
}) {
|
||||
if (field !== currentField) {
|
||||
return <ArrowUpDown className="ml-1 h-3 w-3 text-text-muted" />
|
||||
}
|
||||
return currentDir === 'asc'
|
||||
? <ArrowUp className="ml-1 h-3 w-3 text-accent" />
|
||||
: <ArrowDown className="ml-1 h-3 w-3 text-accent" />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) {
|
||||
const [sortField, setSortField] = useState<SortField>('ip')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedMac, setExpandedMac] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['clients', tenantId, deviceId],
|
||||
queryFn: () => networkApi.getClients(tenantId, deviceId),
|
||||
enabled: active,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
// Toggle sort: if same column, flip direction; otherwise set new column ascending
|
||||
const handleSort = (field: SortField) => {
|
||||
if (field === sortField) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const getAriaSort = (field: SortField): 'ascending' | 'descending' | 'none' => {
|
||||
if (field !== sortField) return 'none'
|
||||
return sortDir === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
// Filter and sort the client list
|
||||
const clients = useMemo(() => {
|
||||
if (!data?.clients) return []
|
||||
|
||||
const query = searchQuery.toLowerCase().trim()
|
||||
|
||||
// Filter by search query
|
||||
const filtered = query
|
||||
? data.clients.filter(
|
||||
(c) =>
|
||||
c.ip.toLowerCase().includes(query) ||
|
||||
c.mac.toLowerCase().includes(query) ||
|
||||
(c.hostname && c.hostname.toLowerCase().includes(query)),
|
||||
)
|
||||
: data.clients
|
||||
|
||||
// Sort
|
||||
return [...filtered].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortField) {
|
||||
case 'ip': {
|
||||
// Numeric IP sort
|
||||
const aParts = a.ip.split('.').map(Number)
|
||||
const bParts = b.ip.split('.').map(Number)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
cmp = (aParts[i] || 0) - (bParts[i] || 0)
|
||||
if (cmp !== 0) break
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'mac':
|
||||
cmp = a.mac.localeCompare(b.mac)
|
||||
break
|
||||
case 'hostname':
|
||||
cmp = (a.hostname || '').localeCompare(b.hostname || '')
|
||||
break
|
||||
case 'interface':
|
||||
cmp = a.interface.localeCompare(b.interface)
|
||||
break
|
||||
case 'status':
|
||||
cmp = a.status.localeCompare(b.status)
|
||||
break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [data?.clients, searchQuery, sortField, sortDir])
|
||||
|
||||
// Stats
|
||||
const totalClients = data?.clients.length ?? 0
|
||||
const wirelessCount = data?.clients.filter((c) => c.is_wireless).length ?? 0
|
||||
const reachableCount = data?.clients.filter((c) => c.status === 'reachable').length ?? 0
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<p className="text-sm">Failed to load client devices</p>
|
||||
<p className="text-xs mt-1">{(error as Error)?.message || 'Unknown error'}</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 text-xs text-accent hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search bar and stats row */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-text-muted" />
|
||||
<Input
|
||||
placeholder="Search by IP, MAC, or hostname..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{totalClients} clients
|
||||
</Badge>
|
||||
<Badge className="gap-1">
|
||||
<Wifi className="h-3 w-3" />
|
||||
{wirelessCount} wireless
|
||||
</Badge>
|
||||
<Badge className="gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-success inline-block" />
|
||||
{reachableCount} reachable
|
||||
</Badge>
|
||||
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="ml-2 p-1 rounded hover:bg-elevated transition-colors"
|
||||
title="Refresh client list"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5 text-text-muted', isFetching && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{clients.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<Users className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p className="text-sm font-medium">No clients found</p>
|
||||
<p className="text-xs mt-1">
|
||||
{searchQuery
|
||||
? 'No clients match your search query'
|
||||
: 'No connected client devices detected on this device'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Client table */}
|
||||
{clients.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface text-text-secondary text-left">
|
||||
{/* Expand chevron column */}
|
||||
<th className="w-8 px-3 py-2.5" />
|
||||
|
||||
<th className="px-3 py-2.5 w-16">Status</th>
|
||||
|
||||
<th
|
||||
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
|
||||
onClick={() => handleSort('ip')}
|
||||
aria-sort={getAriaSort('ip')}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
IP Address
|
||||
<SortIcon field="ip" currentField={sortField} currentDir={sortDir} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<th
|
||||
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
|
||||
onClick={() => handleSort('mac')}
|
||||
aria-sort={getAriaSort('mac')}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
MAC Address
|
||||
<SortIcon field="mac" currentField={sortField} currentDir={sortDir} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<th
|
||||
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
|
||||
onClick={() => handleSort('hostname')}
|
||||
aria-sort={getAriaSort('hostname')}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
Hostname
|
||||
<SortIcon field="hostname" currentField={sortField} currentDir={sortDir} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<th
|
||||
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
|
||||
onClick={() => handleSort('interface')}
|
||||
aria-sort={getAriaSort('interface')}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
Interface
|
||||
<SortIcon field="interface" currentField={sortField} currentDir={sortDir} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<th className="px-3 py-2.5">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => {
|
||||
const isExpanded = expandedMac === client.mac
|
||||
const canExpand = client.is_wireless
|
||||
|
||||
return (
|
||||
<ClientRow
|
||||
key={client.mac}
|
||||
client={client}
|
||||
isExpanded={isExpanded}
|
||||
canExpand={canExpand}
|
||||
onToggle={() => setExpandedMac(isExpanded ? null : client.mac)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last updated timestamp */}
|
||||
{data?.timestamp && (
|
||||
<p className="text-xs text-text-muted text-right">
|
||||
Last updated: {new Date(data.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client row sub-component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ClientRow({
|
||||
client,
|
||||
isExpanded,
|
||||
canExpand,
|
||||
onToggle,
|
||||
}: {
|
||||
client: ClientDevice
|
||||
isExpanded: boolean
|
||||
canExpand: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const dbm = parseSignalDbm(client.signal_strength)
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={cn(
|
||||
'border-t border-border hover:bg-elevated/50 transition-colors',
|
||||
canExpand && 'cursor-pointer',
|
||||
)}
|
||||
onClick={canExpand ? onToggle : undefined}
|
||||
>
|
||||
{/* Expand chevron */}
|
||||
<td className="px-3 py-2.5">
|
||||
{canExpand && (
|
||||
isExpanded
|
||||
? <ChevronDown className="h-3.5 w-3.5 text-text-muted" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 text-text-muted" />
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status dot */}
|
||||
<td className="px-3 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-2 w-2 rounded-full',
|
||||
client.status === 'reachable' ? 'bg-success' : 'bg-text-muted',
|
||||
)}
|
||||
title={client.status}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* IP */}
|
||||
<td className="px-3 py-2.5 font-mono text-xs">{client.ip}</td>
|
||||
|
||||
{/* MAC */}
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{client.mac}</td>
|
||||
|
||||
{/* Hostname */}
|
||||
<td className="px-3 py-2.5">
|
||||
{client.hostname ? (
|
||||
<span className="text-text-primary">{client.hostname}</span>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Interface */}
|
||||
<td className="px-3 py-2.5 text-text-secondary">{client.interface || '\u2014'}</td>
|
||||
|
||||
{/* Type badge */}
|
||||
<td className="px-3 py-2.5">
|
||||
{client.is_wireless ? (
|
||||
<Badge className="gap-1 bg-accent/10 text-accent border-accent/30">
|
||||
<Wifi className="h-3 w-3" />
|
||||
WiFi
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1">
|
||||
<Cable className="h-3 w-3" />
|
||||
Wired
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded wireless detail row */}
|
||||
{isExpanded && canExpand && (
|
||||
<tr className="border-t border-border/50 bg-elevated/30">
|
||||
<td colSpan={7} className="px-6 py-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
|
||||
{/* Signal strength */}
|
||||
<div>
|
||||
<span className="text-text-muted block mb-0.5">Signal Strength</span>
|
||||
{dbm !== null ? (
|
||||
<span className={cn('font-medium flex items-center gap-1', signalColor(dbm))}>
|
||||
<Signal className="h-3.5 w-3.5" />
|
||||
{client.signal_strength} ({signalLabel(dbm)})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TX Rate */}
|
||||
<div>
|
||||
<span className="text-text-muted block mb-0.5">TX Rate</span>
|
||||
<span className="font-medium text-text-primary">
|
||||
{client.tx_rate || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* RX Rate */}
|
||||
<div>
|
||||
<span className="text-text-muted block mb-0.5">RX Rate</span>
|
||||
<span className="font-medium text-text-primary">
|
||||
{client.rx_rate || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div>
|
||||
<span className="text-text-muted block mb-0.5">Uptime</span>
|
||||
<span className="font-medium text-text-primary">
|
||||
{client.uptime || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
178
frontend/src/components/network/InterfaceGauges.tsx
Normal file
178
frontend/src/components/network/InterfaceGauges.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { metricsApi, type InterfaceMetricPoint } from '@/lib/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface InterfaceGaugesProps {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Heuristic speed defaults (bps) based on interface name prefix. */
|
||||
function inferMaxSpeed(ifaceName: string): number {
|
||||
const name = ifaceName.toLowerCase()
|
||||
if (name.startsWith('wlan') || name.startsWith('wifi') || name.startsWith('cap')) {
|
||||
return 300_000_000 // 300 Mbps for wireless
|
||||
}
|
||||
if (name.startsWith('sfp') || name.startsWith('sfpplus') || name.startsWith('qsfp')) {
|
||||
return 10_000_000_000 // 10 Gbps for SFP+
|
||||
}
|
||||
if (name.startsWith('lte') || name.startsWith('ppp')) {
|
||||
return 100_000_000 // 100 Mbps for LTE/PPP
|
||||
}
|
||||
// Default to 1 Gbps for ethernet
|
||||
return 1_000_000_000
|
||||
}
|
||||
|
||||
/** Format bps to a human-readable 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 `${Math.round(bps)} bps`
|
||||
}
|
||||
|
||||
/** Format max speed for display. */
|
||||
function formatMaxSpeed(bps: number): string {
|
||||
if (bps >= 1_000_000_000) return `${bps / 1_000_000_000} Gbps`
|
||||
if (bps >= 1_000_000) return `${bps / 1_000_000} Mbps`
|
||||
return `${bps / 1_000} Kbps`
|
||||
}
|
||||
|
||||
/** Get color class based on utilization percentage. */
|
||||
function getBarColor(pct: number): string {
|
||||
if (pct >= 80) return 'bg-error'
|
||||
if (pct >= 50) return 'bg-warning'
|
||||
return 'bg-success'
|
||||
}
|
||||
|
||||
interface GaugeBarProps {
|
||||
label: string
|
||||
value: number
|
||||
maxSpeed: number
|
||||
direction: 'RX' | 'TX'
|
||||
}
|
||||
|
||||
function GaugeBar({ label, value, maxSpeed, direction }: GaugeBarProps) {
|
||||
const pct = Math.min((value / maxSpeed) * 100, 100)
|
||||
const colorClass = getBarColor(pct)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-medium text-text-muted w-6 text-right shrink-0">
|
||||
{direction}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${colorClass}`}
|
||||
style={{ width: `${Math.max(pct, 0.5)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-text-secondary w-24 text-right shrink-0">
|
||||
{formatBps(value)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesProps) {
|
||||
// Fetch the list of interfaces
|
||||
const { data: interfaces } = useQuery({
|
||||
queryKey: ['interfaces-list', tenantId, deviceId],
|
||||
queryFn: () => metricsApi.interfaceList(tenantId, deviceId),
|
||||
enabled: active,
|
||||
})
|
||||
|
||||
// Fetch latest interface metrics (last 5 minutes)
|
||||
const now = new Date()
|
||||
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000)
|
||||
const { data: metricsData, isLoading } = useQuery({
|
||||
queryKey: ['interface-gauges', tenantId, deviceId],
|
||||
queryFn: () =>
|
||||
metricsApi.interfaces(
|
||||
tenantId,
|
||||
deviceId,
|
||||
fiveMinAgo.toISOString(),
|
||||
now.toISOString(),
|
||||
),
|
||||
refetchInterval: active ? 15_000 : false,
|
||||
enabled: active,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-surface p-3">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-3 w-full mb-1" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!interfaces || interfaces.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
|
||||
No interface data available.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compute latest values per interface from recent metrics
|
||||
const latestByIface = new Map<string, { rx: number; tx: number }>()
|
||||
if (metricsData && metricsData.length > 0) {
|
||||
// Group by interface and take the latest bucket
|
||||
const grouped = new Map<string, InterfaceMetricPoint[]>()
|
||||
for (const point of metricsData) {
|
||||
const key = point.interface
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key)!.push(point)
|
||||
}
|
||||
for (const [ifName, points] of grouped) {
|
||||
// Sort by bucket descending, take latest
|
||||
points.sort((a, b) => b.bucket.localeCompare(a.bucket))
|
||||
const latest = points[0]
|
||||
latestByIface.set(ifName, {
|
||||
rx: latest.avg_rx_bps ?? latest.max_rx_bps ?? 0,
|
||||
tx: latest.avg_tx_bps ?? latest.max_tx_bps ?? 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{interfaces.map((ifaceName) => {
|
||||
const maxSpeed = inferMaxSpeed(ifaceName)
|
||||
const values = latestByIface.get(ifaceName) ?? { rx: 0, tx: 0 }
|
||||
|
||||
return (
|
||||
<div key={ifaceName} className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-sm font-medium text-text-primary">{ifaceName}</span>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
/ {formatMaxSpeed(maxSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<GaugeBar
|
||||
label={ifaceName}
|
||||
value={values.rx}
|
||||
maxSpeed={maxSpeed}
|
||||
direction="RX"
|
||||
/>
|
||||
<GaugeBar
|
||||
label={ifaceName}
|
||||
value={values.tx}
|
||||
maxSpeed={maxSpeed}
|
||||
direction="TX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
frontend/src/components/network/LogsTab.tsx
Normal file
267
frontend/src/components/network/LogsTab.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search, RefreshCw } from 'lucide-react'
|
||||
import { networkApi, type LogEntry } from '@/lib/networkApi'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface LogsTabProps {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Common RouterOS log topics and their severity colors. */
|
||||
function getTopicColor(topics: string): { bg: string; text: string } {
|
||||
const t = topics.toLowerCase()
|
||||
if (t.includes('critical') || t.includes('error')) {
|
||||
return { bg: 'bg-error/10', text: 'text-error' }
|
||||
}
|
||||
if (t.includes('warning')) {
|
||||
return { bg: 'bg-warning/10', text: 'text-warning' }
|
||||
}
|
||||
if (t.includes('info')) {
|
||||
return { bg: 'bg-accent/10', text: 'text-accent' }
|
||||
}
|
||||
return { bg: 'bg-elevated', text: 'text-text-muted' }
|
||||
}
|
||||
|
||||
/** Whether a log entry has error/critical severity. */
|
||||
function isErrorEntry(topics: string): boolean {
|
||||
const t = topics.toLowerCase()
|
||||
return t.includes('error') || t.includes('critical')
|
||||
}
|
||||
|
||||
const TOPIC_OPTIONS = [
|
||||
'system',
|
||||
'firewall',
|
||||
'dhcp',
|
||||
'wireless',
|
||||
'interface',
|
||||
'error',
|
||||
'warning',
|
||||
'info',
|
||||
'critical',
|
||||
'dns',
|
||||
'ppp',
|
||||
'ipsec',
|
||||
'wireguard',
|
||||
'ospf',
|
||||
'bgp',
|
||||
]
|
||||
|
||||
const LIMIT_OPTIONS = [50, 100, 200, 500]
|
||||
|
||||
function TopicBadge({ topics }: { topics: string }) {
|
||||
const colors = getTopicColor(topics)
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${colors.bg} ${colors.text}`}
|
||||
>
|
||||
{topics}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3 py-2 px-3">
|
||||
<Skeleton className="h-4 w-32 shrink-0" />
|
||||
<Skeleton className="h-4 w-20 shrink-0" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogsTab({ tenantId, deviceId, active }: LogsTabProps) {
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTopic, setSelectedTopic] = useState('')
|
||||
const [limit, setLimit] = useState(100)
|
||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounce search input
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchInput(value)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearchQuery(value)
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
// Clean up debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['device-logs', tenantId, deviceId, limit, selectedTopic, searchQuery],
|
||||
queryFn: () =>
|
||||
networkApi.getDeviceLogs(tenantId, deviceId, {
|
||||
limit,
|
||||
topic: selectedTopic || undefined,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
refetchInterval: active && autoRefresh ? 10_000 : false,
|
||||
enabled: active,
|
||||
})
|
||||
|
||||
// Extract unique topics from data for reference
|
||||
const uniqueTopics = useMemo(() => {
|
||||
if (!data?.logs) return TOPIC_OPTIONS
|
||||
const fromData = new Set<string>()
|
||||
for (const entry of data.logs) {
|
||||
if (entry.topics) {
|
||||
for (const t of entry.topics.split(',')) {
|
||||
fromData.add(t.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Merge with common topics, deduplicate
|
||||
const all = new Set([...TOPIC_OPTIONS, ...fromData])
|
||||
return [...all].sort()
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Controls bar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchInput}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs rounded border border-border bg-elevated/50 text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topic filter */}
|
||||
<select
|
||||
value={selectedTopic}
|
||||
onChange={(e) => setSelectedTopic(e.target.value)}
|
||||
className="text-xs rounded border border-border bg-elevated/50 text-text-primary px-2 py-1.5 [color-scheme:dark]"
|
||||
>
|
||||
<option value="">All Topics</option>
|
||||
{uniqueTopics.map((topic) => (
|
||||
<option key={topic} value={topic}>
|
||||
{topic}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Limit selector */}
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
className="text-xs rounded border border-border bg-elevated/50 text-text-primary px-2 py-1.5 [color-scheme:dark]"
|
||||
>
|
||||
{LIMIT_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} entries
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Auto-refresh toggle */}
|
||||
<button
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded border transition-colors ${
|
||||
autoRefresh
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-border bg-elevated/50 text-text-muted hover:text-text-primary'
|
||||
}`}
|
||||
title={autoRefresh ? 'Auto-refresh on (10s)' : 'Auto-refresh off'}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${autoRefresh ? 'animate-spin' : ''}`} />
|
||||
Auto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Log table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-sm text-error">
|
||||
Failed to fetch device logs. The device may be offline or unreachable.
|
||||
</div>
|
||||
) : !data || data.logs.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm font-medium text-text-primary mb-1">No log entries found</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{searchQuery || selectedTopic
|
||||
? 'Try adjusting your search or topic filter.'
|
||||
: 'Device returned no logs.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<table className="w-full text-left font-mono text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-elevated/95 backdrop-blur-sm">
|
||||
<tr className="border-b border-border">
|
||||
<th className="py-2 px-3 text-[10px] font-medium text-text-muted uppercase tracking-wider w-[160px]">
|
||||
Time
|
||||
</th>
|
||||
<th className="py-2 px-3 text-[10px] font-medium text-text-muted uppercase tracking-wider w-[140px]">
|
||||
Topics
|
||||
</th>
|
||||
<th className="py-2 px-3 text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
Message
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.logs.map((entry: LogEntry, i: number) => {
|
||||
const errorRow = isErrorEntry(entry.topics)
|
||||
return (
|
||||
<tr
|
||||
key={`${entry.time}-${i}`}
|
||||
className={`border-b border-border/50 last:border-b-0 ${
|
||||
errorRow
|
||||
? 'bg-error/5'
|
||||
: i % 2 === 0
|
||||
? ''
|
||||
: 'bg-elevated/30'
|
||||
}`}
|
||||
>
|
||||
<td className="py-1.5 px-3 text-text-muted whitespace-nowrap align-top">
|
||||
{entry.time}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 align-top">
|
||||
<TopicBadge topics={entry.topics} />
|
||||
</td>
|
||||
<td
|
||||
className={`py-1.5 px-3 break-all ${
|
||||
errorRow ? 'text-error' : 'text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{entry.message}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entry count */}
|
||||
{data && data.count > 0 && (
|
||||
<div className="text-[10px] text-text-muted text-right">
|
||||
Showing {data.count} entr{data.count === 1 ? 'y' : 'ies'}
|
||||
{autoRefresh && ' (auto-refreshing every 10s)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
394
frontend/src/components/network/TopologyMap.tsx
Normal file
394
frontend/src/components/network/TopologyMap.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* TopologyMap -- Reactflow-based network topology visualization.
|
||||
*
|
||||
* Renders managed MikroTik devices as custom nodes with hostname, IP, and
|
||||
* status indicators. Edges represent neighbor discovery or shared-subnet
|
||||
* connections. Uses dagre for automatic hierarchical layout.
|
||||
*
|
||||
* Double-click a node to navigate to its device detail page.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeProps,
|
||||
Handle,
|
||||
Position,
|
||||
MarkerType,
|
||||
} from 'reactflow'
|
||||
import dagre from '@dagrejs/dagre'
|
||||
import { Router, Server, Loader2, NetworkIcon, Eye, EyeOff } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { networkApi, type TopologyNode, type TopologyEdge } from '@/lib/networkApi'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dagre layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NODE_WIDTH = 200
|
||||
const NODE_HEIGHT = 80
|
||||
|
||||
function getLayoutedElements(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100, ranker: 'network-simplex' })
|
||||
|
||||
nodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||
})
|
||||
|
||||
edges.forEach((edge) => {
|
||||
g.setEdge(edge.source, edge.target)
|
||||
})
|
||||
|
||||
dagre.layout(g)
|
||||
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id)
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
||||
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes: layoutedNodes, edges }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge color rotation (chart tokens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EDGE_COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
'hsl(var(--chart-6))',
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom DeviceNode component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeviceNodeData {
|
||||
hostname: string
|
||||
ip: string
|
||||
status: string
|
||||
model: string | null
|
||||
uptime: string | null
|
||||
}
|
||||
|
||||
function DeviceNode({ data }: NodeProps<DeviceNodeData>) {
|
||||
const isOnline = data.status === 'online'
|
||||
const DeviceIcon = data.model?.toLowerCase().includes('switch') ? Server : Router
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-surface shadow-md px-3 py-2 min-w-[180px]',
|
||||
'transition-shadow hover:shadow-lg',
|
||||
isOnline ? 'border-border' : 'border-error/30',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="!bg-accent !w-2 !h-2" />
|
||||
<div className="flex items-start gap-2">
|
||||
<DeviceIcon className="h-5 w-5 text-text-muted flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-text-primary truncate">
|
||||
{data.hostname}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full flex-shrink-0',
|
||||
isOnline ? 'bg-success' : 'bg-error',
|
||||
)}
|
||||
title={isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted block truncate">{data.ip}</span>
|
||||
{data.model && (
|
||||
<span className="text-[10px] text-text-muted/70 block truncate">
|
||||
{data.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-accent !w-2 !h-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeTypes = { device: DeviceNode }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tooltip (shown on single click / hover)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TooltipData {
|
||||
hostname: string
|
||||
ip: string
|
||||
model: string | null
|
||||
status: string
|
||||
uptime: string | null
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function NodeTooltip({ data, onClose }: { data: TooltipData; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 rounded-lg border border-border bg-elevated shadow-lg px-3 py-2 text-xs pointer-events-none"
|
||||
style={{ left: data.x + 10, top: data.y - 10 }}
|
||||
>
|
||||
<div className="font-medium text-text-primary">{data.hostname}</div>
|
||||
<div className="text-text-muted mt-0.5">IP: {data.ip}</div>
|
||||
{data.model && <div className="text-text-muted">Model: {data.model}</div>}
|
||||
<div className="text-text-muted">
|
||||
Status:{' '}
|
||||
<span className={data.status === 'online' ? 'text-success' : 'text-error'}>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
{data.uptime && <div className="text-text-muted">Uptime: {data.uptime}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TopologyMap component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TopologyMapProps {
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
export function TopologyMap({ tenantId }: TopologyMapProps) {
|
||||
const navigate = useNavigate()
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null)
|
||||
const [showSubnetEdges, setShowSubnetEdges] = useState(false)
|
||||
|
||||
const { data: topology, isLoading, error } = useQuery({
|
||||
queryKey: ['topology', tenantId],
|
||||
queryFn: () => networkApi.getTopology(tenantId),
|
||||
enabled: !!tenantId,
|
||||
refetchInterval: 5 * 60 * 1000, // Re-fetch every 5 minutes (matches cache TTL)
|
||||
})
|
||||
|
||||
// Count subnet edges for the toggle label
|
||||
const subnetEdgeCount = useMemo(
|
||||
() => topology?.edges.filter((e: TopologyEdge) => e.label === 'shared subnet').length ?? 0,
|
||||
[topology],
|
||||
)
|
||||
|
||||
// Convert backend data to reactflow nodes/edges
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!topology?.nodes.length) return { nodes: [], edges: [] }
|
||||
|
||||
const rfNodes: Node<DeviceNodeData>[] = topology.nodes.map((n: TopologyNode) => ({
|
||||
id: n.id,
|
||||
type: 'device',
|
||||
data: {
|
||||
hostname: n.hostname,
|
||||
ip: n.ip,
|
||||
status: n.status,
|
||||
model: n.model,
|
||||
uptime: n.uptime,
|
||||
},
|
||||
position: { x: 0, y: 0 }, // Will be set by dagre
|
||||
}))
|
||||
|
||||
// Filter edges: exclude subnet edges when toggle is off
|
||||
const visibleEdges = topology.edges.filter(
|
||||
(e: TopologyEdge) => showSubnetEdges || e.label !== 'shared subnet',
|
||||
)
|
||||
|
||||
const rfEdges: Edge[] = visibleEdges.map((e: TopologyEdge, idx: number) => {
|
||||
const isSubnet = e.label === 'shared subnet'
|
||||
const isVpn = e.label === 'vpn tunnel'
|
||||
return {
|
||||
id: `${e.source}-${e.target}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: isSubnet ? undefined : e.label,
|
||||
animated: isVpn,
|
||||
style: {
|
||||
stroke: isSubnet
|
||||
? 'hsl(var(--muted))'
|
||||
: isVpn
|
||||
? 'hsl(var(--accent))'
|
||||
: EDGE_COLORS[idx % EDGE_COLORS.length],
|
||||
strokeWidth: isSubnet ? 1 : 2,
|
||||
strokeDasharray: isSubnet ? '6 3' : undefined,
|
||||
opacity: isSubnet ? 0.4 : 1,
|
||||
},
|
||||
labelStyle: { fontSize: 10, fill: 'hsl(var(--text-muted))' },
|
||||
labelBgStyle: { fill: 'hsl(var(--surface))', fillOpacity: 0.9 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
markerEnd: isSubnet
|
||||
? undefined
|
||||
: { type: MarkerType.ArrowClosed, width: 12, height: 12 },
|
||||
}
|
||||
})
|
||||
|
||||
return getLayoutedElements(rfNodes, rfEdges)
|
||||
}, [topology, showSubnetEdges])
|
||||
|
||||
// Double-click navigates to device detail
|
||||
const onNodeDoubleClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node<DeviceNodeData>) => {
|
||||
navigate({
|
||||
to: '/tenants/$tenantId/devices/$deviceId',
|
||||
params: { tenantId, deviceId: node.id },
|
||||
})
|
||||
},
|
||||
[navigate, tenantId],
|
||||
)
|
||||
|
||||
// Single-click shows tooltip
|
||||
const onNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node<DeviceNodeData>) => {
|
||||
setTooltip({
|
||||
hostname: node.data.hostname,
|
||||
ip: node.data.ip,
|
||||
model: node.data.model,
|
||||
status: node.data.status,
|
||||
uptime: node.data.uptime,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Clear tooltip on pane click
|
||||
const onPaneClick = useCallback(() => {
|
||||
setTooltip(null)
|
||||
}, [])
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-accent" />
|
||||
<span className="text-sm text-text-muted">Loading topology...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="rounded-lg border border-error/30 bg-error/5 p-6 text-center max-w-sm">
|
||||
<p className="text-sm text-error">Failed to load topology</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!nodes.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<NetworkIcon className="h-12 w-12 text-text-muted/40" />
|
||||
<div>
|
||||
<p className="text-sm text-text-secondary">No devices found</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Add devices to your tenant to see the network topology.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full" data-testid="topology-map">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="hsl(var(--muted))" gap={20} size={1} />
|
||||
<Controls
|
||||
className="!bg-surface !border-border !shadow-md [&>button]:!bg-surface [&>button]:!border-border [&>button]:!text-text-secondary [&>button:hover]:!bg-elevated"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const data = node.data as DeviceNodeData
|
||||
return data.status === 'online'
|
||||
? 'hsl(var(--success))'
|
||||
: 'hsl(var(--error))'
|
||||
}}
|
||||
maskColor="hsl(var(--background) / 0.7)"
|
||||
className="!bg-surface !border-border"
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Click tooltip */}
|
||||
{tooltip && <NodeTooltip data={tooltip} onClose={() => setTooltip(null)} />}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 left-4 rounded-lg border border-border bg-surface/90 backdrop-blur-sm px-3 py-2 text-xs text-text-muted">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-success" /> Online
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-error" /> Offline
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-4 border-t-2 border-accent" /> VPN
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-4 border-t-2" style={{ borderColor: 'hsl(var(--chart-1))' }} /> Neighbor
|
||||
</span>
|
||||
{subnetEdgeCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowSubnetEdges((v) => !v)}
|
||||
className="flex items-center gap-1.5 hover:text-text-secondary transition-colors"
|
||||
title={showSubnetEdges ? 'Hide shared subnet edges' : 'Show shared subnet edges'}
|
||||
>
|
||||
{showSubnetEdges ? (
|
||||
<Eye className="h-3 w-3" />
|
||||
) : (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
)}
|
||||
<span className="w-4 border-t border-dashed border-muted" />
|
||||
Subnet ({subnetEdgeCount})
|
||||
</button>
|
||||
)}
|
||||
<span className="text-text-muted/60">Double-click to open device</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
frontend/src/components/network/VpnTab.tsx
Normal file
157
frontend/src/components/network/VpnTab.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Shield, Lock, Globe } from 'lucide-react'
|
||||
import { networkApi, type VpnTunnel } from '@/lib/networkApi'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface VpnTabProps {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Format byte count to human-readable string. */
|
||||
function formatBytes(bytes: string | null): string {
|
||||
if (!bytes) return '--'
|
||||
const n = parseInt(bytes, 10)
|
||||
if (isNaN(n)) return bytes
|
||||
if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GB`
|
||||
if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`
|
||||
if (n >= 1_024) return `${(n / 1_024).toFixed(1)} KB`
|
||||
return `${n} B`
|
||||
}
|
||||
|
||||
/** VPN type configuration for icons and colors. */
|
||||
const VPN_TYPE_CONFIG = {
|
||||
wireguard: {
|
||||
icon: Shield,
|
||||
label: 'WireGuard',
|
||||
color: '#a855f7', // purple
|
||||
},
|
||||
ipsec: {
|
||||
icon: Lock,
|
||||
label: 'IPsec',
|
||||
color: '#3b82f6', // blue
|
||||
},
|
||||
l2tp: {
|
||||
icon: Globe,
|
||||
label: 'L2TP',
|
||||
color: '#22c55e', // green
|
||||
},
|
||||
} as const
|
||||
|
||||
function TunnelRow({ tunnel }: { tunnel: VpnTunnel }) {
|
||||
const config = VPN_TYPE_CONFIG[tunnel.type]
|
||||
const Icon = config.icon
|
||||
const isUp = tunnel.status === 'connected' || tunnel.status === 'established'
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border last:border-b-0 hover:bg-elevated/30 transition-colors">
|
||||
{/* Type */}
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex items-center justify-center w-6 h-6 rounded"
|
||||
style={{ backgroundColor: config.color + '20', color: config.color }}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<Badge color={config.color}>{config.label}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
{/* Remote Endpoint */}
|
||||
<td className="py-2.5 px-3 font-mono text-xs text-text-primary">
|
||||
{tunnel.remote_endpoint}
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="py-2.5 px-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
||||
isUp ? 'text-success' : 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${isUp ? 'bg-success' : 'bg-text-muted'}`}
|
||||
/>
|
||||
{tunnel.status}
|
||||
</span>
|
||||
</td>
|
||||
{/* Uptime */}
|
||||
<td className="py-2.5 px-3 text-xs text-text-secondary font-mono">
|
||||
{tunnel.uptime ?? '--'}
|
||||
</td>
|
||||
{/* RX */}
|
||||
<td className="py-2.5 px-3 text-xs text-text-secondary font-mono text-right">
|
||||
{formatBytes(tunnel.rx_bytes)}
|
||||
</td>
|
||||
{/* TX */}
|
||||
<td className="py-2.5 px-3 text-xs text-text-secondary font-mono text-right">
|
||||
{formatBytes(tunnel.tx_bytes)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['vpn-tunnels', tenantId, deviceId],
|
||||
queryFn: () => networkApi.getVpnTunnels(tenantId, deviceId),
|
||||
refetchInterval: active ? 30_000 : false,
|
||||
enabled: active,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface p-6 text-center text-sm text-error">
|
||||
Failed to load VPN tunnels. The device may not support this feature.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.tunnels.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<Shield className="w-10 h-10 mx-auto mb-3 text-text-muted opacity-40" />
|
||||
<p className="text-sm font-medium text-text-primary mb-1">
|
||||
No active VPN tunnels
|
||||
</p>
|
||||
<p className="text-xs text-text-muted max-w-sm mx-auto">
|
||||
VPN tunnels will appear here when WireGuard peers, IPsec SAs, or L2TP
|
||||
connections are active on this device.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/50">
|
||||
<th className="py-2 px-3 text-xs font-medium text-text-muted">Type</th>
|
||||
<th className="py-2 px-3 text-xs font-medium text-text-muted">Remote Endpoint</th>
|
||||
<th className="py-2 px-3 text-xs font-medium text-text-muted">Status</th>
|
||||
<th className="py-2 px-3 text-xs font-medium text-text-muted">Uptime</th>
|
||||
<th className="py-2 px-3 text-xs font-medium text-text-muted text-right">RX</th>
|
||||
<th className="py-2 px-3 text-xs font-medium text-text-muted text-right">TX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.tunnels.map((tunnel, i) => (
|
||||
<TunnelRow key={`${tunnel.type}-${tunnel.remote_endpoint}-${i}`} tunnel={tunnel} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user