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;
|
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 {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 12px;
|
flex: 1;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clearBtn {
|
.clearBtn {
|
||||||
@@ -29,59 +92,115 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Event list ──────────────────────────────────────── */
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
padding: 6px 12px;
|
padding: 8px 16px;
|
||||||
transition: background 0.08s;
|
transition: background 0.1s;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover {
|
.item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
/* ── Icon circle ─────────────────────────────────────── */
|
||||||
margin-top: 3px;
|
|
||||||
|
.icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
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 {
|
.message {
|
||||||
flex: 1;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Severity tint */
|
|
||||||
.error .message {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning .message {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-faint);
|
font-family: var(--font-mono);
|
||||||
flex-shrink: 0;
|
color: var(--text-muted);
|
||||||
margin-top: 2px;
|
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 {
|
.empty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
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 styles from './EventFeed.module.css'
|
||||||
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
|
|
||||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||||
|
|
||||||
export interface FeedEvent {
|
export interface FeedEvent {
|
||||||
id: string
|
id: string
|
||||||
severity: 'error' | 'warning' | 'success' | 'running'
|
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
|
timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +27,30 @@ function formatRelativeTime(date: Date): string {
|
|||||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
|
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 < 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`
|
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> = {
|
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
|
||||||
@@ -39,10 +64,14 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [isPaused, setIsPaused] = useState(false)
|
const [isPaused, setIsPaused] = useState(false)
|
||||||
const [activeFilters, setActiveFilters] = useState<Set<SeverityFilter>>(new Set())
|
const [activeFilters, setActiveFilters] = useState<Set<SeverityFilter>>(new Set())
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const searchLower = search.toLowerCase()
|
||||||
|
|
||||||
const displayed = events
|
const displayed = events
|
||||||
.slice(-maxItems)
|
.slice(-maxItems)
|
||||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||||
|
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to bottom
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
@@ -81,7 +110,28 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.feed} ${className ?? ''}`}>
|
<div className={`${styles.feed} ${className ?? ''}`}>
|
||||||
{/* Filter pills */}
|
{/* 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}>
|
<div className={styles.filters}>
|
||||||
{allSeverities.map((sev) => {
|
{allSeverities.map((sev) => {
|
||||||
const count = events.filter((e) => e.severity === sev).length
|
const count = events.filter((e) => e.severity === sev).length
|
||||||
@@ -104,6 +154,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Event list */}
|
{/* Event list */}
|
||||||
<div
|
<div
|
||||||
@@ -114,13 +165,21 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
aria-label="Event feed"
|
aria-label="Event feed"
|
||||||
>
|
>
|
||||||
{displayed.length === 0 ? (
|
{displayed.length === 0 ? (
|
||||||
<div className={styles.empty}>No events</div>
|
<div className={styles.empty}>
|
||||||
|
{search ? 'No matching events' : 'No events'}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displayed.map((event) => (
|
displayed.map((event) => (
|
||||||
<div key={event.id} className={`${styles.item} ${styles[event.severity]}`}>
|
<div key={event.id} className={styles.item}>
|
||||||
<StatusDot variant={event.severity} className={styles.dot} />
|
<div className={`${styles.icon} ${styles[`icon_${event.severity}`]}`}>
|
||||||
<span className={styles.message}>{event.message}</span>
|
{event.icon ?? DEFAULT_ICONS[event.severity]}
|
||||||
<span className={styles.time}>{formatRelativeTime(event.timestamp)}</span>
|
</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>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export function FilterBar({
|
|||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
|
onClear={search ? () => {
|
||||||
|
if (onSearchChange) onSearchChange('')
|
||||||
|
else setInternalSearch('')
|
||||||
|
} : undefined}
|
||||||
icon={
|
icon={
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 6px 10px 6px 28px;
|
padding: 6px 26px 6px 28px;
|
||||||
color: var(--sidebar-text);
|
color: var(--sidebar-text);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -80,6 +80,32 @@
|
|||||||
align-items: center;
|
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 */
|
/* Scrollable nav area */
|
||||||
.navArea {
|
.navArea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
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 styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
||||||
@@ -194,8 +194,24 @@ function StarredGroup({
|
|||||||
|
|
||||||
export function Sidebar({ apps, className }: SidebarProps) {
|
export function Sidebar({ apps, className }: SidebarProps) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [appsCollapsed, setAppsCollapsed] = useState(false)
|
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||||
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
|
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 navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
@@ -242,6 +258,16 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -271,19 +297,16 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agents tree (collapsible + navigable) */}
|
{/* Agents tree (collapsible) */}
|
||||||
<div className={styles.treeSection}>
|
<div className={styles.treeSection}>
|
||||||
<div className={styles.treeSectionToggle}>
|
|
||||||
<Link to="/agents" className={styles.treeSectionLink}>Agents</Link>
|
|
||||||
<button
|
<button
|
||||||
className={styles.treeSectionChevronBtn}
|
className={styles.treeSectionToggle}
|
||||||
onClick={() => setAgentsCollapsed((v) => !v)}
|
onClick={() => setAgentsCollapsed((v) => !v)}
|
||||||
aria-expanded={!agentsCollapsed}
|
aria-expanded={!agentsCollapsed}
|
||||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
|
||||||
>
|
>
|
||||||
{agentsCollapsed ? '▸' : '▾'}
|
<span className={styles.treeSectionChevron}>{agentsCollapsed ? '▸' : '▾'}</span>
|
||||||
|
<span>Agents</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
{!agentsCollapsed && (
|
{!agentsCollapsed && (
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={agentNodes}
|
nodes={agentNodes}
|
||||||
|
|||||||
@@ -27,3 +27,30 @@
|
|||||||
.input:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
|
.input:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
|
||||||
|
|
||||||
.hasIcon { padding-left: 30px; }
|
.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> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
|
onClear?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ icon, className, ...rest }, ref) => {
|
({ icon, onClear, className, value, ...rest }, ref) => {
|
||||||
|
const showClear = onClear && value !== undefined && value !== ''
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrap} ${className ?? ''}`}>
|
<div className={`${styles.wrap} ${className ?? ''}`}>
|
||||||
{icon && <span className={styles.icon}>{icon}</span>}
|
{icon && <span className={styles.icon}>{icon}</span>}
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`${styles.input} ${icon ? styles.hasIcon : ''}`}
|
className={`${styles.input} ${icon ? styles.hasIcon : ''} ${showClear ? styles.hasClear : ''}`}
|
||||||
|
value={value}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
{showClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clearBtn}
|
||||||
|
onClick={onClear}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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 {
|
.sectionHeaderRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,46 +122,65 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance header row */
|
/* Instance table */
|
||||||
.instanceHeader {
|
.instanceTable {
|
||||||
display: grid;
|
width: 100%;
|
||||||
grid-template-columns: 8px minmax(80px, 1.2fr) auto auto auto auto auto;
|
border-collapse: collapse;
|
||||||
gap: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 16px;
|
}
|
||||||
|
|
||||||
|
.instanceTable thead th {
|
||||||
|
padding: 4px 12px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thStatus {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdStatus {
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance row */
|
/* Instance row */
|
||||||
.instanceRow {
|
.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;
|
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;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceRow:hover {
|
.instanceRow:hover td {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceRowActive {
|
.instanceRowActive td {
|
||||||
background: var(--amber-bg);
|
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 */
|
/* Instance fields */
|
||||||
@@ -217,7 +236,23 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Event section */
|
/* Event card (timeline panel) */
|
||||||
.eventSection {
|
.eventCard {
|
||||||
margin-top: 20px;
|
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 { useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import styles from './AgentHealth.module.css'
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -116,6 +116,7 @@ function buildBreadcrumb(scope: Scope) {
|
|||||||
|
|
||||||
export function AgentHealth() {
|
export function AgentHealth() {
|
||||||
const scope = useScope()
|
const scope = useScope()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// Filter agents by scope
|
// Filter agents by scope
|
||||||
const filteredAgents = useMemo(() => {
|
const filteredAgents = useMemo(() => {
|
||||||
@@ -134,16 +135,8 @@ export function AgentHealth() {
|
|||||||
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
||||||
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
||||||
|
|
||||||
// Filter events by scope
|
// Events are a global timeline feed — show all regardless of scope
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = agentEvents
|
||||||
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])
|
|
||||||
|
|
||||||
// Single instance for expanded charts
|
// Single instance for expanded charts
|
||||||
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
||||||
@@ -156,7 +149,6 @@ export function AgentHealth() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={buildBreadcrumb(scope)}
|
breadcrumb={buildBreadcrumb(scope)}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -191,11 +183,13 @@ export function AgentHealth() {
|
|||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
<div className={styles.sectionHeaderRow}>
|
<div className={styles.sectionHeaderRow}>
|
||||||
<span className={styles.sectionTitle}>
|
<span className={styles.sectionTitle}>
|
||||||
{scope.level === 'all' ? 'Agent Groups' : scope.level === 'app' ? scope.appId : scope.instanceId}
|
{scope.level === 'all' ? 'Agents' : scope.level === 'app' ? scope.appId : scope.instanceId}
|
||||||
</span>
|
|
||||||
<span className={styles.sectionMeta}>
|
|
||||||
{liveCount}/{totalInstances} live
|
|
||||||
</span>
|
</span>
|
||||||
|
<Badge
|
||||||
|
label={`${liveCount}/${totalInstances} live`}
|
||||||
|
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group cards grid */}
|
{/* Group cards grid */}
|
||||||
@@ -206,9 +200,11 @@ export function AgentHealth() {
|
|||||||
title={group.appId}
|
title={group.appId}
|
||||||
accent={appHealth(group)}
|
accent={appHealth(group)}
|
||||||
headerRight={
|
headerRight={
|
||||||
<span className={styles.instanceCountBadge}>
|
<Badge
|
||||||
{group.instances.length} instance{group.instances.length !== 1 ? 's' : ''}
|
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||||
</span>
|
color={appHealth(group)}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
meta={
|
meta={
|
||||||
<div className={styles.groupMeta}>
|
<div className={styles.groupMeta}>
|
||||||
@@ -226,39 +222,54 @@ export function AgentHealth() {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
>
|
>
|
||||||
{/* Instance header row */}
|
<table className={styles.instanceTable}>
|
||||||
<div className={styles.instanceHeader}>
|
<thead>
|
||||||
<span />
|
<tr>
|
||||||
<span>Instance</span>
|
<th className={styles.thStatus} />
|
||||||
<span>State</span>
|
<th>Instance</th>
|
||||||
<span>Uptime</span>
|
<th>State</th>
|
||||||
<span>TPS</span>
|
<th>Uptime</th>
|
||||||
<span>Errors</span>
|
<th>TPS</th>
|
||||||
<span>Heartbeat</span>
|
<th>Errors</th>
|
||||||
</div>
|
<th>Heartbeat</th>
|
||||||
|
</tr>
|
||||||
{/* Instance rows */}
|
</thead>
|
||||||
|
<tbody>
|
||||||
{group.instances.map((inst) => (
|
{group.instances.map((inst) => (
|
||||||
<div key={inst.id}>
|
<>
|
||||||
<Link
|
<tr
|
||||||
to={`/agents/${inst.appId}/${inst.id}`}
|
key={inst.id}
|
||||||
className={[
|
className={[
|
||||||
styles.instanceRow,
|
styles.instanceRow,
|
||||||
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
|
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].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'} />
|
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
|
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<Badge
|
<Badge
|
||||||
label={inst.status.toUpperCase()}
|
label={inst.status.toUpperCase()}
|
||||||
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
|
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
/>
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
|
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
|
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
|
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||||
{inst.errorRate ?? '0 err/h'}
|
{inst.errorRate ?? '0 err/h'}
|
||||||
</MonoText>
|
</MonoText>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<MonoText size="xs" className={
|
<MonoText size="xs" className={
|
||||||
inst.status === 'dead' ? styles.instanceHeartbeatDead :
|
inst.status === 'dead' ? styles.instanceHeartbeatDead :
|
||||||
inst.status === 'stale' ? styles.instanceHeartbeatStale :
|
inst.status === 'stale' ? styles.instanceHeartbeatStale :
|
||||||
@@ -266,10 +277,13 @@ export function AgentHealth() {
|
|||||||
}>
|
}>
|
||||||
{inst.lastSeen}
|
{inst.lastSeen}
|
||||||
</MonoText>
|
</MonoText>
|
||||||
</Link>
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
{/* Expanded charts for single instance */}
|
{/* Expanded charts for single instance */}
|
||||||
{singleInstance?.id === inst.id && trendData && (
|
{singleInstance?.id === inst.id && trendData && (
|
||||||
|
<tr key={`${inst.id}-charts`} className={styles.chartRow}>
|
||||||
|
<td colSpan={7}>
|
||||||
<div className={styles.instanceCharts}>
|
<div className={styles.instanceCharts}>
|
||||||
<div className={styles.chartPanel}>
|
<div className={styles.chartPanel}>
|
||||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||||
@@ -290,18 +304,23 @@ export function AgentHealth() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
))}
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</GroupCard>
|
</GroupCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* EventFeed */}
|
{/* EventFeed */}
|
||||||
{filteredEvents.length > 0 && (
|
{filteredEvents.length > 0 && (
|
||||||
<div className={styles.eventSection}>
|
<div className={styles.eventCard}>
|
||||||
<div className={styles.sectionHeaderRow}>
|
<div className={styles.eventCardHeader}>
|
||||||
<span className={styles.sectionTitle}>Timeline</span>
|
<span className={styles.sectionTitle}>Timeline</span>
|
||||||
|
<span className={styles.sectionMeta}>{filteredEvents.length} events</span>
|
||||||
</div>
|
</div>
|
||||||
<EventFeed events={filteredEvents} />
|
<EventFeed events={filteredEvents} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user