feat(ui): add Needs Attention panel and metrics strip to dashboard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { metricsApi, tenantsApi } from '@/lib/api'
|
||||
import { metricsApi, tenantsApi, type FleetDevice } from '@/lib/api'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { alertsApi } from '@/lib/alertsApi'
|
||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||
@@ -11,7 +11,6 @@ import { LoadingText } 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'
|
||||
@@ -40,6 +39,113 @@ function DashboardLoading() {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Needs Attention (inline component) ─────────────────────────────────────
|
||||
|
||||
interface AttentionItem {
|
||||
id: string
|
||||
hostname: string
|
||||
model: string | null
|
||||
severity: 'error' | 'warning'
|
||||
reason: string
|
||||
}
|
||||
|
||||
function NeedsAttention({ devices }: { devices: FleetDevice[] }) {
|
||||
const items = useMemo<AttentionItem[]>(() => {
|
||||
const result: AttentionItem[] = []
|
||||
|
||||
for (const d of devices) {
|
||||
if (d.status === 'offline') {
|
||||
result.push({
|
||||
id: `${d.id}-offline`,
|
||||
hostname: d.hostname,
|
||||
model: d.model,
|
||||
severity: 'error',
|
||||
reason: 'Offline',
|
||||
})
|
||||
} else if (d.status === 'degraded') {
|
||||
result.push({
|
||||
id: `${d.id}-degraded`,
|
||||
hostname: d.hostname,
|
||||
model: d.model,
|
||||
severity: 'warning',
|
||||
reason: 'Degraded',
|
||||
})
|
||||
}
|
||||
|
||||
if (d.last_cpu_load != null && d.last_cpu_load > 80) {
|
||||
result.push({
|
||||
id: `${d.id}-cpu`,
|
||||
hostname: d.hostname,
|
||||
model: d.model,
|
||||
severity: 'warning',
|
||||
reason: `CPU ${d.last_cpu_load}%`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: errors first, then warnings
|
||||
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">
|
||||
{/* Header */}
|
||||
<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>
|
||||
{/* Rows */}
|
||||
{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">
|
||||
<span className="text-xs text-text-primary font-medium truncate">
|
||||
{item.hostname}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-secondary">
|
||||
{item.model}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-medium flex-shrink-0',
|
||||
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() {
|
||||
@@ -101,6 +207,15 @@ export function FleetDashboard() {
|
||||
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
|
||||
@@ -156,10 +271,10 @@ export function FleetDashboard() {
|
||||
return (
|
||||
<div className="space-y-6" data-testid="dashboard">
|
||||
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-4 pb-2.5 mb-3.5 border-b border-border-default">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Dashboard</h1>
|
||||
<p className="text-sm text-text-muted mt-0.5">
|
||||
<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
|
||||
@@ -196,7 +311,7 @@ export function FleetDashboard() {
|
||||
'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'
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
@@ -218,13 +333,54 @@ export function FleetDashboard() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* KPI Cards — full width, 4 columns */}
|
||||
<KpiCards
|
||||
totalDevices={totalDevices}
|
||||
onlinePercent={onlinePercent}
|
||||
activeAlerts={totalAlerts}
|
||||
totalBandwidthBps={totalBandwidthBps}
|
||||
/>
|
||||
{/* 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">
|
||||
|
||||
Reference in New Issue
Block a user