diff --git a/src/design-system/composites/EventFeed/EventFeed.module.css b/src/design-system/composites/EventFeed/EventFeed.module.css index a7a5e86..772c1d1 100644 --- a/src/design-system/composites/EventFeed/EventFeed.module.css +++ b/src/design-system/composites/EventFeed/EventFeed.module.css @@ -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; diff --git a/src/design-system/composites/EventFeed/EventFeed.tsx b/src/design-system/composites/EventFeed/EventFeed.tsx index 0d19821..1050be0 100644 --- a/src/design-system/composites/EventFeed/EventFeed.tsx +++ b/src/design-system/composites/EventFeed/EventFeed.tsx @@ -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 = { + error: '\u2715', // ✕ + warning: '\u26A0', // ⚠ + success: '\u25B6', // ▶ + running: '\u2699', // ⚙ } const SEVERITY_LABELS: Record = { @@ -39,10 +64,14 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps) const scrollRef = useRef(null) const [isPaused, setIsPaused] = useState(false) const [activeFilters, setActiveFilters] = useState>(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 (
- {/* Filter pills */} -
- {allSeverities.map((sev) => { - const count = events.filter((e) => e.severity === sev).length - return ( - toggleFilter(sev)} - /> - ) - })} - {activeFilters.size > 0 && ( - - )} + {/* Search + filter bar */} +
+
+ setSearch(e.target.value)} + aria-label="Search events" + /> + {search && ( + + )} +
+
+ {allSeverities.map((sev) => { + const count = events.filter((e) => e.severity === sev).length + return ( + toggleFilter(sev)} + /> + ) + })} + {activeFilters.size > 0 && ( + + )} +
{/* Event list */} @@ -114,13 +165,21 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps) aria-label="Event feed" > {displayed.length === 0 ? ( -
No events
+
+ {search ? 'No matching events' : 'No events'} +
) : ( displayed.map((event) => ( -
- - {event.message} - {formatRelativeTime(event.timestamp)} +
+
+ {event.icon ?? DEFAULT_ICONS[event.severity]} +
+
+
{event.message}
+
+ {formatRelativeTime(event.timestamp)} · {formatAbsoluteTime(event.timestamp)} +
+
)) )} diff --git a/src/design-system/composites/FilterBar/FilterBar.tsx b/src/design-system/composites/FilterBar/FilterBar.tsx index f67c3c2..6baeb65 100644 --- a/src/design-system/composites/FilterBar/FilterBar.tsx +++ b/src/design-system/composites/FilterBar/FilterBar.tsx @@ -73,6 +73,10 @@ export function FilterBar({ placeholder={searchPlaceholder} value={search} onChange={handleSearchChange} + onClear={search ? () => { + if (onSearchChange) onSearchChange('') + else setInternalSearch('') + } : undefined} icon={ diff --git a/src/design-system/layout/Sidebar/Sidebar.module.css b/src/design-system/layout/Sidebar/Sidebar.module.css index 6993b37..b930d0c 100644 --- a/src/design-system/layout/Sidebar/Sidebar.module.css +++ b/src/design-system/layout/Sidebar/Sidebar.module.css @@ -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; diff --git a/src/design-system/layout/Sidebar/Sidebar.tsx b/src/design-system/layout/Sidebar/Sidebar.tsx index a319a9c..35b90c2 100644 --- a/src/design-system/layout/Sidebar/Sidebar.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.tsx @@ -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 && ( + + )}
@@ -271,19 +297,16 @@ export function Sidebar({ apps, className }: SidebarProps) { )}
- {/* Agents tree (collapsible + navigable) */} + {/* Agents tree (collapsible) */}
-
- Agents - -
+ {!agentsCollapsed && ( { icon?: ReactNode + onClear?: () => void } export const Input = forwardRef( - ({ icon, className, ...rest }, ref) => { + ({ icon, onClear, className, value, ...rest }, ref) => { + const showClear = onClear && value !== undefined && value !== '' return (
{icon && {icon}} + {showClear && ( + + )}
) }, diff --git a/src/mocks/agentEvents.ts b/src/mocks/agentEvents.ts deleted file mode 100644 index 3477d95..0000000 --- a/src/mocks/agentEvents.ts +++ /dev/null @@ -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), - }, -] diff --git a/src/mocks/agentEvents.tsx b/src/mocks/agentEvents.tsx new file mode 100644 index 0000000..406dd07 --- /dev/null +++ b/src/mocks/agentEvents.tsx @@ -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: <>notif-1 status changed to DEAD — 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: <>pay-2 status changed to STALE — 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: <>ord-3 started — 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: <>pay-2 error rate elevated: 12 err/h (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 order-validation added to ord-1, ord-2, ord-3, + 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 ship-1, ship-2 — 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: <>ship-1 and ship-2 upgraded 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: <>notif-1 failed health check — memory allocation error, + searchText: 'notif-1 failed health check — memory allocation error', + timestamp: new Date(Date.now() - 48 * MINUTE), + }, +] diff --git a/src/pages/AgentHealth/AgentHealth.module.css b/src/pages/AgentHealth/AgentHealth.module.css index 611bef8..bb6bc84 100644 --- a/src/pages/AgentHealth/AgentHealth.module.css +++ b/src/pages/AgentHealth/AgentHealth.module.css @@ -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); } diff --git a/src/pages/AgentHealth/AgentHealth.tsx b/src/pages/AgentHealth/AgentHealth.tsx index 6da6334..8489f97 100644 --- a/src/pages/AgentHealth/AgentHealth.tsx +++ b/src/pages/AgentHealth/AgentHealth.tsx @@ -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() { @@ -191,11 +183,13 @@ export function AgentHealth() { {/* Section header */}
- {scope.level === 'all' ? 'Agent Groups' : scope.level === 'app' ? scope.appId : scope.instanceId} - - - {liveCount}/{totalInstances} live + {scope.level === 'all' ? 'Agents' : scope.level === 'app' ? scope.appId : scope.instanceId} + 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'} + variant="filled" + />
{/* Group cards grid */} @@ -206,9 +200,11 @@ export function AgentHealth() { title={group.appId} accent={appHealth(group)} headerRight={ - - {group.instances.length} instance{group.instances.length !== 1 ? 's' : ''} - + } meta={
@@ -226,82 +222,105 @@ export function AgentHealth() {
) : undefined} > - {/* Instance header row */} -
- - Instance - State - Uptime - TPS - Errors - Heartbeat -
+ + + + + + + + + + + + + {group.instances.map((inst) => ( + <> + navigate(`/agents/${inst.appId}/${inst.id}`)} + > + + + + + + + + - {/* Instance rows */} - {group.instances.map((inst) => ( -
- - - {inst.name} - - {inst.uptime} - {inst.tps.toFixed(1)}/s - - {inst.errorRate ?? '0 err/h'} - - - {inst.lastSeen} - - - - {/* Expanded charts for single instance */} - {singleInstance?.id === inst.id && trendData && ( -
-
-
Throughput (msg/s)
- -
-
-
Error Rate (err/h)
- -
-
- )} -
- ))} + {/* Expanded charts for single instance */} + {singleInstance?.id === inst.id && trendData && ( + + + + )} + + ))} + +
+ InstanceStateUptimeTPSErrorsHeartbeat
+ + + {inst.name} + + + + {inst.uptime} + + {inst.tps.toFixed(1)}/s + + + {inst.errorRate ?? '0 err/h'} + + + + {inst.lastSeen} + +
+
+
+
Throughput (msg/s)
+ +
+
+
Error Rate (err/h)
+ +
+
+
))}
{/* EventFeed */} {filteredEvents.length > 0 && ( -
-
+
+
Timeline + {filteredEvents.length} events