- WifiPanel: revert useEffect back to useState initializer (avoids synchronous setState in effect) - Device detail: avoid Date.now() during render for push alert check Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
926 lines
35 KiB
TypeScript
926 lines
35 KiB
TypeScript
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<DeviceUpdate>({
|
|
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 },
|
|
) => (
|
|
<div className="space-y-1">
|
|
<Label htmlFor={id} className="text-xs text-text-secondary">
|
|
{label}
|
|
</Label>
|
|
<Input
|
|
id={id}
|
|
type={opts?.type ?? 'text'}
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={opts?.placeholder}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent data-testid="dialog-edit-device">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Device</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{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' })}
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-3 space-y-1">
|
|
<p className="text-xs text-text-muted mb-2">Leave blank to keep existing credentials</p>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{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' })}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-3">
|
|
<p className="text-xs text-text-muted mb-2">GPS coordinates (optional)</p>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{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' })}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={updateMutation.isPending}>
|
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const config: Record<string, { label: string; className: string }> = {
|
|
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 (
|
|
<span className={cn('inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border', c.className)}>
|
|
<Circle className="h-2 w-2 fill-current" />
|
|
{c.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function TlsSecurityBadge({ tlsMode }: { tlsMode: string }) {
|
|
const config: Record<string, { label: string; icon: React.ElementType; className: string }> = {
|
|
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 (
|
|
<span className={cn('inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border', c.className)}>
|
|
<Icon className="h-3 w-3" />
|
|
{c.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function TlsModeSelector({
|
|
tenantId,
|
|
deviceId,
|
|
currentMode,
|
|
}: {
|
|
tenantId: string
|
|
deviceId: string
|
|
currentMode: string
|
|
}) {
|
|
const queryClient = useQueryClient()
|
|
const [confirmPlain, setConfirmPlain] = useState(false)
|
|
const [pendingMode, setPendingMode] = useState<string | null>(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 (
|
|
<>
|
|
<Select value={currentMode} onValueChange={handleChange}>
|
|
<SelectTrigger className="h-7 text-xs w-36" data-testid="select-tls-mode">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="auto">Auto (recommended)</SelectItem>
|
|
<SelectItem value="portal_ca">CA Verified</SelectItem>
|
|
<SelectItem value="insecure">Insecure TLS</SelectItem>
|
|
<SelectItem value="plain">Plain-Text</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Dialog open={confirmPlain} onOpenChange={setConfirmPlain}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-error">
|
|
<ShieldOff className="h-5 w-5" />
|
|
Enable Plain-Text Connection?
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3 text-sm text-text-secondary">
|
|
<p>
|
|
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.
|
|
</p>
|
|
<div className="rounded border border-error/30 bg-error/5 px-3 py-2 text-xs text-error">
|
|
Credentials will be transmitted in clear text. Anyone on the network
|
|
can intercept them.
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setConfirmPlain(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={updateMutation.isPending}
|
|
onClick={() => pendingMode && updateMutation.mutate(pendingMode)}
|
|
>
|
|
{updateMutation.isPending ? 'Saving...' : 'Enable Plain-Text'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-start gap-4 py-2 border-b border-border/50 last:border-0">
|
|
<span className="text-xs text-text-muted w-32 flex-shrink-0 pt-0.5">{label}</span>
|
|
<span className="text-sm text-text-primary flex-1">{value ?? '—'}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <DetailPageSkeleton />
|
|
}
|
|
|
|
if (!device) {
|
|
return <div className="text-text-muted text-sm">Device not found</div>
|
|
}
|
|
|
|
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 (
|
|
<div className={cn('space-y-4', mode === 'simple' ? 'max-w-5xl' : 'max-w-3xl')} data-testid="device-detail">
|
|
{/* Breadcrumb */}
|
|
<div className="flex items-center gap-1 text-xs text-text-muted">
|
|
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
|
|
Tenants
|
|
</Link>
|
|
<ChevronRight className="h-3 w-3" />
|
|
<Link
|
|
to="/tenants/$tenantId/devices"
|
|
params={{ tenantId }}
|
|
className="hover:text-text-secondary transition-colors"
|
|
>
|
|
{tenant?.name ?? tenantId}
|
|
</Link>
|
|
<ChevronRight className="h-3 w-3" />
|
|
<span className="text-text-secondary">{device.hostname}</span>
|
|
</div>
|
|
|
|
{/* Device header */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-lg font-semibold" data-testid="device-hostname">{device.hostname}</h1>
|
|
<StatusBadge status={device.status} />
|
|
<TlsSecurityBadge tlsMode={device.tls_mode} />
|
|
</div>
|
|
<p className="font-mono text-sm text-text-secondary">{device.ip_address}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<SimpleModeToggle mode={mode} onModeChange={toggleMode} />
|
|
{user?.role !== 'viewer' && (
|
|
<div className="flex gap-2">
|
|
{device.routeros_version !== null && (
|
|
<>
|
|
<WinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
|
<RemoteWinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
|
</>
|
|
)}
|
|
<SSHTerminal tenantId={tenantId} deviceId={deviceId} deviceName={device.hostname} />
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
{canWrite(user) && (
|
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)} data-testid="button-edit-device">
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
{canDelete(user) && (
|
|
<Button variant="destructive" size="sm" onClick={handleDelete} data-testid="button-delete-device">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Emergency rollback banner */}
|
|
<RollbackAlert
|
|
tenantId={tenantId}
|
|
deviceId={deviceId}
|
|
deviceStatus={device.status}
|
|
hasRecentPushAlert={hasRecentPushAlert}
|
|
/>
|
|
|
|
{/* Config View (Simple or Standard) */}
|
|
<SimpleConfigView
|
|
tenantId={tenantId}
|
|
deviceId={deviceId}
|
|
device={device}
|
|
mode={mode}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
onModeChange={toggleMode}
|
|
overviewContent={
|
|
<>
|
|
{/* Device info */}
|
|
<div className="rounded-lg border border-border bg-surface px-4 py-2">
|
|
<InfoRow label="Model" value={device.model} />
|
|
<InfoRow label="RouterOS" value={device.routeros_version} />
|
|
<InfoRow label="Firmware" value={device.firmware_version || 'N/A'} />
|
|
<InfoRow label="Uptime" value={formatUptime(device.uptime_seconds)} />
|
|
<InfoRow label="Last Seen" value={formatDateTime(device.last_seen)} />
|
|
<InfoRow label="Serial" value={device.serial_number || 'N/A'} />
|
|
<InfoRow label="API Port" value={`${device.api_port} (plain) / ${device.api_ssl_port} (TLS)`} />
|
|
<InfoRow
|
|
label="TLS Mode"
|
|
value={
|
|
<div className="flex items-center gap-2">
|
|
<TlsSecurityBadge tlsMode={device.tls_mode} />
|
|
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
|
<TlsModeSelector
|
|
tenantId={tenantId}
|
|
deviceId={device.id}
|
|
currentMode={device.tls_mode}
|
|
/>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
<InfoRow label="Added" value={formatDate(device.created_at)} />
|
|
</div>
|
|
|
|
{/* Credentials (masked) */}
|
|
<div className="rounded-lg border border-border bg-surface px-4 py-3">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium text-text-secondary">Credentials</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowCreds((v) => !v)}
|
|
className="h-6 px-2 text-xs"
|
|
>
|
|
{showCreds ? (
|
|
<>
|
|
<EyeOff className="h-3 w-3" /> Hide
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="h-3 w-3" /> Reveal
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex gap-4">
|
|
<span className="text-xs text-text-muted w-20">Username</span>
|
|
<span className="font-mono">
|
|
{showCreds ? '(stored \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<span className="text-xs text-text-muted w-20">Password</span>
|
|
<span className="font-mono">
|
|
{showCreds ? '(encrypted at rest \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Groups */}
|
|
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<FolderOpen className="h-4 w-4 text-text-muted" />
|
|
<h3 className="text-sm font-medium text-text-secondary">Groups</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{device.groups.map((group) => (
|
|
<div
|
|
key={group.id}
|
|
className="flex items-center gap-1 text-xs border border-border-bright rounded px-2 py-1"
|
|
>
|
|
{group.name}
|
|
{canWrite(user) && (
|
|
<button
|
|
onClick={() => removeFromGroupMutation.mutate(group.id)}
|
|
className="text-text-muted hover:text-text-secondary ml-1"
|
|
title="Remove from group"
|
|
>
|
|
×
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{device.groups.length === 0 && (
|
|
<span className="text-xs text-text-muted">No groups assigned</span>
|
|
)}
|
|
</div>
|
|
{canWrite(user) && availableGroups.length > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<Select onValueChange={(id) => addToGroupMutation.mutate(id)}>
|
|
<SelectTrigger className="h-7 text-xs w-48">
|
|
<SelectValue placeholder="Add to group..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableGroups.map((g) => (
|
|
<SelectItem key={g.id} value={g.id}>
|
|
{g.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Tag className="h-4 w-4 text-text-muted" />
|
|
<h3 className="text-sm font-medium text-text-secondary">Tags</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{device.tags.map((tag) => (
|
|
<div key={tag.id} className="flex items-center gap-1">
|
|
<Badge color={tag.color}>
|
|
{tag.name}
|
|
{canWrite(user) && (
|
|
<button
|
|
onClick={() => removeTagMutation.mutate(tag.id)}
|
|
className="ml-1 opacity-60 hover:opacity-100"
|
|
title="Remove tag"
|
|
>
|
|
×
|
|
</button>
|
|
)}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
{device.tags.length === 0 && (
|
|
<span className="text-xs text-text-muted">No tags assigned</span>
|
|
)}
|
|
</div>
|
|
{canWrite(user) && availableTags.length > 0 && (
|
|
<Select onValueChange={(id) => addTagMutation.mutate(id)}>
|
|
<SelectTrigger className="h-7 text-xs w-48">
|
|
<SelectValue placeholder="Add tag..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableTags.map((t) => (
|
|
<SelectItem key={t.id} value={t.id}>
|
|
{t.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Interface Utilization */}
|
|
<div className="rounded-lg border border-border bg-surface p-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-3">Interface Utilization</h3>
|
|
<InterfaceGauges tenantId={tenantId} deviceId={deviceId} active={activeTab === 'overview'} />
|
|
</div>
|
|
|
|
{/* Configuration History */}
|
|
<ConfigHistorySection tenantId={tenantId} deviceId={deviceId} deviceName={device.hostname} />
|
|
</>
|
|
}
|
|
alertsContent={
|
|
<DeviceAlertsSection tenantId={tenantId} deviceId={deviceId} active={activeTab === 'alerts'} />
|
|
}
|
|
/>
|
|
|
|
{canWrite(user) && (
|
|
<EditDeviceDialog
|
|
device={device}
|
|
tenantId={tenantId}
|
|
open={editOpen}
|
|
onOpenChange={setEditOpen}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, string> = {
|
|
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 (
|
|
<span
|
|
className={cn(
|
|
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
|
|
config[severity] ?? config.info,
|
|
)}
|
|
>
|
|
{severity}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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 <TableSkeleton rows={3} />
|
|
}
|
|
|
|
return (
|
|
<div className="mt-4 space-y-4">
|
|
{/* Active alerts */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-text-secondary mb-2 flex items-center gap-2">
|
|
<BellRing className="h-4 w-4" />
|
|
Active Alerts
|
|
{firingAlerts.length > 0 && (
|
|
<span className="bg-error/20 text-error text-xs px-1.5 rounded-full">
|
|
{firingAlerts.length}
|
|
</span>
|
|
)}
|
|
</h3>
|
|
|
|
{firingAlerts.length === 0 ? (
|
|
<div className="rounded-lg border border-border bg-surface p-6 text-center">
|
|
<CheckCircle className="h-6 w-6 text-success/50 mx-auto mb-1" />
|
|
<p className="text-xs text-text-muted">No active alerts for this device.</p>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
{firingAlerts.map((alert) => {
|
|
const isSilenced =
|
|
alert.silenced_until && new Date(alert.silenced_until) > new Date()
|
|
return (
|
|
<div
|
|
key={alert.id}
|
|
className="flex items-center gap-3 px-4 py-3 border-b border-border/50 last:border-0"
|
|
>
|
|
<BellRing className="h-4 w-4 text-error flex-shrink-0" />
|
|
<SeverityBadge severity={alert.severity} />
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm text-text-primary truncate block">
|
|
{alert.message ?? `${alert.metric} ${alert.value ?? ''}`}
|
|
</span>
|
|
<span className="text-xs text-text-muted">
|
|
{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)'}
|
|
</span>
|
|
</div>
|
|
{!alert.acknowledged_at && canWrite(user) && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => acknowledgeMutation.mutate(alert.id)}
|
|
>
|
|
Ack
|
|
</Button>
|
|
)}
|
|
{canWrite(user) && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
|
<BellOff className="h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
silenceMutation.mutate({ alertId: alert.id, minutes: 15 })
|
|
}
|
|
>
|
|
15 min
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
silenceMutation.mutate({ alertId: alert.id, minutes: 60 })
|
|
}
|
|
>
|
|
1 hour
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
silenceMutation.mutate({ alertId: alert.id, minutes: 240 })
|
|
}
|
|
>
|
|
4 hours
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
silenceMutation.mutate({ alertId: alert.id, minutes: 1440 })
|
|
}
|
|
>
|
|
24 hours
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Resolved alerts */}
|
|
{resolvedAlerts.length > 0 && (
|
|
<div>
|
|
<button
|
|
onClick={() => setShowResolved((v) => !v)}
|
|
className="text-sm font-medium text-text-muted hover:text-text-secondary flex items-center gap-2 mb-2"
|
|
>
|
|
<CheckCircle className="h-4 w-4" />
|
|
Recent Resolved ({resolvedAlerts.length})
|
|
<span className="text-xs">{showResolved ? '(hide)' : '(show)'}</span>
|
|
</button>
|
|
|
|
{showResolved && (
|
|
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
{resolvedAlerts.map((alert) => (
|
|
<div
|
|
key={alert.id}
|
|
className="flex items-center gap-3 px-4 py-2 border-b border-border/50 last:border-0 opacity-60"
|
|
>
|
|
<CheckCircle className="h-3.5 w-3.5 text-success flex-shrink-0" />
|
|
<SeverityBadge severity={alert.severity} />
|
|
<span className="text-xs text-text-secondary flex-1 truncate">
|
|
{alert.message ?? alert.metric ?? 'System alert'}
|
|
</span>
|
|
<span className="text-xs text-text-muted">
|
|
{alert.resolved_at ? timeAgo(alert.resolved_at) : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Link to full alerts page */}
|
|
<div className="text-center">
|
|
<Link
|
|
to="/alerts"
|
|
className="text-xs text-info hover:text-accent"
|
|
>
|
|
View all alerts for this device
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|