import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { ChevronRight, Eye, EyeOff, Pencil, Trash2, Circle, Tag, FolderOpen, BellOff, BellRing, CheckCircle, ShieldCheck, ShieldAlert, ShieldOff, Shield, } from 'lucide-react' import { devicesApi, deviceGroupsApi, deviceTagsApi, tenantsApi, configApi, type DeviceResponse, type DeviceUpdate } from '@/lib/api' import { alertsApi } from '@/lib/alertsApi' import { useAuth, canWrite, canDelete } from '@/lib/auth' import { toast } from '@/components/ui/toast' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { formatUptime, formatDateTime, formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' import { DetailPageSkeleton } from '@/components/ui/page-skeleton' import { TableSkeleton } from '@/components/ui/page-skeleton' import { InterfaceGauges } from '@/components/network/InterfaceGauges' import { ConfigHistorySection } from '@/components/config/ConfigHistorySection' // Phase 27: Simple Configuration Interface import { useSimpleConfigMode } from '@/hooks/useSimpleConfig' import { SimpleModeToggle } from '@/components/simple-config/SimpleModeToggle' import { SimpleConfigView } from '@/components/simple-config/SimpleConfigView' import { WinBoxButton } from '@/components/fleet/WinBoxButton' import { RemoteWinBoxButton } from '@/components/fleet/RemoteWinBoxButton' import { SSHTerminal } from '@/components/fleet/SSHTerminal' import { RollbackAlert } from '@/components/config/RollbackAlert' export const Route = createFileRoute( '/_authenticated/tenants/$tenantId/devices/$deviceId', )({ component: DeviceDetailPage, }) // --------------------------------------------------------------------------- // Edit Device Dialog // --------------------------------------------------------------------------- function EditDeviceDialog({ device, tenantId, open, onOpenChange, }: { device: DeviceResponse tenantId: string open: boolean onOpenChange: (open: boolean) => void }) { const queryClient = useQueryClient() const [form, setForm] = useState({ hostname: device.hostname, ip_address: device.ip_address, api_port: device.api_port, api_ssl_port: device.api_ssl_port, username: '', password: '', latitude: device.latitude ?? undefined, longitude: device.longitude ?? undefined, }) const updateMutation = useMutation({ mutationFn: (data: DeviceUpdate) => devicesApi.update(tenantId, device.id, data), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device', tenantId, device.id] }) void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] }) toast({ title: 'Device updated' }) onOpenChange(false) }, onError: () => toast({ title: 'Failed to update device', variant: 'destructive' }), }) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() // Only send fields that are non-empty strings / defined numbers const payload: DeviceUpdate = { hostname: form.hostname || undefined, ip_address: form.ip_address || undefined, api_port: form.api_port, api_ssl_port: form.api_ssl_port, latitude: form.latitude, longitude: form.longitude, } // Only include credentials if the user typed something if (form.username) payload.username = form.username if (form.password) payload.password = form.password updateMutation.mutate(payload) } const field = ( id: string, label: string, value: string | number | undefined, onChange: (v: string) => void, opts?: { type?: string; placeholder?: string }, ) => (
onChange(e.target.value)} placeholder={opts?.placeholder} className="h-8 text-sm" />
) return ( Edit Device
{field('hostname', 'Hostname', form.hostname, (v) => setForm((f) => ({ ...f, hostname: v })))} {field('ip_address', 'IP Address', form.ip_address, (v) => setForm((f) => ({ ...f, ip_address: v })))} {field('api_port', 'API Port', form.api_port, (v) => setForm((f) => ({ ...f, api_port: parseInt(v) || undefined })), { type: 'number' })} {field('api_ssl_port', 'API TLS Port', form.api_ssl_port, (v) => setForm((f) => ({ ...f, api_ssl_port: parseInt(v) || undefined })), { type: 'number' })}

Leave blank to keep existing credentials

{field('username', 'Username', form.username, (v) => setForm((f) => ({ ...f, username: v })), { placeholder: 'unchanged' })} {field('password', 'Password', form.password, (v) => setForm((f) => ({ ...f, password: v })), { type: 'password', placeholder: 'unchanged' })}

GPS coordinates (optional)

{field('latitude', 'Latitude', form.latitude, (v) => setForm((f) => ({ ...f, latitude: v ? parseFloat(v) : undefined })), { type: 'number', placeholder: '0.000000' })} {field('longitude', 'Longitude', form.longitude, (v) => setForm((f) => ({ ...f, longitude: v ? parseFloat(v) : undefined })), { type: 'number', placeholder: '0.000000' })}
) } function StatusBadge({ status }: { status: string }) { const config: Record = { online: { label: 'Online', className: 'text-success border-success/50 bg-success/10' }, offline: { label: 'Offline', className: 'text-error border-error/50 bg-error/10' }, unknown: { label: 'Unknown', className: 'text-text-muted border-border bg-elevated/50' }, } const c = config[status] ?? config.unknown return ( {c.label} ) } function TlsSecurityBadge({ tlsMode }: { tlsMode: string }) { const config: Record = { portal_ca: { label: 'CA Verified', icon: ShieldCheck, className: 'text-success border-success/50 bg-success/10', }, auto: { label: 'Self-Signed TLS', icon: Shield, className: 'text-warning border-warning/50 bg-warning/10', }, insecure: { label: 'Insecure TLS', icon: ShieldAlert, className: 'text-orange-400 border-orange-400/50 bg-orange-400/10', }, plain: { label: 'Plain-Text (Insecure)', icon: ShieldOff, className: 'text-error border-error/50 bg-error/10', }, } const c = config[tlsMode] ?? config.auto const Icon = c.icon return ( {c.label} ) } function TlsModeSelector({ tenantId, deviceId, currentMode, }: { tenantId: string deviceId: string currentMode: string }) { const queryClient = useQueryClient() const [confirmPlain, setConfirmPlain] = useState(false) const [pendingMode, setPendingMode] = useState(null) const updateMutation = useMutation({ mutationFn: (mode: string) => devicesApi.update(tenantId, deviceId, { tls_mode: mode }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] }) toast({ title: 'TLS mode updated' }) setConfirmPlain(false) setPendingMode(null) }, onError: () => toast({ title: 'Failed to update TLS mode', variant: 'destructive' }), }) const handleChange = (value: string) => { if (value === 'plain') { setPendingMode(value) setConfirmPlain(true) } else { updateMutation.mutate(value) } } return ( <> Enable Plain-Text Connection?

Plain-text mode sends credentials and all data unencrypted over the network. This is a serious security risk and should only be used for devices that do not support TLS at all.

Credentials will be transmitted in clear text. Anyone on the network can intercept them.
) } function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return (
{label} {value ?? '—'}
) } function DeviceDetailPage() { const { tenantId, deviceId } = Route.useParams() const navigate = useNavigate() const queryClient = useQueryClient() const { user } = useAuth() const [showCreds, setShowCreds] = useState(false) const [activeTab, setActiveTab] = useState('overview') const [editOpen, setEditOpen] = useState(false) const { mode, toggleMode } = useSimpleConfigMode(deviceId) const { data: device, isLoading } = useQuery({ queryKey: ['device', tenantId, deviceId], queryFn: () => devicesApi.get(tenantId, deviceId), }) const { data: tenant } = useQuery({ queryKey: ['tenants', tenantId], queryFn: () => tenantsApi.get(tenantId), }) const { data: backups } = useQuery({ queryKey: ['config-backups', tenantId, deviceId], queryFn: () => configApi.listBackups(tenantId, deviceId), }) // True if a pre-restore backup was created within the last 30 minutes, // indicating a config push just happened before the device went offline. const hasRecentPushAlert = backups?.some((b) => { if (b.trigger_type !== 'pre-restore') return false // created_at within last 30 minutes — compare timestamps without Date.now() const thirtyMinAgo = new Date() thirtyMinAgo.setMinutes(thirtyMinAgo.getMinutes() - 30) return new Date(b.created_at) > thirtyMinAgo }) ?? false const { data: groups } = useQuery({ queryKey: ['device-groups', tenantId], queryFn: () => deviceGroupsApi.list(tenantId), enabled: canWrite(user), }) const { data: tags } = useQuery({ queryKey: ['device-tags', tenantId], queryFn: () => deviceTagsApi.list(tenantId), enabled: canWrite(user), }) const deleteMutation = useMutation({ mutationFn: () => devicesApi.delete(tenantId, deviceId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] }) void queryClient.invalidateQueries({ queryKey: ['tenants'] }) toast({ title: 'Device deleted' }) void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId } }) }, onError: () => toast({ title: 'Failed to delete device', variant: 'destructive' }), }) const addToGroupMutation = useMutation({ mutationFn: (groupId: string) => devicesApi.addToGroup(tenantId, deviceId, groupId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] }) }, onError: () => toast({ title: 'Failed to add to group', variant: 'destructive' }), }) const removeFromGroupMutation = useMutation({ mutationFn: (groupId: string) => devicesApi.removeFromGroup(tenantId, deviceId, groupId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] }) }, onError: () => toast({ title: 'Failed to remove from group', variant: 'destructive' }), }) const addTagMutation = useMutation({ mutationFn: (tagId: string) => devicesApi.addTag(tenantId, deviceId, tagId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] }) }, onError: () => toast({ title: 'Failed to add tag', variant: 'destructive' }), }) const removeTagMutation = useMutation({ mutationFn: (tagId: string) => devicesApi.removeTag(tenantId, deviceId, tagId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] }) }, onError: () => toast({ title: 'Failed to remove tag', variant: 'destructive' }), }) const handleDelete = () => { if (confirm(`Delete device "${device?.hostname}"? This cannot be undone.`)) { deleteMutation.mutate() } } if (isLoading) { return } if (!device) { return
Device not found
} const deviceGroupIds = new Set(device.groups.map((g) => g.id)) const deviceTagIds = new Set(device.tags.map((t) => t.id)) const availableGroups = groups?.filter((g) => !deviceGroupIds.has(g.id)) ?? [] const availableTags = tags?.filter((t) => !deviceTagIds.has(t.id)) ?? [] return (
{/* Breadcrumb */}
Tenants {tenant?.name ?? tenantId} {device.hostname}
{/* Device header */}

{device.hostname}

{device.ip_address}

{user?.role !== 'viewer' && (
{device.routeros_version !== null && ( <> )}
)}
{canWrite(user) && ( )} {canDelete(user) && ( )}
{/* Emergency rollback banner */} {/* Config View (Simple or Standard) */} {/* Device info */}
{(user?.role === 'admin' || user?.role === 'super_admin') && ( )}
} />
{/* Credentials (masked) */}

