feat(19-04): make device detail page type-aware with conditional rendering
- Add isRouterOS/isSNMP constants derived from device.device_type - Guard SimpleModeToggle, SSHTerminal, RollbackAlert, TlsSecurityBadge behind isRouterOS - Config backup query only fires for RouterOS devices - SNMP devices get dedicated layout: system info, SNMP profile, interface gauges, groups, tags, alerts - Header metadata shows SNMP version for SNMP devices, RouterOS version for RouterOS devices Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@ import { WinBoxButton } from '@/components/fleet/WinBoxButton'
|
|||||||
import { RemoteWinBoxButton } from '@/components/fleet/RemoteWinBoxButton'
|
import { RemoteWinBoxButton } from '@/components/fleet/RemoteWinBoxButton'
|
||||||
import { SSHTerminal } from '@/components/fleet/SSHTerminal'
|
import { SSHTerminal } from '@/components/fleet/SSHTerminal'
|
||||||
import { RollbackAlert } from '@/components/config/RollbackAlert'
|
import { RollbackAlert } from '@/components/config/RollbackAlert'
|
||||||
|
import { SNMPMetricsSection } from '@/components/fleet/SNMPMetricsSection'
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/_authenticated/tenants/$tenantId/devices/$deviceId',
|
'/_authenticated/tenants/$tenantId/devices/$deviceId',
|
||||||
@@ -308,27 +309,31 @@ function DeviceDetailPage() {
|
|||||||
document.getElementById('main-content')?.scrollTo(0, 0)
|
document.getElementById('main-content')?.scrollTo(0, 0)
|
||||||
}
|
}
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const { mode, toggleMode } = useSimpleConfigMode(deviceId)
|
|
||||||
|
|
||||||
const { data: device, isLoading } = useQuery({
|
const { data: device, isLoading } = useQuery({
|
||||||
queryKey: ['device', tenantId, deviceId],
|
queryKey: ['device', tenantId, deviceId],
|
||||||
queryFn: () => devicesApi.get(tenantId, deviceId),
|
queryFn: () => devicesApi.get(tenantId, deviceId),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isRouterOS = (device?.device_type ?? 'routeros') === 'routeros'
|
||||||
|
const isSNMP = device?.device_type === 'snmp'
|
||||||
|
|
||||||
|
const { mode, toggleMode } = useSimpleConfigMode(isRouterOS ? deviceId : '__snmp__')
|
||||||
|
|
||||||
const { data: backups } = useQuery({
|
const { data: backups } = useQuery({
|
||||||
queryKey: ['config-backups', tenantId, deviceId],
|
queryKey: ['config-backups', tenantId, deviceId],
|
||||||
queryFn: () => configApi.listBackups(tenantId, deviceId),
|
queryFn: () => configApi.listBackups(tenantId, deviceId),
|
||||||
|
enabled: isRouterOS,
|
||||||
})
|
})
|
||||||
|
|
||||||
// True if a pre-restore backup was created within the last 30 minutes,
|
// True if a pre-restore backup was created within the last 30 minutes,
|
||||||
// indicating a config push just happened before the device went offline.
|
// indicating a config push just happened before the device went offline.
|
||||||
const hasRecentPushAlert = backups?.some((b) => {
|
const hasRecentPushAlert = isRouterOS && (backups?.some((b) => {
|
||||||
if (b.trigger_type !== 'pre-restore') return false
|
if (b.trigger_type !== 'pre-restore') return false
|
||||||
// created_at within last 30 minutes — compare timestamps without Date.now()
|
// created_at within last 30 minutes — compare timestamps without Date.now()
|
||||||
const thirtyMinAgo = new Date()
|
const thirtyMinAgo = new Date()
|
||||||
thirtyMinAgo.setMinutes(thirtyMinAgo.getMinutes() - 30)
|
thirtyMinAgo.setMinutes(thirtyMinAgo.getMinutes() - 30)
|
||||||
return new Date(b.created_at) > thirtyMinAgo
|
return new Date(b.created_at) > thirtyMinAgo
|
||||||
}) ?? false
|
}) ?? false)
|
||||||
|
|
||||||
const { data: groups } = useQuery({
|
const { data: groups } = useQuery({
|
||||||
queryKey: ['device-groups', tenantId],
|
queryKey: ['device-groups', tenantId],
|
||||||
@@ -457,7 +462,7 @@ function DeviceDetailPage() {
|
|||||||
)}>
|
)}>
|
||||||
{device.status}
|
{device.status}
|
||||||
</span>
|
</span>
|
||||||
<TlsSecurityBadge tlsMode={device.tls_mode} />
|
{isRouterOS && <TlsSecurityBadge tlsMode={device.tls_mode} />}
|
||||||
</div>
|
</div>
|
||||||
{/* Metadata + actions row */}
|
{/* Metadata + actions row */}
|
||||||
<div className="flex items-center justify-between mt-0.5 gap-2">
|
<div className="flex items-center justify-between mt-0.5 gap-2">
|
||||||
@@ -465,22 +470,28 @@ function DeviceDetailPage() {
|
|||||||
{device.model ?? device.board_name ?? '\u2014'}
|
{device.model ?? device.board_name ?? '\u2014'}
|
||||||
{' \u00b7 '}
|
{' \u00b7 '}
|
||||||
<span className="font-mono text-[8px]">{device.ip_address}</span>
|
<span className="font-mono text-[8px]">{device.ip_address}</span>
|
||||||
{device.routeros_version && (
|
{isRouterOS && device.routeros_version && (
|
||||||
<>
|
<>
|
||||||
{' \u00b7 '}
|
{' \u00b7 '}
|
||||||
<span className="font-mono text-[8px]">v{device.routeros_version}</span>
|
<span className="font-mono text-[8px]">v{device.routeros_version}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isSNMP && device.snmp_version && (
|
||||||
|
<>
|
||||||
|
{' \u00b7 '}
|
||||||
|
<span className="text-[8px]">SNMP {device.snmp_version.toUpperCase()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<SimpleModeToggle mode={mode} onModeChange={toggleMode} />
|
{isRouterOS && <SimpleModeToggle mode={mode} onModeChange={toggleMode} />}
|
||||||
{user?.role !== 'viewer' && device.routeros_version !== null && (
|
{user?.role !== 'viewer' && device.routeros_version !== null && (
|
||||||
<>
|
<>
|
||||||
<WinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
<WinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
||||||
<RemoteWinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
<RemoteWinBoxButton tenantId={tenantId} deviceId={deviceId} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{user?.role !== 'viewer' && (
|
{isRouterOS && user?.role !== 'viewer' && (
|
||||||
<SSHTerminal tenantId={tenantId} deviceId={deviceId} deviceName={device.hostname} />
|
<SSHTerminal tenantId={tenantId} deviceId={deviceId} deviceName={device.hostname} />
|
||||||
)}
|
)}
|
||||||
{canWrite(user) && (
|
{canWrite(user) && (
|
||||||
@@ -497,218 +508,364 @@ function DeviceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Emergency rollback banner */}
|
{/* Emergency rollback banner (RouterOS only) */}
|
||||||
<RollbackAlert
|
{isRouterOS && (
|
||||||
tenantId={tenantId}
|
<RollbackAlert
|
||||||
deviceId={deviceId}
|
tenantId={tenantId}
|
||||||
deviceStatus={device.status}
|
deviceId={deviceId}
|
||||||
hasRecentPushAlert={hasRecentPushAlert}
|
deviceStatus={device.status}
|
||||||
/>
|
hasRecentPushAlert={hasRecentPushAlert}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Config View (Simple or Standard) */}
|
{/* Main content: RouterOS gets SimpleConfigView, SNMP gets dedicated layout */}
|
||||||
<SimpleConfigView
|
{isRouterOS ? (
|
||||||
tenantId={tenantId}
|
<SimpleConfigView
|
||||||
deviceId={deviceId}
|
tenantId={tenantId}
|
||||||
device={device}
|
deviceId={deviceId}
|
||||||
mode={mode}
|
device={device}
|
||||||
activeTab={activeTab}
|
mode={mode}
|
||||||
onTabChange={setActiveTab}
|
activeTab={activeTab}
|
||||||
onModeChange={toggleMode}
|
onTabChange={setActiveTab}
|
||||||
overviewContent={
|
onModeChange={toggleMode}
|
||||||
<>
|
overviewContent={
|
||||||
{/* Device info */}
|
<>
|
||||||
<div className="rounded-sm border border-border-default bg-panel px-3 py-1.5">
|
{/* Device info */}
|
||||||
<InfoRow label="Model" value={device.model} />
|
<div className="rounded-sm border border-border-default bg-panel px-3 py-1.5">
|
||||||
<InfoRow label="RouterOS" value={device.routeros_version} />
|
<InfoRow label="Model" value={device.model} />
|
||||||
<InfoRow label="Firmware" value={device.firmware_version || 'N/A'} />
|
<InfoRow label="RouterOS" value={device.routeros_version} />
|
||||||
<InfoRow label="Uptime" value={formatUptime(device.uptime_seconds)} />
|
<InfoRow label="Firmware" value={device.firmware_version || 'N/A'} />
|
||||||
<InfoRow label="Last Seen" value={formatDateTime(device.last_seen)} />
|
<InfoRow label="Uptime" value={formatUptime(device.uptime_seconds)} />
|
||||||
<InfoRow label="Serial" value={device.serial_number || 'N/A'} />
|
<InfoRow label="Last Seen" value={formatDateTime(device.last_seen)} />
|
||||||
<InfoRow label="API Port" value={`${device.api_port} (plain) / ${device.api_ssl_port} (TLS)`} />
|
<InfoRow label="Serial" value={device.serial_number || 'N/A'} />
|
||||||
<InfoRow
|
<InfoRow label="API Port" value={`${device.api_port} (plain) / ${device.api_ssl_port} (TLS)`} />
|
||||||
label="TLS Mode"
|
<InfoRow
|
||||||
value={
|
label="TLS Mode"
|
||||||
<div className="flex items-center gap-2">
|
value={
|
||||||
<TlsSecurityBadge tlsMode={device.tls_mode} />
|
<div className="flex items-center gap-2">
|
||||||
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
<TlsSecurityBadge tlsMode={device.tls_mode} />
|
||||||
<TlsModeSelector
|
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||||
tenantId={tenantId}
|
<TlsModeSelector
|
||||||
deviceId={device.id}
|
tenantId={tenantId}
|
||||||
currentMode={device.tls_mode}
|
deviceId={device.id}
|
||||||
/>
|
currentMode={device.tls_mode}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
}
|
</div>
|
||||||
/>
|
}
|
||||||
<InfoRow label="Added" value={formatDate(device.created_at)} />
|
/>
|
||||||
<InfoRow
|
<InfoRow label="Added" value={formatDate(device.created_at)} />
|
||||||
label="Site"
|
<InfoRow
|
||||||
value={
|
label="Site"
|
||||||
<div className="flex items-center gap-2">
|
value={
|
||||||
<MapPin className="h-3.5 w-3.5 text-text-muted" />
|
<div className="flex items-center gap-2">
|
||||||
{canWrite(user) ? (
|
<MapPin className="h-3.5 w-3.5 text-text-muted" />
|
||||||
<Select
|
{canWrite(user) ? (
|
||||||
value={device.site_id ?? 'unassigned'}
|
<Select
|
||||||
onValueChange={(value) => siteAssignMutation.mutate(value)}
|
value={device.site_id ?? 'unassigned'}
|
||||||
>
|
onValueChange={(value) => siteAssignMutation.mutate(value)}
|
||||||
<SelectTrigger className="h-7 w-[160px] text-xs">
|
>
|
||||||
<SelectValue placeholder="Unassigned" />
|
<SelectTrigger className="h-7 w-[160px] text-xs">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Unassigned" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
<SelectContent>
|
||||||
{sitesData?.sites.map((s) => (
|
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
{sitesData?.sites.map((s) => (
|
||||||
))}
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
) : (
|
</Select>
|
||||||
<span className="text-sm">{device.site_name ?? 'Unassigned'}</span>
|
) : (
|
||||||
)}
|
<span className="text-sm">{device.site_name ?? 'Unassigned'}</span>
|
||||||
</div>
|
)}
|
||||||
}
|
</div>
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Credentials (masked) */}
|
{/* Credentials (masked) */}
|
||||||
<div className="rounded-sm border border-border-default bg-panel px-3 py-2">
|
<div className="rounded-sm border border-border-default bg-panel px-3 py-2">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-medium text-text-secondary">Credentials</h3>
|
<h3 className="text-sm font-medium text-text-secondary">Credentials</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreds((v) => !v)}
|
onClick={() => setShowCreds((v) => !v)}
|
||||||
className="h-6 px-2 text-xs"
|
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-sm border border-border-default bg-panel px-3 py-2 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-default rounded px-2 py-1"
|
|
||||||
>
|
>
|
||||||
{group.name}
|
{showCreds ? (
|
||||||
{canWrite(user) && (
|
<>
|
||||||
<button
|
<EyeOff className="h-3 w-3" /> Hide
|
||||||
onClick={() => removeFromGroupMutation.mutate(group.id)}
|
</>
|
||||||
className="text-text-muted hover:text-text-secondary ml-1"
|
) : (
|
||||||
title="Remove from group"
|
<>
|
||||||
>
|
<Eye className="h-3 w-3" /> Reveal
|
||||||
×
|
</>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Button>
|
||||||
))}
|
</div>
|
||||||
{device.groups.length === 0 && (
|
<div className="space-y-2 text-sm">
|
||||||
<span className="text-xs text-text-muted">No groups assigned</span>
|
<div className="flex gap-4">
|
||||||
)}
|
<span className="text-xs text-text-muted w-20">Username</span>
|
||||||
</div>
|
<span className="font-mono">
|
||||||
{canWrite(user) && availableGroups.length > 0 && (
|
{showCreds ? '(stored \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
|
||||||
<div className="flex items-center gap-2">
|
</span>
|
||||||
<Select onValueChange={(id) => addToGroupMutation.mutate(id)}>
|
</div>
|
||||||
<SelectTrigger className="h-7 text-xs w-48">
|
<div className="flex gap-4">
|
||||||
<SelectValue placeholder="Add to group..." />
|
<span className="text-xs text-text-muted w-20">Password</span>
|
||||||
</SelectTrigger>
|
<span className="font-mono">
|
||||||
<SelectContent>
|
{showCreds ? '(encrypted at rest \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
|
||||||
{availableGroups.map((g) => (
|
</span>
|
||||||
<SelectItem key={g.id} value={g.id}>
|
</div>
|
||||||
{g.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="rounded-sm border border-border-default bg-panel px-3 py-2 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>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{device.tags.map((tag) => (
|
{/* Groups */}
|
||||||
<div key={tag.id} className="flex items-center gap-1">
|
<div className="rounded-sm border border-border-default bg-panel px-3 py-2 space-y-3">
|
||||||
<Badge color={tag.color}>
|
<div className="flex items-center gap-2">
|
||||||
{tag.name}
|
<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-default rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
{canWrite(user) && (
|
{canWrite(user) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeTagMutation.mutate(tag.id)}
|
onClick={() => removeFromGroupMutation.mutate(group.id)}
|
||||||
className="ml-1 opacity-60 hover:opacity-100"
|
className="text-text-muted hover:text-text-secondary ml-1"
|
||||||
title="Remove tag"
|
title="Remove from group"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</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>
|
||||||
))}
|
|
||||||
{device.tags.length === 0 && (
|
|
||||||
<span className="text-xs text-text-muted">No tags assigned</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canWrite(user) && availableTags.length > 0 && (
|
|
||||||
<Select onValueChange={(id) => addTagMutation.mutate(id)}>
|
{/* Tags */}
|
||||||
|
<div className="rounded-sm border border-border-default bg-panel px-3 py-2 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-panel p-4">
|
||||||
|
<h3 className="text-sm font-medium text-text-muted 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'} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* SNMP device system info */}
|
||||||
|
<div className="rounded-sm border border-border-default bg-panel px-3 py-1.5">
|
||||||
|
<InfoRow label="Hostname" value={device.hostname} />
|
||||||
|
<InfoRow label="IP Address" value={device.ip_address} />
|
||||||
|
<InfoRow label="Model" value={device.model} />
|
||||||
|
<InfoRow label="Uptime" value={formatUptime(device.uptime_seconds)} />
|
||||||
|
<InfoRow label="Last Seen" value={formatDateTime(device.last_seen)} />
|
||||||
|
<InfoRow label="SNMP Version" value={device.snmp_version?.toUpperCase() ?? '\u2014'} />
|
||||||
|
<InfoRow label="SNMP Port" value={String(device.snmp_port ?? 161)} />
|
||||||
|
<InfoRow label="Firmware" value={device.firmware_version ?? '\u2014'} />
|
||||||
|
<InfoRow label="Added" value={formatDate(device.created_at)} />
|
||||||
|
<InfoRow
|
||||||
|
label="Site"
|
||||||
|
value={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-text-muted" />
|
||||||
|
{canWrite(user) ? (
|
||||||
|
<Select
|
||||||
|
value={device.site_id ?? 'unassigned'}
|
||||||
|
onValueChange={(value) => siteAssignMutation.mutate(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[160px] text-xs">
|
||||||
|
<SelectValue placeholder="Unassigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||||
|
{sitesData?.sites.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">{device.site_name ?? 'Unassigned'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SNMP Profile info */}
|
||||||
|
<SNMPMetricsSection tenantId={tenantId} deviceId={deviceId} snmpProfileId={device.snmp_profile_id} />
|
||||||
|
|
||||||
|
{/* Interface Utilization (works for SNMP via standard MIB mapping) */}
|
||||||
|
<div className="rounded-lg border border-border bg-panel p-4">
|
||||||
|
<h3 className="text-sm font-medium text-text-muted mb-3">Interface Utilization</h3>
|
||||||
|
<InterfaceGauges tenantId={tenantId} deviceId={deviceId} active={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Groups */}
|
||||||
|
<div className="rounded-sm border border-border-default bg-panel px-3 py-2 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-default 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">
|
<SelectTrigger className="h-7 text-xs w-48">
|
||||||
<SelectValue placeholder="Add tag..." />
|
<SelectValue placeholder="Add to group..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableTags.map((t) => (
|
{availableGroups.map((g) => (
|
||||||
<SelectItem key={t.id} value={t.id}>
|
<SelectItem key={g.id} value={g.id}>
|
||||||
{t.name}
|
{g.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="rounded-sm border border-border-default bg-panel px-3 py-2 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>
|
</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 */}
|
{/* Alerts */}
|
||||||
<div className="rounded-lg border border-border bg-panel p-4">
|
<DeviceAlertsSection tenantId={tenantId} deviceId={deviceId} active={true} />
|
||||||
<h3 className="text-sm font-medium text-text-muted mb-3">Interface Utilization</h3>
|
</div>
|
||||||
<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) && (
|
{canWrite(user) && (
|
||||||
<EditDeviceDialog
|
<EditDeviceDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user