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,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">&mdash;</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">&mdash;</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>
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}