- Hostname is now a Link to the device detail page - MapPin icon shown for devices with coordinates, links to /map - Hover accent color on both links - Also fixes tenant-switch query bug and VPN tab colors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
import { useState, useMemo } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Link } from '@tanstack/react-router'
|
|
import { useAuth } from '@/lib/auth'
|
|
import { metricsApi, tenantsApi, type FleetDevice } from '@/lib/api'
|
|
import { useUIStore } from '@/lib/store'
|
|
import { alertsApi } from '@/lib/alertsApi'
|
|
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
|
import { LayoutDashboard, MapPin } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { LoadingText } from '@/components/ui/skeleton'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
|
|
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
|
|
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'
|
|
import { WirelessIssues } from '@/components/dashboard/WirelessIssues'
|
|
|
|
// ─── 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 Loading ───────────────────────────────────────────────────────
|
|
|
|
function DashboardLoading() {
|
|
return (
|
|
<div className="py-8 text-center">
|
|
<LoadingText />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Needs Attention (inline component) ─────────────────────────────────────
|
|
|
|
interface AttentionItem {
|
|
id: string
|
|
deviceId: string
|
|
tenantId: string
|
|
hostname: string
|
|
model: string | null
|
|
severity: 'error' | 'warning'
|
|
reason: string
|
|
hasCoords: boolean
|
|
}
|
|
|
|
function NeedsAttention({ devices }: { devices: FleetDevice[] }) {
|
|
const items = useMemo<AttentionItem[]>(() => {
|
|
const result: AttentionItem[] = []
|
|
|
|
for (const d of devices) {
|
|
const base = {
|
|
deviceId: d.id,
|
|
tenantId: d.tenant_id,
|
|
hostname: d.hostname,
|
|
model: d.model,
|
|
hasCoords: d.latitude != null && d.longitude != null,
|
|
}
|
|
|
|
if (d.status === 'offline') {
|
|
result.push({ ...base, id: `${d.id}-offline`, severity: 'error', reason: 'Offline' })
|
|
} else if (d.status === 'degraded') {
|
|
result.push({ ...base, id: `${d.id}-degraded`, severity: 'warning', reason: 'Degraded' })
|
|
}
|
|
|
|
if (d.last_cpu_load != null && d.last_cpu_load > 80) {
|
|
result.push({ ...base, id: `${d.id}-cpu`, severity: 'warning', reason: `CPU ${d.last_cpu_load}%` })
|
|
}
|
|
}
|
|
|
|
result.sort((a, b) => {
|
|
if (a.severity === b.severity) return 0
|
|
return a.severity === 'error' ? -1 : 1
|
|
})
|
|
|
|
return result.slice(0, 10)
|
|
}, [devices])
|
|
|
|
const count = items.length
|
|
|
|
return (
|
|
<div className="bg-panel border border-border-default rounded-sm mb-3.5">
|
|
<div className="px-3 py-2 border-b border-border-default bg-elevated">
|
|
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
|
|
Needs Attention
|
|
</span>
|
|
<span className="text-[7px] text-[hsl(var(--text-label))]"> · </span>
|
|
<span className="text-[7px] text-text-secondary font-mono">{count}</span>
|
|
</div>
|
|
{count > 0 ? (
|
|
<div className="divide-y divide-border-subtle">
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-center justify-between px-3 py-1.5 border-l-2"
|
|
style={{
|
|
borderLeftColor:
|
|
item.severity === 'error'
|
|
? 'hsl(var(--error))'
|
|
: 'hsl(var(--warning))',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Link
|
|
to="/tenants/$tenantId/devices/$deviceId"
|
|
params={{ tenantId: item.tenantId, deviceId: item.deviceId }}
|
|
className="text-xs text-text-primary font-medium truncate hover:text-accent transition-[color] duration-[50ms]"
|
|
>
|
|
{item.hostname}
|
|
</Link>
|
|
<span className="text-[10px] text-text-secondary flex-shrink-0">
|
|
{item.model}
|
|
</span>
|
|
{item.hasCoords && (
|
|
<Link
|
|
to="/map"
|
|
className="text-text-muted hover:text-accent transition-[color] duration-[50ms] flex-shrink-0"
|
|
title="View on map"
|
|
>
|
|
<MapPin className="h-3 w-3" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'text-[10px] font-mono font-medium flex-shrink-0 ml-2',
|
|
item.severity === 'error' ? 'text-error' : 'text-warning',
|
|
)}
|
|
>
|
|
{item.reason}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="py-5 text-center">
|
|
<span className="text-[9px] text-text-muted">No issues detected</span>
|
|
</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 && !selectedTenantId ? 'all' : tenantId],
|
|
queryFn: () =>
|
|
isSuperAdmin && !selectedTenantId
|
|
? 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
|
|
|
|
const degradedCount = useMemo(
|
|
() => fleetDevices?.filter((d) => d.status === 'degraded').length ?? 0,
|
|
[fleetDevices],
|
|
)
|
|
const offlineCount = useMemo(
|
|
() => fleetDevices?.filter((d) => d.status === 'offline').length ?? 0,
|
|
[fleetDevices],
|
|
)
|
|
|
|
// 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 pb-2.5 mb-3.5 border-b border-border-default">
|
|
<div>
|
|
<h1 className="text-sm font-semibold text-text-primary">Overview</h1>
|
|
<p className="text-[9px] 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-panel">
|
|
{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-soft text-accent'
|
|
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Dashboard Content ───────────────────────────────────────────── */}
|
|
{fleetLoading ? (
|
|
<DashboardLoading />
|
|
) : totalDevices === 0 ? (
|
|
<EmptyState
|
|
icon={LayoutDashboard}
|
|
title="No fleet data"
|
|
description="Add devices to see your fleet dashboard."
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* Metrics Strip — joined 4-column bar */}
|
|
<div className="flex gap-px mb-3.5 bg-border-default rounded-sm overflow-hidden">
|
|
<div className="flex-1 bg-panel px-3 py-2">
|
|
<div className="text-lg font-medium font-mono text-text-primary">
|
|
{totalDevices}
|
|
</div>
|
|
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
|
Devices
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 bg-panel px-3 py-2">
|
|
<div className="text-lg font-medium font-mono text-success">
|
|
{onlineDevices.length}
|
|
</div>
|
|
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
|
Online
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 bg-panel px-3 py-2">
|
|
<div
|
|
className={cn(
|
|
'text-lg font-medium font-mono',
|
|
degradedCount > 0 ? 'text-warning' : 'text-text-primary',
|
|
)}
|
|
>
|
|
{degradedCount}
|
|
</div>
|
|
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
|
Degraded
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 bg-panel px-3 py-2">
|
|
<div
|
|
className={cn(
|
|
'text-lg font-medium font-mono',
|
|
offlineCount > 0 ? 'text-error' : 'text-text-primary',
|
|
)}
|
|
>
|
|
{offlineCount}
|
|
</div>
|
|
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
|
Offline
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Needs Attention — full width */}
|
|
<NeedsAttention devices={fleetDevices ?? []} />
|
|
|
|
{/* 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>
|
|
|
|
{/* Wireless Issues */}
|
|
<div>
|
|
<WirelessIssues tenantId={tenantId || null} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|