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:
hsiegeln
2026-03-18 19:11:58 +01:00
parent e7668e8144
commit 674444682e
11 changed files with 582 additions and 243 deletions

View File

@@ -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);
}

View File

@@ -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>