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:
@@ -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;
|
||||
|
||||
@@ -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)} · {formatAbsoluteTime(event.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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
69
src/mocks/agentEvents.tsx
Normal 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),
|
||||
},
|
||||
]
|
||||
@@ -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