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:
230
frontend/src/components/fleet/AddDeviceForm.tsx
Normal file
230
frontend/src/components/fleet/AddDeviceForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1119
frontend/src/components/fleet/AdoptionWizard.tsx
Normal file
1119
frontend/src/components/fleet/AdoptionWizard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
110
frontend/src/components/fleet/DeviceFilters.tsx
Normal file
110
frontend/src/components/fleet/DeviceFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
297
frontend/src/components/fleet/FleetDashboard.tsx
Normal file
297
frontend/src/components/fleet/FleetDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
455
frontend/src/components/fleet/FleetTable.tsx
Normal file
455
frontend/src/components/fleet/FleetTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
224
frontend/src/components/fleet/ScanResultsList.tsx
Normal file
224
frontend/src/components/fleet/ScanResultsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
frontend/src/components/fleet/SubnetScanForm.tsx
Normal file
94
frontend/src/components/fleet/SubnetScanForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user