Credentials

Username {showCreds ? '(stored \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
Password {showCreds ? '(encrypted at rest \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
{/* Groups */}

Groups

{device.groups.map((group) => (
{group.name} {canWrite(user) && ( )}
))} {device.groups.length === 0 && ( No groups assigned )}
{canWrite(user) && availableGroups.length > 0 && (
)}
{/* Tags */}

Tags

{device.tags.map((tag) => (
{tag.name} {canWrite(user) && ( )}
))} {device.tags.length === 0 && ( No tags assigned )}
{canWrite(user) && availableTags.length > 0 && ( )}
{/* Interface Utilization */}

Interface Utilization

{/* Configuration History */} } alertsContent={ } /> {canWrite(user) && ( )} ) } // --------------------------------------------------------------------------- // Device Alerts Section // --------------------------------------------------------------------------- function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime() const mins = Math.floor(diff / 60000) if (mins < 1) return 'just now' if (mins < 60) return `${mins}m ago` const hours = Math.floor(mins / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) return `${days}d ago` } function SeverityBadge({ severity }: { severity: string }) { const config: Record = { critical: 'bg-error/20 text-error border-error/40', warning: 'bg-warning/20 text-warning border-warning/40', info: 'bg-info/20 text-info border-info/40', } return ( {severity} ) } function DeviceAlertsSection({ tenantId, deviceId, active, }: { tenantId: string deviceId: string active: boolean }) { const queryClient = useQueryClient() const { user } = useAuth() const [showResolved, setShowResolved] = useState(false) const { data: alertsData, isLoading } = useQuery({ queryKey: ['device-alerts', tenantId, deviceId], queryFn: () => alertsApi.getDeviceAlerts(tenantId, deviceId, { per_page: 20 }), enabled: active, refetchInterval: active ? 30_000 : undefined, }) const acknowledgeMutation = useMutation({ mutationFn: (alertId: string) => alertsApi.acknowledgeAlert(tenantId, alertId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device-alerts'] }) void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] }) toast({ title: 'Alert acknowledged' }) }, }) const silenceMutation = useMutation({ mutationFn: ({ alertId, minutes }: { alertId: string; minutes: number }) => alertsApi.silenceAlert(tenantId, alertId, minutes), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['device-alerts'] }) void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] }) toast({ title: 'Alert silenced' }) }, }) const alerts = alertsData?.items ?? [] const firingAlerts = alerts.filter((a) => a.status === 'firing') const resolvedAlerts = alerts.filter((a) => a.status === 'resolved').slice(0, 5) if (isLoading) { return } return (
{/* Active alerts */}

