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

@@ -5,13 +5,76 @@
min-height: 0;
}
/* ── Toolbar: search + filter pills ──────────────────── */
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.searchWrap {
position: relative;
flex: 0 1 200px;
min-width: 120px;
}
.searchInput {
width: 100%;
height: 28px;
padding: 0 26px 0 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-surface);
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-primary);
outline: none;
transition: border-color 0.15s;
}
.searchInput::placeholder {
color: var(--text-faint);
}
.searchInput:focus {
border-color: var(--amber);
}
.searchClear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
line-height: 1;
transition: color 0.1s, background 0.1s;
}
.searchClear:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
flex: 1;
}
.clearBtn {
@@ -29,59 +92,115 @@
color: var(--text-primary);
}
/* ── Event list ──────────────────────────────────────── */
.list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
padding: 8px 0;
}
.item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 12px;
transition: background 0.08s;
border-bottom: 1px solid var(--border-subtle);
}
.item:last-child {
border-bottom: none;
gap: 12px;
padding: 8px 16px;
transition: background 0.1s;
}
.item:hover {
background: var(--bg-hover);
}
.dot {
margin-top: 3px;
/* ── Icon circle ─────────────────────────────────────── */
.icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
margin-top: 1px;
}
.icon_error {
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error-border);
}
.icon_warning {
background: var(--warning-bg);
color: var(--warning);
border: 1px solid var(--warning-border);
}
.icon_success {
background: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
}
.icon_running {
background: var(--running-bg);
color: var(--running);
border: 1px solid var(--running-border);
}
/* ── Body: message + timestamp stacked ───────────────── */
.body {
flex: 1;
min-width: 0;
}
.message {
flex: 1;
font-size: 12px;
color: var(--text-primary);
line-height: 1.5;
line-height: 1.4;
word-break: break-word;
}
/* Severity tint */
.error .message {
color: var(--error);
}
.warning .message {
color: var(--warning);
}
.time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-faint);
flex-shrink: 0;
margin-top: 2px;
font-family: var(--font-mono);
color: var(--text-muted);
margin-top: 1px;
}
/* ── Inline highlight classes for rich messages ──────── */
.agent {
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-primary);
}
.highlightDead {
color: var(--error);
font-weight: 600;
}
.highlightStale {
color: var(--warning);
font-weight: 600;
}
.highlightStart {
color: var(--success);
font-weight: 600;
}
.highlightRunning {
color: var(--running);
font-weight: 600;
}
/* ── Empty + resume ──────────────────────────────────── */
.empty {
padding: 24px;
text-align: center;

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import styles from './EventFeed.module.css'
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
export interface FeedEvent {
id: string
severity: 'error' | 'warning' | 'success' | 'running'
message: string
message: string | ReactNode
/** Plain-text version of message for search filtering (required when message is ReactNode) */
searchText?: string
icon?: ReactNode
timestamp: Date
}
@@ -25,7 +27,30 @@ function formatRelativeTime(date: Date): string {
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return date.toLocaleDateString()
return `${Math.floor(diff / 86_400_000)}d ago`
}
function formatAbsoluteTime(date: Date): string {
const day = date.getDate()
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
const month = months[date.getMonth()]
const year = date.getFullYear()
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
return `${day} ${month} ${year}, ${h}:${m}`
}
function getSearchableText(event: FeedEvent): string {
if (event.searchText) return event.searchText
if (typeof event.message === 'string') return event.message
return ''
}
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
error: '\u2715', // ✕
warning: '\u26A0', // ⚠
success: '\u25B6', // ▶
running: '\u2699', // ⚙
}
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
@@ -39,10 +64,14 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
const scrollRef = useRef<HTMLDivElement>(null)
const [isPaused, setIsPaused] = useState(false)
const [activeFilters, setActiveFilters] = useState<Set<SeverityFilter>>(new Set())
const [search, setSearch] = useState('')
const searchLower = search.toLowerCase()
const displayed = events
.slice(-maxItems)
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
// Auto-scroll to bottom
const scrollToBottom = useCallback(() => {
@@ -81,28 +110,50 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
return (
<div className={`${styles.feed} ${className ?? ''}`}>
{/* Filter pills */}
<div className={styles.filters}>
{allSeverities.map((sev) => {
const count = events.filter((e) => e.severity === sev).length
return (
<FilterPill
key={sev}
label={SEVERITY_LABELS[sev]}
count={count}
active={activeFilters.has(sev)}
onClick={() => toggleFilter(sev)}
/>
)
})}
{activeFilters.size > 0 && (
<button
className={styles.clearBtn}
onClick={() => setActiveFilters(new Set())}
>
Clear
</button>
)}
{/* Search + filter bar */}
<div className={styles.toolbar}>
<div className={styles.searchWrap}>
<input
type="text"
className={styles.searchInput}
placeholder="Search events…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search events"
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
</button>
)}
</div>
<div className={styles.filters}>
{allSeverities.map((sev) => {
const count = events.filter((e) => e.severity === sev).length
return (
<FilterPill
key={sev}
label={SEVERITY_LABELS[sev]}
count={count}
active={activeFilters.has(sev)}
onClick={() => toggleFilter(sev)}
/>
)
})}
{activeFilters.size > 0 && (
<button
className={styles.clearBtn}
onClick={() => setActiveFilters(new Set())}
>
Clear
</button>
)}
</div>
</div>
{/* Event list */}
@@ -114,13 +165,21 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
aria-label="Event feed"
>
{displayed.length === 0 ? (
<div className={styles.empty}>No events</div>
<div className={styles.empty}>
{search ? 'No matching events' : 'No events'}
</div>
) : (
displayed.map((event) => (
<div key={event.id} className={`${styles.item} ${styles[event.severity]}`}>
<StatusDot variant={event.severity} className={styles.dot} />
<span className={styles.message}>{event.message}</span>
<span className={styles.time}>{formatRelativeTime(event.timestamp)}</span>
<div key={event.id} className={styles.item}>
<div className={`${styles.icon} ${styles[`icon_${event.severity}`]}`}>
{event.icon ?? DEFAULT_ICONS[event.severity]}
</div>
<div className={styles.body}>
<div className={styles.message}>{event.message}</div>
<div className={styles.time}>
{formatRelativeTime(event.timestamp)} &middot; {formatAbsoluteTime(event.timestamp)}
</div>
</div>
</div>
))
)}

View File

@@ -73,6 +73,10 @@ export function FilterBar({
placeholder={searchPlaceholder}
value={search}
onChange={handleSearchChange}
onClear={search ? () => {
if (onSearchChange) onSearchChange('')
else setInternalSearch('')
} : undefined}
icon={
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />

View File

@@ -54,7 +54,7 @@
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
padding: 6px 10px 6px 28px;
padding: 6px 26px 6px 28px;
color: var(--sidebar-text);
font-family: var(--font-body);
font-size: 12px;
@@ -80,6 +80,32 @@
align-items: center;
}
.searchClear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--sidebar-muted);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
line-height: 1;
transition: color 0.1s, background 0.1s;
}
.searchClear:hover {
color: var(--sidebar-text);
background: rgba(255, 255, 255, 0.08);
}
/* Scrollable nav area */
.navArea {
flex: 1;

View File

@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'
import { useNavigate, useLocation, Link } from 'react-router-dom'
import { useNavigate, useLocation } from 'react-router-dom'
import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
@@ -194,8 +194,24 @@ function StarredGroup({
export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('')
const [appsCollapsed, setAppsCollapsed] = useState(false)
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
return next
})
}
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
_setAgentsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
return next
})
}
const navigate = useNavigate()
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
@@ -242,6 +258,16 @@ export function Sidebar({ apps, className }: SidebarProps) {
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
@@ -271,19 +297,16 @@ export function Sidebar({ apps, className }: SidebarProps) {
)}
</div>
{/* Agents tree (collapsible + navigable) */}
{/* Agents tree (collapsible) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<Link to="/agents" className={styles.treeSectionLink}>Agents</Link>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
</button>
</div>
<button
className={styles.treeSectionToggle}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
>
<span className={styles.treeSectionChevron}>{agentsCollapsed ? '▸' : '▾'}</span>
<span>Agents</span>
</button>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}

View File

@@ -27,3 +27,30 @@
.input:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
.hasIcon { padding-left: 30px; }
.hasClear { padding-right: 26px; }
.clearBtn {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
line-height: 1;
transition: color 0.1s, background 0.1s;
}
.clearBtn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}

View File

@@ -3,18 +3,31 @@ import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode
onClear?: () => void
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ icon, className, ...rest }, ref) => {
({ icon, onClear, className, value, ...rest }, ref) => {
const showClear = onClear && value !== undefined && value !== ''
return (
<div className={`${styles.wrap} ${className ?? ''}`}>
{icon && <span className={styles.icon}>{icon}</span>}
<input
ref={ref}
className={`${styles.input} ${icon ? styles.hasIcon : ''}`}
className={`${styles.input} ${icon ? styles.hasIcon : ''} ${showClear ? styles.hasClear : ''}`}
value={value}
{...rest}
/>
{showClear && (
<button
type="button"
className={styles.clearBtn}
onClick={onClear}
aria-label="Clear search"
>
×
</button>
)}
</div>
)
},

View File

@@ -1,55 +0,0 @@
import type { FeedEvent } from '../design-system/composites/EventFeed/EventFeed'
const MINUTE = 60_000
const HOUR = 3_600_000
export const agentEvents: FeedEvent[] = [
{
id: 'evt-1',
severity: 'error',
message: '[notification-hub] notif-1 status changed to DEAD — no heartbeat for 47m',
timestamp: new Date(Date.now() - 47 * MINUTE),
},
{
id: 'evt-2',
severity: 'warning',
message: '[payment-svc] pay-2 status changed to STALE — missed 3 consecutive heartbeats',
timestamp: new Date(Date.now() - 3 * MINUTE),
},
{
id: 'evt-3',
severity: 'success',
message: '[order-service] ord-3 started — instance joined cluster (v3.2.1)',
timestamp: new Date(Date.now() - 2 * HOUR - 15 * MINUTE),
},
{
id: 'evt-4',
severity: 'warning',
message: '[payment-svc] pay-2 error rate elevated: 12 err/h (threshold: 10 err/h)',
timestamp: new Date(Date.now() - 5 * MINUTE),
},
{
id: 'evt-5',
severity: 'running',
message: '[order-service] Route "order-validation" added to ord-1, ord-2, ord-3',
timestamp: new Date(Date.now() - 1 * HOUR - 30 * MINUTE),
},
{
id: 'evt-6',
severity: 'running',
message: '[shipment-svc] Configuration updated — retry policy changed to 3 attempts with exponential backoff',
timestamp: new Date(Date.now() - 4 * HOUR),
},
{
id: 'evt-7',
severity: 'success',
message: '[shipment-svc] ship-1 and ship-2 upgraded to v3.2.0 — rolling restart complete',
timestamp: new Date(Date.now() - 7 * 24 * HOUR),
},
{
id: 'evt-8',
severity: 'error',
message: '[notification-hub] notif-1 failed health check — memory allocation error',
timestamp: new Date(Date.now() - 48 * MINUTE),
},
]

69
src/mocks/agentEvents.tsx Normal file
View File

@@ -0,0 +1,69 @@
import type { FeedEvent } from '../design-system/composites/EventFeed/EventFeed'
const MINUTE = 60_000
const HOUR = 3_600_000
const agent = { fontFamily: 'var(--font-mono)', fontWeight: 600 } as const
const dead = { color: 'var(--error)', fontWeight: 600 } as const
const stale = { color: 'var(--warning)', fontWeight: 600 } as const
const started = { color: 'var(--success)', fontWeight: 600 } as const
const info = { color: 'var(--running)', fontWeight: 600 } as const
export const agentEvents: FeedEvent[] = [
{
id: 'evt-1',
severity: 'error',
message: <><span style={agent}>notif-1</span> status changed to <span style={dead}>DEAD</span> no heartbeat for 47m</>,
searchText: 'notif-1 status changed to DEAD — no heartbeat for 47m',
timestamp: new Date(Date.now() - 47 * MINUTE),
},
{
id: 'evt-2',
severity: 'warning',
message: <><span style={agent}>pay-2</span> status changed to <span style={stale}>STALE</span> missed 3 consecutive heartbeats</>,
searchText: 'pay-2 status changed to STALE — missed 3 consecutive heartbeats',
timestamp: new Date(Date.now() - 3 * MINUTE),
},
{
id: 'evt-3',
severity: 'success',
message: <><span style={agent}>ord-3</span> <span style={started}>started</span> instance joined cluster (v3.2.1)</>,
searchText: 'ord-3 started — instance joined cluster (v3.2.1)',
timestamp: new Date(Date.now() - 2 * HOUR - 15 * MINUTE),
},
{
id: 'evt-4',
severity: 'warning',
message: <><span style={agent}>pay-2</span> error rate elevated: <span style={stale}>12 err/h</span> (threshold: 10 err/h)</>,
searchText: 'pay-2 error rate elevated: 12 err/h (threshold: 10 err/h)',
timestamp: new Date(Date.now() - 5 * MINUTE),
},
{
id: 'evt-5',
severity: 'running',
message: <>Route <span style={info}>order-validation</span> added to <span style={agent}>ord-1</span>, <span style={agent}>ord-2</span>, <span style={agent}>ord-3</span></>,
searchText: 'Route order-validation added to ord-1, ord-2, ord-3',
timestamp: new Date(Date.now() - 1 * HOUR - 30 * MINUTE),
},
{
id: 'evt-6',
severity: 'running',
message: <>Config push to <span style={agent}>ship-1</span>, <span style={agent}>ship-2</span> retry policy changed to 3 attempts with exponential backoff</>,
searchText: 'Config push to ship-1, ship-2 — retry policy changed to 3 attempts with exponential backoff',
timestamp: new Date(Date.now() - 4 * HOUR),
},
{
id: 'evt-7',
severity: 'success',
message: <><span style={agent}>ship-1</span> and <span style={agent}>ship-2</span> <span style={started}>upgraded</span> to v3.2.0 rolling restart complete</>,
searchText: 'ship-1 and ship-2 upgraded to v3.2.0 — rolling restart complete',
timestamp: new Date(Date.now() - 7 * 24 * HOUR),
},
{
id: 'evt-8',
severity: 'error',
message: <><span style={agent}>notif-1</span> failed health check <span style={dead}>memory allocation error</span></>,
searchText: 'notif-1 failed health check — memory allocation error',
timestamp: new Date(Date.now() - 48 * MINUTE),
},
]

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>