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 { useState, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuth } from '@/lib/auth'
|
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 { useUIStore } from '@/lib/store'
|
||||||
import { alertsApi } from '@/lib/alertsApi'
|
import { alertsApi } from '@/lib/alertsApi'
|
||||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||||
@@ -11,7 +11,6 @@ import { LoadingText } from '@/components/ui/skeleton'
|
|||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
|
|
||||||
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
|
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
|
||||||
import { KpiCards } from '@/components/dashboard/KpiCards'
|
|
||||||
import { HealthScore } from '@/components/dashboard/HealthScore'
|
import { HealthScore } from '@/components/dashboard/HealthScore'
|
||||||
import { EventsTimeline } from '@/components/dashboard/EventsTimeline'
|
import { EventsTimeline } from '@/components/dashboard/EventsTimeline'
|
||||||
import { BandwidthChart, type BandwidthDevice } from '@/components/dashboard/BandwidthChart'
|
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 ─────────────────────────────────────────────────────────
|
// ─── Fleet Dashboard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function FleetDashboard() {
|
export function FleetDashboard() {
|
||||||
@@ -101,6 +207,15 @@ export function FleetDashboard() {
|
|||||||
const onlinePercent =
|
const onlinePercent =
|
||||||
totalDevices > 0 ? (onlineDevices.length / totalDevices) * 100 : 0
|
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
|
// Alert counts
|
||||||
const alerts = alertsData?.items ?? []
|
const alerts = alertsData?.items ?? []
|
||||||
const criticalCount = alerts.filter((a) => a.severity === 'critical').length
|
const criticalCount = alerts.filter((a) => a.severity === 'critical').length
|
||||||
@@ -156,10 +271,10 @@ export function FleetDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6" data-testid="dashboard">
|
<div className="space-y-6" data-testid="dashboard">
|
||||||
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
{/* ── 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>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Dashboard</h1>
|
<h1 className="text-sm font-semibold text-text-primary">Overview</h1>
|
||||||
<p className="text-sm text-text-muted mt-0.5">
|
<p className="text-[9px] text-text-muted mt-0.5">
|
||||||
Fleet overview across{' '}
|
Fleet overview across{' '}
|
||||||
{isSuperAdmin
|
{isSuperAdmin
|
||||||
? selectedTenantId && selectedTenantName
|
? selectedTenantId && selectedTenantName
|
||||||
@@ -196,7 +311,7 @@ export function FleetDashboard() {
|
|||||||
'px-2.5 py-1 text-xs font-medium transition-colors',
|
'px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
'first:rounded-l-md last:rounded-r-md',
|
'first:rounded-l-md last:rounded-r-md',
|
||||||
refreshInterval === opt.value
|
refreshInterval === opt.value
|
||||||
? 'bg-accent/15 text-accent'
|
? 'bg-accent-soft text-accent'
|
||||||
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
|
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -218,13 +333,54 @@ export function FleetDashboard() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* KPI Cards — full width, 4 columns */}
|
{/* Metrics Strip — joined 4-column bar */}
|
||||||
<KpiCards
|
<div className="flex gap-px mb-3.5 bg-border-default rounded-sm overflow-hidden">
|
||||||
totalDevices={totalDevices}
|
<div className="flex-1 bg-panel px-3 py-2">
|
||||||
onlinePercent={onlinePercent}
|
<div className="text-lg font-medium font-mono text-text-primary">
|
||||||
activeAlerts={totalAlerts}
|
{totalDevices}
|
||||||
totalBandwidthBps={totalBandwidthBps}
|
</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 */}
|
{/* Widget Grid — responsive 3 columns */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user