Active Alerts {firingAlerts.length > 0 && ( {firingAlerts.length} )}

{firingAlerts.length === 0 ? (

No active alerts for this device.

) : (
{firingAlerts.map((alert) => { const isSilenced = alert.silenced_until && new Date(alert.silenced_until) > new Date() return (
{alert.message ?? `${alert.metric} ${alert.value ?? ''}`} {alert.rule_name && `${alert.rule_name} — `} {alert.threshold != null && `${alert.value != null ? Number(alert.value).toFixed(1) : '?'} / ${alert.threshold}`} {' — '} {timeAgo(alert.fired_at)} {isSilenced && ' (silenced)'}
{!alert.acknowledged_at && canWrite(user) && ( )} {canWrite(user) && ( silenceMutation.mutate({ alertId: alert.id, minutes: 15 }) } > 15 min silenceMutation.mutate({ alertId: alert.id, minutes: 60 }) } > 1 hour silenceMutation.mutate({ alertId: alert.id, minutes: 240 }) } > 4 hours silenceMutation.mutate({ alertId: alert.id, minutes: 1440 }) } > 24 hours )}
) })}
)}
{/* Resolved alerts */} {resolvedAlerts.length > 0 && (
{showResolved && (
{resolvedAlerts.map((alert) => (
{alert.message ?? alert.metric ?? 'System alert'} {alert.resolved_at ? timeAgo(alert.resolved_at) : ''}
))}
)}
)} {/* Link to full alerts page */}
View all alerts for this device
) }