feat: enhance EventFeed timeline and Agent Health page styling
- EventFeed: 28px icon circles with severity colors, stacked message/timestamp, search input with clear button, ReactNode message support with searchText field - Agent Health: timeline wrapped in card panel, instance listing as proper table, colored Badge pills for live counts, removed shift pill - Input primitive: onClear prop with × button for all search fields - Sidebar: Agents section collapsible like Applications, collapse state persisted to localStorage - FilterBar/Sidebar: clear buttons on all search inputs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,7 @@
|
||||
.sectionHeaderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -122,46 +122,65 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Instance header row */
|
||||
.instanceHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(80px, 1.2fr) auto auto auto auto auto;
|
||||
gap: 12px;
|
||||
padding: 4px 16px;
|
||||
/* Instance table */
|
||||
.instanceTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.instanceTable thead th {
|
||||
padding: 4px 12px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-faint);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thStatus {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.tdStatus {
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Instance row */
|
||||
.instanceRow {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(80px, 1.2fr) auto auto auto auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.1s;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.instanceRow:last-child {
|
||||
.instanceRow td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceRow:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.instanceRow:hover {
|
||||
.instanceRow:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.instanceRowActive {
|
||||
.instanceRowActive td {
|
||||
background: var(--amber-bg);
|
||||
border-left: 3px solid var(--amber);
|
||||
}
|
||||
|
||||
.instanceRowActive td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--amber);
|
||||
}
|
||||
|
||||
/* Chart expansion row */
|
||||
.chartRow td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Instance fields */
|
||||
@@ -217,7 +236,23 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Event section */
|
||||
.eventSection {
|
||||
/* Event card (timeline panel) */
|
||||
.eventCard {
|
||||
margin-top: 20px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.eventCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import styles from './AgentHealth.module.css'
|
||||
|
||||
// Layout
|
||||
@@ -116,6 +116,7 @@ function buildBreadcrumb(scope: Scope) {
|
||||
|
||||
export function AgentHealth() {
|
||||
const scope = useScope()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Filter agents by scope
|
||||
const filteredAgents = useMemo(() => {
|
||||
@@ -134,16 +135,8 @@ export function AgentHealth() {
|
||||
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
||||
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
||||
|
||||
// Filter events by scope
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (scope.level === 'all') return agentEvents
|
||||
if (scope.level === 'app') {
|
||||
return agentEvents.filter((e) => e.message.includes(`[${scope.appId}]`))
|
||||
}
|
||||
return agentEvents.filter(
|
||||
(e) => e.message.includes(`[${scope.appId}]`) && e.message.includes(scope.instanceId),
|
||||
)
|
||||
}, [scope])
|
||||
// Events are a global timeline feed — show all regardless of scope
|
||||
const filteredEvents = agentEvents
|
||||
|
||||
// Single instance for expanded charts
|
||||
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
||||
@@ -156,7 +149,6 @@ export function AgentHealth() {
|
||||
<TopBar
|
||||
breadcrumb={buildBreadcrumb(scope)}
|
||||
environment="PRODUCTION"
|
||||
shift="Day (06:00-18:00)"
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
|
||||
@@ -191,11 +183,13 @@ export function AgentHealth() {
|
||||
{/* Section header */}
|
||||
<div className={styles.sectionHeaderRow}>
|
||||
<span className={styles.sectionTitle}>
|
||||
{scope.level === 'all' ? 'Agent Groups' : scope.level === 'app' ? scope.appId : scope.instanceId}
|
||||
</span>
|
||||
<span className={styles.sectionMeta}>
|
||||
{liveCount}/{totalInstances} live
|
||||
{scope.level === 'all' ? 'Agents' : scope.level === 'app' ? scope.appId : scope.instanceId}
|
||||
</span>
|
||||
<Badge
|
||||
label={`${liveCount}/${totalInstances} live`}
|
||||
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group cards grid */}
|
||||
@@ -206,9 +200,11 @@ export function AgentHealth() {
|
||||
title={group.appId}
|
||||
accent={appHealth(group)}
|
||||
headerRight={
|
||||
<span className={styles.instanceCountBadge}>
|
||||
{group.instances.length} instance{group.instances.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||
color={appHealth(group)}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
@@ -226,82 +222,105 @@ export function AgentHealth() {
|
||||
</div>
|
||||
) : undefined}
|
||||
>
|
||||
{/* Instance header row */}
|
||||
<div className={styles.instanceHeader}>
|
||||
<span />
|
||||
<span>Instance</span>
|
||||
<span>State</span>
|
||||
<span>Uptime</span>
|
||||
<span>TPS</span>
|
||||
<span>Errors</span>
|
||||
<span>Heartbeat</span>
|
||||
</div>
|
||||
<table className={styles.instanceTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.thStatus} />
|
||||
<th>Instance</th>
|
||||
<th>State</th>
|
||||
<th>Uptime</th>
|
||||
<th>TPS</th>
|
||||
<th>Errors</th>
|
||||
<th>Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.instances.map((inst) => (
|
||||
<>
|
||||
<tr
|
||||
key={inst.id}
|
||||
className={[
|
||||
styles.instanceRow,
|
||||
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => navigate(`/agents/${inst.appId}/${inst.id}`)}
|
||||
>
|
||||
<td className={styles.tdStatus}>
|
||||
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
label={inst.status.toUpperCase()}
|
||||
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||
{inst.errorRate ?? '0 err/h'}
|
||||
</MonoText>
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="xs" className={
|
||||
inst.status === 'dead' ? styles.instanceHeartbeatDead :
|
||||
inst.status === 'stale' ? styles.instanceHeartbeatStale :
|
||||
styles.instanceMeta
|
||||
}>
|
||||
{inst.lastSeen}
|
||||
</MonoText>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Instance rows */}
|
||||
{group.instances.map((inst) => (
|
||||
<div key={inst.id}>
|
||||
<Link
|
||||
to={`/agents/${inst.appId}/${inst.id}`}
|
||||
className={[
|
||||
styles.instanceRow,
|
||||
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
||||
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
|
||||
<Badge
|
||||
label={inst.status.toUpperCase()}
|
||||
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
|
||||
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||
{inst.errorRate ?? '0 err/h'}
|
||||
</MonoText>
|
||||
<MonoText size="xs" className={
|
||||
inst.status === 'dead' ? styles.instanceHeartbeatDead :
|
||||
inst.status === 'stale' ? styles.instanceHeartbeatStale :
|
||||
styles.instanceMeta
|
||||
}>
|
||||
{inst.lastSeen}
|
||||
</MonoText>
|
||||
</Link>
|
||||
|
||||
{/* Expanded charts for single instance */}
|
||||
{singleInstance?.id === inst.id && trendData && (
|
||||
<div className={styles.instanceCharts}>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'tps', data: trendData.throughput }]}
|
||||
height={160}
|
||||
width={480}
|
||||
yLabel="msg/s"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Error Rate (err/h)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
|
||||
height={160}
|
||||
width={480}
|
||||
yLabel="err/h"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Expanded charts for single instance */}
|
||||
{singleInstance?.id === inst.id && trendData && (
|
||||
<tr key={`${inst.id}-charts`} className={styles.chartRow}>
|
||||
<td colSpan={7}>
|
||||
<div className={styles.instanceCharts}>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'tps', data: trendData.throughput }]}
|
||||
height={160}
|
||||
width={480}
|
||||
yLabel="msg/s"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Error Rate (err/h)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
|
||||
height={160}
|
||||
width={480}
|
||||
yLabel="err/h"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</GroupCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* EventFeed */}
|
||||
{filteredEvents.length > 0 && (
|
||||
<div className={styles.eventSection}>
|
||||
<div className={styles.sectionHeaderRow}>
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span className={styles.sectionTitle}>Timeline</span>
|
||||
<span className={styles.sectionMeta}>{filteredEvents.length} events</span>
|
||||
</div>
|
||||
<EventFeed events={filteredEvents} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user