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,230 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CheckCircle2, XCircle } from 'lucide-react'
import { devicesApi, vpnApi } from '@/lib/api'
import { toast } from '@/components/ui/toast'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { VpnOnboardingWizard } from '@/components/vpn/VpnOnboardingWizard'
interface Props {
tenantId: string
open: boolean
onClose: () => void
}
type ConnectionStatus = 'idle' | 'success' | 'error'
export function AddDeviceForm({ tenantId, open, onClose }: Props) {
const queryClient = useQueryClient()
const [form, setForm] = useState({
hostname: '',
ip_address: '',
api_port: '8728',
api_ssl_port: '8729',
username: '',
password: '',
})
const [error, setError] = useState<string | null>(null)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle')
// Check if VPN is enabled for this tenant
const { data: vpnConfig } = useQuery({
queryKey: ['vpn-config', tenantId],
queryFn: () => vpnApi.getConfig(tenantId),
enabled: open && !!tenantId,
})
const vpnEnabled = vpnConfig?.is_enabled ?? false
const mutation = useMutation({
mutationFn: () =>
devicesApi.create(tenantId, {
hostname: form.hostname || form.ip_address,
ip_address: form.ip_address,
api_port: parseInt(form.api_port) || 8728,
api_ssl_port: parseInt(form.api_ssl_port) || 8729,
username: form.username,
password: form.password,
}),
onSuccess: (device) => {
setConnectionStatus('success')
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
void queryClient.invalidateQueries({ queryKey: ['tenants'] })
toast({ title: `Device "${device.hostname}" added successfully` })
setTimeout(() => handleClose(), 1000)
},
onError: (err: unknown) => {
setConnectionStatus('error')
const detail =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
?? 'Connection failed. Check the IP address, port, and credentials.'
setError(detail)
},
})
const handleClose = () => {
setForm({
hostname: '',
ip_address: '',
api_port: '8728',
api_ssl_port: '8729',
username: '',
password: '',
})
setError(null)
setConnectionStatus('idle')
onClose()
}
const handleVpnSuccess = () => {
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
void queryClient.invalidateQueries({ queryKey: ['vpn-peers', tenantId] })
handleClose()
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.ip_address.trim() || !form.username.trim() || !form.password.trim()) {
setError('IP address, username, and password are required')
return
}
setError(null)
setConnectionStatus('idle')
mutation.mutate()
}
const update = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((f) => ({ ...f, [field]: e.target.value }))
if (error) setError(null)
setConnectionStatus('idle')
}
const directConnectionForm = (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label htmlFor="device-ip">IP Address / Hostname *</Label>
<Input
id="device-ip"
value={form.ip_address}
onChange={update('ip_address')}
placeholder="192.168.1.1"
autoFocus={!vpnEnabled}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-hostname">Display Name</Label>
<Input
id="device-hostname"
value={form.hostname}
onChange={update('hostname')}
placeholder="router-01 (optional)"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-api-port">API Port</Label>
<Input
id="device-api-port"
value={form.api_port}
onChange={update('api_port')}
placeholder="8728"
type="number"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-ssl-port">TLS API Port</Label>
<Input
id="device-ssl-port"
value={form.api_ssl_port}
onChange={update('api_ssl_port')}
placeholder="8729"
type="number"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-username">Username *</Label>
<Input
id="device-username"
value={form.username}
onChange={update('username')}
placeholder="admin"
autoComplete="off"
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label htmlFor="device-password">Password *</Label>
<Input
id="device-password"
type="password"
value={form.password}
onChange={update('password')}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
</div>
{connectionStatus === 'success' && (
<div className="flex items-center gap-2 rounded-md bg-success/10 border border-success/50 px-3 py-2">
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
<p className="text-xs text-success">Device connected and added successfully</p>
</div>
)}
{connectionStatus === 'error' && error && (
<div className="flex items-center gap-2 rounded-md bg-error/10 border border-error/50 px-3 py-2">
<XCircle className="h-4 w-4 text-error flex-shrink-0" />
<p className="text-xs text-error">{error}</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={handleClose} size="sm">
Cancel
</Button>
<Button type="submit" size="sm" disabled={mutation.isPending}>
{mutation.isPending ? 'Connecting...' : 'Add Device'}
</Button>
</DialogFooter>
</form>
)
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Device</DialogTitle>
</DialogHeader>
{vpnEnabled ? (
<Tabs defaultValue="vpn" className="mt-2">
<TabsList className="w-full">
<TabsTrigger value="vpn" className="flex-1">VPN Onboarding</TabsTrigger>
<TabsTrigger value="direct" className="flex-1">Direct Connection</TabsTrigger>
</TabsList>
<TabsContent value="vpn" className="mt-4">
<VpnOnboardingWizard
tenantId={tenantId}
onSuccess={handleVpnSuccess}
onCancel={handleClose}
/>
</TabsContent>
<TabsContent value="direct" className="mt-4">
{directConnectionForm}
</TabsContent>
</Tabs>
) : (
directConnectionForm
)}
</DialogContent>
</Dialog>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
import { useNavigate, useSearch } from '@tanstack/react-router'
import { Search, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { useCallback, useRef, useEffect } from 'react'
interface DeviceFiltersProps {
tenantId: string
}
const DEBOUNCE_MS = 300
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function DeviceFilters({ tenantId }: DeviceFiltersProps) {
// Use relative navigation for filter params
const navigate = useNavigate()
// Safely get search params
let searchObj: { search?: string; status?: string; page?: number; page_size?: number } = {}
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
searchObj = useSearch({ from: '/_authenticated/tenants/$tenantId/devices/' }) as typeof searchObj
} catch {
searchObj = {}
}
const searchText = searchObj.search ?? ''
const statusFilter = searchObj.status ?? ''
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Sync input value with URL param
useEffect(() => {
if (inputRef.current && inputRef.current.value !== searchText) {
inputRef.current.value = searchText
}
}, [searchText])
const updateFilter = useCallback(
(updates: Record<string, string | number | undefined>) => {
void navigate({
to: '/tenants/$tenantId/devices',
params: { tenantId },
search: (prev) => ({ ...prev, ...updates, page: 1 }),
})
},
[navigate, tenantId],
)
const handleSearch = (value: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
updateFilter({ search: value || undefined })
}, DEBOUNCE_MS)
}
const handleStatus = (value: string) => {
updateFilter({ status: value === 'all' ? undefined : value })
}
const hasFilters = !!(searchText || statusFilter)
const clearFilters = () => {
if (inputRef.current) inputRef.current.value = ''
updateFilter({ search: undefined, status: undefined })
}
return (
<div className="flex items-center gap-2">
{/* Text search */}
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-text-muted" />
<Input
ref={inputRef}
className="pl-8"
placeholder="Search hostname, IP..."
defaultValue={searchText}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{/* Status filter */}
<Select value={statusFilter || 'all'} onValueChange={handleStatus}>
<SelectTrigger className="w-32">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All status</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
<SelectItem value="unknown">Not Yet Polled</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-text-muted">
<X className="h-3.5 w-3.5 mr-1" />
Clear
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,297 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/lib/auth'
import { metricsApi, tenantsApi } from '@/lib/api'
import { useUIStore } from '@/lib/store'
import { alertsApi } from '@/lib/alertsApi'
import { useEventStreamContext } from '@/contexts/EventStreamContext'
import { LayoutDashboard } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
import { KpiCards } from '@/components/dashboard/KpiCards'
import { HealthScore } from '@/components/dashboard/HealthScore'
import { EventsTimeline } from '@/components/dashboard/EventsTimeline'
import { BandwidthChart, type BandwidthDevice } from '@/components/dashboard/BandwidthChart'
import { AlertSummary } from '@/components/dashboard/AlertSummary'
import { QuickActions } from '@/components/dashboard/QuickActions'
// ─── Types ───────────────────────────────────────────────────────────────────
type RefreshInterval = 15000 | 30000 | 60000 | false
const REFRESH_OPTIONS: { label: string; value: RefreshInterval }[] = [
{ label: '15s', value: 15000 },
{ label: '30s', value: 30000 },
{ label: '60s', value: 60000 },
{ label: 'Off', value: false },
]
// ─── Dashboard Skeleton ──────────────────────────────────────────────────────
function DashboardSkeleton() {
return (
<div className="space-y-4">
{/* KPI cards skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border p-4">
<Skeleton className="h-3 w-24 mb-2" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
{/* Widget grid skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-48 w-full" />
</div>
<div className="rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-32 w-full" />
</div>
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-48 w-full" />
</div>
<div className="rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-32 w-full" />
</div>
</div>
</div>
)
}
// ─── Fleet Dashboard ─────────────────────────────────────────────────────────
export function FleetDashboard() {
const { user } = useAuth()
const isSuperAdmin = user?.role === 'super_admin'
const { selectedTenantId } = useUIStore()
const tenantId = isSuperAdmin ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// Fetch tenants for super admins to resolve selected org name
const { data: tenants } = useQuery({
queryKey: ['tenants'],
queryFn: tenantsApi.list,
enabled: !!isSuperAdmin,
})
const selectedTenantName = tenants?.find((t) => t.id === selectedTenantId)?.name
const [refreshInterval, setRefreshInterval] = useState<RefreshInterval>(30000)
// ── SSE connection state (disable polling when connected) ────────────────
const { connectionState } = useEventStreamContext()
const isSSEConnected = connectionState === 'connected'
// ── Fleet summary query ──────────────────────────────────────────────────
const {
data: fleetDevices,
isLoading: fleetLoading,
isFetching: fleetFetching,
dataUpdatedAt,
} = useQuery({
queryKey: ['fleet-summary', isSuperAdmin ? 'all' : tenantId],
queryFn: () =>
isSuperAdmin
? metricsApi.fleetSummaryAll()
: metricsApi.fleetSummary(tenantId),
// Disable polling when SSE is connected (events update cache directly)
refetchInterval: isSSEConnected ? false : refreshInterval,
enabled: !!user,
})
// ── Alerts query (for counts by severity) ────────────────────────────────
const { data: alertsData } = useQuery({
queryKey: ['dashboard-alerts', tenantId, 'firing'],
queryFn: () =>
alertsApi.getAlerts(tenantId, {
status: 'firing',
per_page: 200,
}),
// Disable polling when SSE is connected (events invalidate cache)
refetchInterval: isSSEConnected ? false : refreshInterval,
enabled: !!user && !isSuperAdmin && !!tenantId,
})
// ── Derived data ─────────────────────────────────────────────────────────
const totalDevices = fleetDevices?.length ?? 0
const onlineDevices = useMemo(
() => fleetDevices?.filter((d) => d.status === 'online') ?? [],
[fleetDevices],
)
const onlinePercent =
totalDevices > 0 ? (onlineDevices.length / totalDevices) * 100 : 0
// Alert counts
const alerts = alertsData?.items ?? []
const criticalCount = alerts.filter((a) => a.severity === 'critical').length
const warningCount = alerts.filter((a) => a.severity === 'warning').length
const infoCount = alerts.filter((a) => a.severity === 'info').length
const totalAlerts = criticalCount + warningCount + infoCount
// Health score device data
const healthDevices = useMemo(
() =>
fleetDevices?.map((d) => ({
status: d.status,
last_cpu_load: d.last_cpu_load,
last_memory_used_pct: d.last_memory_used_pct,
})) ?? [],
[fleetDevices],
)
// Top resource consumers (using CPU load as proxy for bandwidth)
// Sort by CPU load descending, take top 10
const topConsumers: BandwidthDevice[] = useMemo(() => {
if (!fleetDevices) return []
return [...fleetDevices]
.filter((d) => d.status === 'online' && d.last_cpu_load != null)
.sort((a, b) => (b.last_cpu_load ?? 0) - (a.last_cpu_load ?? 0))
.slice(0, 10)
.map((d) => ({
hostname: d.hostname,
deviceId: d.id,
tenantId: d.tenant_id,
// Use CPU load percentage as the bandwidth metric for visualization
bandwidthBps: (d.last_cpu_load ?? 0) * 10_000_000, // Scale to make chart readable
}))
}, [fleetDevices])
// Total "bandwidth" (sum of CPU loads scaled)
const totalBandwidthBps = useMemo(
() => topConsumers.reduce((sum, d) => sum + d.bandwidthBps, 0),
[topConsumers],
)
// Last updated timestamp
const lastUpdated = dataUpdatedAt
? new Date(dataUpdatedAt).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
: null
const isRefreshing = fleetFetching && !fleetLoading
return (
<div className="space-y-6" data-testid="dashboard">
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">Dashboard</h1>
<p className="text-sm text-text-muted mt-0.5">
Fleet overview across{' '}
{isSuperAdmin
? selectedTenantId && selectedTenantName
? selectedTenantName
: 'all tenants'
: 'your organization'}
</p>
</div>
<div className="flex items-center gap-3">
{/* Refresh indicator */}
{isRefreshing && (
<span className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent" />
</span>
Refreshing
</span>
)}
{/* Last updated */}
{lastUpdated && !isRefreshing && (
<span className="text-xs text-text-muted hidden sm:inline">
Updated {lastUpdated}
</span>
)}
{/* Refresh interval selector */}
<div className="flex items-center rounded-md border border-border bg-surface">
{REFRESH_OPTIONS.map((opt) => (
<button
key={opt.label}
onClick={() => setRefreshInterval(opt.value)}
data-testid={`refresh-${opt.label.toLowerCase()}`}
className={cn(
'px-2.5 py-1 text-xs font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
refreshInterval === opt.value
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* ── Dashboard Content ───────────────────────────────────────────── */}
{fleetLoading ? (
<DashboardSkeleton />
) : totalDevices === 0 ? (
<EmptyState
icon={LayoutDashboard}
title="No fleet data"
description="Add devices to see your fleet dashboard."
/>
) : (
<>
{/* KPI Cards — full width, 4 columns */}
<KpiCards
totalDevices={totalDevices}
onlinePercent={onlinePercent}
activeAlerts={totalAlerts}
totalBandwidthBps={totalBandwidthBps}
/>
{/* Widget Grid — responsive 3 columns */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Events Timeline — spans 2 columns on desktop */}
<div className="lg:col-span-2">
<EventsTimeline
tenantId={tenantId}
isSuperAdmin={isSuperAdmin ?? false}
/>
</div>
{/* Right column: Alert Summary + Quick Actions stacked */}
<div className="space-y-4">
<AlertSummary
criticalCount={criticalCount}
warningCount={warningCount}
infoCount={infoCount}
tenantId={tenantId}
/>
<QuickActions
tenantId={tenantId}
isSuperAdmin={isSuperAdmin ?? false}
/>
</div>
{/* Bandwidth / Top Resource Consumers — spans 2 columns on desktop */}
<div className="lg:col-span-2">
<BandwidthChart devices={topConsumers} />
</div>
{/* Health Score */}
<div>
<HealthScore
devices={healthDevices}
activeAlerts={totalAlerts}
criticalAlerts={criticalCount}
/>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,455 @@
import { useRef, useState, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronUp, ChevronDown, ChevronsUpDown, Monitor } from 'lucide-react'
import { devicesApi, type DeviceResponse } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { useShortcut } from '@/hooks/useShortcut'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { formatUptime, formatDateTime } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { TableSkeleton } from '@/components/ui/page-skeleton'
import { EmptyState } from '@/components/ui/empty-state'
interface FleetTableProps {
tenantId: string
search?: string
status?: string
sortBy?: string
sortDir?: 'asc' | 'desc'
page?: number
pageSize?: number
}
type SortDir = 'asc' | 'desc'
function StatusDot({ status }: { status: string }) {
const colors: Record<string, string> = {
online: 'bg-online',
offline: 'bg-offline',
unknown: 'bg-unknown',
}
return (
<span
className={cn('inline-block w-2 h-2 rounded-full flex-shrink-0', colors[status] ?? colors.unknown)}
title={status}
/>
)
}
interface SortHeaderProps {
column: string
label: string
currentSort: string
currentDir: SortDir
onSort: (col: string) => void
className?: string
}
function SortHeader({ column, label, currentSort, currentDir, onSort, className }: SortHeaderProps) {
const isActive = currentSort === column
const ariaSortValue: 'ascending' | 'descending' | 'none' = isActive
? (currentDir === 'asc' ? 'ascending' : 'descending')
: 'none'
return (
<th scope="col" className={cn('px-2 py-2 text-xs font-medium text-text-muted', className)} aria-sort={ariaSortValue}>
<button
className="flex items-center gap-1 hover:text-text-primary transition-colors group"
onClick={() => onSort(column)}
data-testid={`sort-${column}`}
>
{label}
{isActive ? (
currentDir === 'asc' ? (
<ChevronUp className="h-3 w-3 text-text-secondary" />
) : (
<ChevronDown className="h-3 w-3 text-text-secondary" />
)
) : (
<ChevronsUpDown className="h-3 w-3 text-text-muted group-hover:text-text-secondary" />
)}
</button>
</th>
)
}
function DeviceCard({ device, onClick }: { device: DeviceResponse; onClick: () => void }) {
return (
<button
onClick={onClick}
className="w-full text-left rounded-lg border border-border bg-surface p-3 hover:bg-elevated/50 transition-colors min-h-[44px]"
data-testid={`device-card-${device.hostname}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={device.status} />
<span className="font-medium text-sm text-text-primary truncate">{device.hostname}</span>
</div>
<span className="text-xs text-text-muted shrink-0">{formatUptime(device.uptime_seconds)}</span>
</div>
<div className="mt-1.5 flex items-center gap-3 text-xs text-text-secondary">
<span className="font-mono">{device.ip_address}</span>
{device.model && <span>{device.model}</span>}
{device.routeros_version && <span>v{device.routeros_version}</span>}
</div>
{device.tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{device.tags.map((tag) => (
<Badge key={tag.id} color={tag.color} className="text-[10px]">{tag.name}</Badge>
))}
</div>
)}
</button>
)
}
const VIRTUAL_SCROLL_THRESHOLD = 100
const VIRTUAL_ROW_HEIGHT = 48
const VIRTUAL_OVERSCAN = 10
export function FleetTable({
tenantId,
search,
status,
sortBy = 'hostname',
sortDir = 'asc',
page = 1,
pageSize = 25,
}: FleetTableProps) {
const navigate = useNavigate()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { data, isLoading, isFetching } = useQuery({
queryKey: ['devices', tenantId, { search, status, sortBy, sortDir, page, pageSize }],
queryFn: () =>
devicesApi.list(tenantId, {
search,
status,
sort_by: sortBy,
sort_dir: sortDir,
page,
page_size: pageSize,
}),
placeholderData: (prev) => prev,
})
const updateSearch = (updates: Record<string, string | number | undefined>) => {
void navigate({
to: '/tenants/$tenantId/devices',
params: { tenantId },
search: (prev) => ({ ...prev, ...updates }),
})
}
const handleSort = (col: string) => {
const newDir: SortDir =
col === sortBy ? (sortDir === 'asc' ? 'desc' : 'asc') : 'asc'
updateSearch({ sort_by: col, sort_dir: newDir, page: 1 })
}
const handleDeviceClick = (device: DeviceResponse) => {
void navigate({
to: '/tenants/$tenantId/devices/$deviceId',
params: { tenantId, deviceId: device.id },
})
}
const totalPages = data ? Math.ceil(data.total / data.page_size) : 0
const startItem = data ? (data.page - 1) * data.page_size + 1 : 0
const endItem = data ? Math.min(data.page * data.page_size, data.total) : 0
const sortProps = { currentSort: sortBy, currentDir: sortDir as SortDir, onSort: handleSort }
const items = data?.items ?? []
const useVirtual = items.length > VIRTUAL_SCROLL_THRESHOLD
const [selectedIndex, setSelectedIndex] = useState(-1)
// j/k/Enter keyboard navigation for device list
const hasItems = items.length > 0
useShortcut(
'j',
useCallback(() => {
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
}, [items.length]),
hasItems,
)
useShortcut(
'k',
useCallback(() => {
setSelectedIndex((prev) => Math.max(prev - 1, 0))
}, []),
hasItems,
)
useShortcut(
'Enter',
useCallback(() => {
if (selectedIndex >= 0 && selectedIndex < items.length) {
handleDeviceClick(items[selectedIndex])
}
}, [selectedIndex, items]),
hasItems && selectedIndex >= 0,
)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => VIRTUAL_ROW_HEIGHT,
overscan: VIRTUAL_OVERSCAN,
enabled: useVirtual,
})
function renderDeviceRow(device: DeviceResponse) {
return (
<>
<td className="px-2 py-1.5 text-center">
<StatusDot status={device.status} />
</td>
<td className="px-2 py-1.5 font-medium">{device.hostname}</td>
<td className="px-2 py-1.5 font-mono text-xs text-text-secondary">
{device.ip_address}
</td>
<td className="px-2 py-1.5 text-text-secondary">{device.model ?? '—'}</td>
<td className="px-2 py-1.5 text-text-secondary">
{device.routeros_version ?? '—'}
</td>
<td className="px-2 py-1.5 text-text-secondary">
{device.firmware_version || '—'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{formatUptime(device.uptime_seconds)}
</td>
<td className="px-2 py-1.5 text-xs text-text-muted">
{formatDateTime(device.last_seen)}
</td>
<td className="px-2 py-1.5">
<div className="flex flex-wrap gap-1">
{device.tags.map((tag) => (
<Badge key={tag.id} color={tag.color} className="text-xs">
{tag.name}
</Badge>
))}
</div>
</td>
</>
)
}
const tableHead = (
<thead>
<tr className="border-b border-border bg-surface">
<th scope="col" className="px-2 py-2 text-xs font-medium text-text-muted w-6"></th>
<SortHeader column="hostname" label="Hostname" {...sortProps} className="text-left" />
<SortHeader column="ip_address" label="IP" {...sortProps} className="text-left" />
<SortHeader column="model" label="Model" {...sortProps} className="text-left" />
<SortHeader column="routeros_version" label="RouterOS" {...sortProps} className="text-left" />
<SortHeader column="firmware_version" label="Firmware" {...sortProps} className="text-left" />
<SortHeader column="uptime_seconds" label="Uptime" {...sortProps} className="text-right" />
<SortHeader column="last_seen" label="Last Seen" {...sortProps} className="text-left" />
<th scope="col" className="px-2 py-2 text-xs font-medium text-text-muted text-left">Tags</th>
</tr>
</thead>
)
return (
<div className="space-y-2" data-testid="fleet-table">
{/* Mobile card view (below lg:) */}
<div className={cn(
'lg:hidden space-y-2',
isFetching && !isLoading && 'opacity-70',
)}>
{isLoading ? (
<TableSkeleton rows={3} />
) : items.length === 0 ? (
<EmptyState
icon={Monitor}
title="No devices yet"
description="Add your first device to start monitoring your network."
action={{
label: 'Add Device',
onClick: () => void navigate({
to: '/tenants/$tenantId/devices',
params: { tenantId },
search: { add: 'true' },
}),
}}
/>
) : (
items.map((device) => (
<DeviceCard
key={device.id}
device={device}
onClick={() => handleDeviceClick(device)}
/>
))
)}
</div>
{/* Desktop table view (lg: and above) */}
<div
className={cn(
'hidden lg:block rounded-lg border border-border overflow-hidden transition-opacity',
isFetching && !isLoading && 'opacity-70',
)}
>
{useVirtual ? (
/* Virtual scrolling for large lists (>100 items) */
<div className="overflow-x-auto">
<div className="min-w-[900px]">
<table className="w-full text-sm">
{tableHead}
</table>
<div
ref={scrollContainerRef}
className="max-h-[calc(100vh-300px)] overflow-y-auto"
>
<div
style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}
>
<table className="w-full text-sm">
<tbody>
{virtualizer.getVirtualItems().map((virtualRow) => {
const device = items[virtualRow.index]
return (
<tr
key={device.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={cn(
'border-b border-border/50 hover:bg-elevated/50 cursor-pointer transition-colors',
selectedIndex === virtualRow.index && 'bg-elevated/50',
)}
onClick={() => handleDeviceClick(device)}
aria-selected={selectedIndex === virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderDeviceRow(device)}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
) : (
/* Standard table for small lists (<=100 items) */
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[900px]">
{tableHead}
<tbody>
{isLoading ? (
<tr>
<td colSpan={9} className="px-3 py-4">
<TableSkeleton />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={9}>
<EmptyState
icon={Monitor}
title="No devices yet"
description="Add your first device to start monitoring your network."
action={{
label: 'Add Device',
onClick: () => void navigate({
to: '/tenants/$tenantId/devices',
params: { tenantId },
search: { add: 'true' },
}),
}}
/>
</td>
</tr>
) : (
items.map((device, idx) => (
<tr
key={device.id}
data-testid={`device-row-${device.hostname}`}
className={cn(
'border-b border-border/50 hover:bg-elevated/50 cursor-pointer transition-colors',
selectedIndex === idx && 'bg-elevated/50',
)}
onClick={() => handleDeviceClick(device)}
aria-selected={selectedIndex === idx}
>
{renderDeviceRow(device)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination (shown for both views) */}
{data && data.total > 0 && (
<div className="flex items-center justify-between text-xs text-text-muted">
<span>
Showing {startItem}{endItem} of {data.total} device{data.total !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-3">
<Select
value={String(pageSize)}
onValueChange={(v) => updateSearch({ page_size: parseInt(v), page: 1 })}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25 / page</SelectItem>
<SelectItem value="50">50 / page</SelectItem>
<SelectItem value="100">100 / page</SelectItem>
<SelectItem value="250">250 / page</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => updateSearch({ page: page - 1 })}
disabled={page <= 1}
className="h-7 px-2"
data-testid="pagination-prev"
>
Prev
</Button>
<span className="px-2">
{page} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => updateSearch({ page: page + 1 })}
disabled={page >= totalPages}
className="h-7 px-2"
data-testid="pagination-next"
>
Next
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,224 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { CheckCircle2, XCircle, Wifi, WifiOff } from 'lucide-react'
import { devicesApi, type SubnetScanResponse } from '@/lib/api'
import { toast } from '@/components/ui/toast'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
interface Props {
tenantId: string
results: SubnetScanResponse
onDone: () => void
}
interface DeviceCredentials {
username: string
password: string
}
export function ScanResultsList({ tenantId, results, onDone }: Props) {
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [sharedCreds, setSharedCreds] = useState<DeviceCredentials>({
username: 'admin',
password: '',
})
const [useShared] = useState(true)
const mutation = useMutation({
mutationFn: () =>
devicesApi.bulkAdd(tenantId, {
devices: Array.from(selected).map((ip) => {
const discovered = results.discovered.find((d) => d.ip_address === ip)
return {
ip_address: ip,
hostname: discovered?.hostname ?? undefined,
}
}),
shared_username: useShared ? sharedCreds.username : undefined,
shared_password: useShared ? sharedCreds.password : undefined,
}),
onSuccess: (data) => {
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
void queryClient.invalidateQueries({ queryKey: ['tenants'] })
const added = data.added.length
const failed = data.failed.length
toast({
title: `${added} device${added !== 1 ? 's' : ''} added${failed > 0 ? `, ${failed} failed` : ''}`,
variant: failed > 0 ? 'destructive' : 'default',
})
onDone()
},
onError: () => {
toast({ title: 'Bulk add failed', variant: 'destructive' })
},
})
const toggleSelect = (ip: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(ip)) next.delete(ip)
else next.add(ip)
return next
})
}
const selectAll = () => {
setSelected(new Set(results.discovered.map((d) => d.ip_address)))
}
const deselectAll = () => setSelected(new Set())
const allSelected =
results.discovered.length > 0 && selected.size === results.discovered.length
return (
<div className="space-y-4">
{/* Summary */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">
Scan complete {' '}
<span className="text-success">{results.total_discovered} discovered</span>
{' '}of {results.total_scanned} addresses scanned
</p>
<p className="text-xs text-text-muted mt-0.5">CIDR: {results.cidr}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? 'Deselect All' : 'Select All'}
</Button>
</div>
</div>
{results.discovered.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-text-muted text-sm">
No MikroTik devices found in this range
</div>
) : (
<>
{/* Device checklist */}
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<th className="px-3 py-2 w-8">
<Checkbox
checked={allSelected}
onCheckedChange={(c) => (c ? selectAll() : deselectAll())}
/>
</th>
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">IP Address</th>
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">Hostname</th>
<th className="text-center px-3 py-2 text-xs font-medium text-text-muted">API</th>
<th className="text-center px-3 py-2 text-xs font-medium text-text-muted">TLS</th>
</tr>
</thead>
<tbody>
{results.discovered.map((device) => (
<tr
key={device.ip_address}
className="border-b border-border/50 hover:bg-surface cursor-pointer"
onClick={() => toggleSelect(device.ip_address)}
>
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(device.ip_address)}
onCheckedChange={() => toggleSelect(device.ip_address)}
/>
</td>
<td className="px-3 py-2 font-mono text-xs">{device.ip_address}</td>
<td className="px-3 py-2 text-text-secondary">{device.hostname ?? '—'}</td>
<td className="px-3 py-2 text-center">
{device.api_port_open ? (
<Wifi className="h-3.5 w-3.5 text-success mx-auto" />
) : (
<WifiOff className="h-3.5 w-3.5 text-text-muted mx-auto" />
)}
</td>
<td className="px-3 py-2 text-center">
{device.api_ssl_port_open ? (
<Wifi className="h-3.5 w-3.5 text-success mx-auto" />
) : (
<WifiOff className="h-3.5 w-3.5 text-text-muted mx-auto" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Credentials */}
{selected.size > 0 && (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">
Credentials for {selected.size} selected device{selected.size !== 1 ? 's' : ''}
</h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="bulk-username">Username</Label>
<Input
id="bulk-username"
value={sharedCreds.username}
onChange={(e) =>
setSharedCreds((c) => ({ ...c, username: e.target.value }))
}
placeholder="admin"
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bulk-password">Password</Label>
<Input
id="bulk-password"
type="password"
value={sharedCreds.password}
onChange={(e) =>
setSharedCreds((c) => ({ ...c, password: e.target.value }))
}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-text-muted">
Shared credentials used for all selected devices
</p>
<Button
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !sharedCreds.username || !sharedCreds.password}
>
{mutation.isPending ? (
'Adding...'
) : (
<>
<CheckCircle2 className="h-3.5 w-3.5" />
Add {selected.size} Device{selected.size !== 1 ? 's' : ''}
</>
)}
</Button>
</div>
{mutation.isError && (
<div className="flex items-center gap-2 rounded-md bg-error/10 border border-error/50 px-3 py-2">
<XCircle className="h-4 w-4 text-error" />
<p className="text-xs text-error">Failed to add devices. Please try again.</p>
</div>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Search, AlertCircle } from 'lucide-react'
import { devicesApi, type SubnetScanResponse } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface Props {
tenantId: string
onResults: (results: SubnetScanResponse) => void
}
export function SubnetScanForm({ tenantId, onResults }: Props) {
const [cidr, setCidr] = useState('')
const [error, setError] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: () => devicesApi.scan(tenantId, cidr),
onSuccess: (data) => {
onResults(data)
},
onError: (err: unknown) => {
const detail =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
?? 'Scan failed. Check the CIDR format.'
setError(detail)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!cidr.trim()) {
setError('CIDR is required (e.g. 192.168.1.0/24)')
return
}
setError(null)
mutation.mutate()
}
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold">Scan Subnet</h2>
<p className="text-xs text-text-muted mt-0.5">
Discover MikroTik devices on a network range (max /20 4096 IPs)
</p>
</div>
<form onSubmit={handleSubmit} className="flex items-end gap-2">
<div className="flex-1 max-w-xs space-y-1.5">
<Label htmlFor="scan-cidr">Network CIDR</Label>
<Input
id="scan-cidr"
value={cidr}
onChange={(e) => {
setCidr(e.target.value)
if (error) setError(null)
}}
placeholder="e.g., 192.168.1.0/24"
autoFocus
/>
<p className="text-[10px] text-text-muted mt-0.5">CIDR notation /24 scans 254 addresses</p>
</div>
<Button type="submit" size="sm" disabled={mutation.isPending}>
{mutation.isPending ? (
<>
<Search className="h-3.5 w-3.5 animate-pulse" />
Scanning...
</>
) : (
<>
<Search className="h-3.5 w-3.5" />
Scan
</>
)}
</Button>
</form>
{error && (
<div className="flex items-center gap-2 rounded-md bg-error/10 border border-error/50 px-3 py-2">
<AlertCircle className="h-4 w-4 text-error flex-shrink-0" />
<p className="text-xs text-error">{error}</p>
</div>
)}
{mutation.isPending && (
<div className="text-xs text-text-muted animate-pulse">
Scanning {cidr}... This may take up to 30 seconds for larger ranges.
</div>
)}
</div>
)
}