From 4572230c9cbdb06d319b7345086be1d5187d00b7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:28:56 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20align=20all=20pages=20with=20design=20sy?= =?UTF-8?q?stem=20mocks=20=E2=80=94=20stat=20cards,=20tables,=20detail=20p?= =?UTF-8?q?anels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: correct stat card labels (Exchanges/Success Rate/Errors/Throughput/Latency p99), add detail text, trends, sparklines on all cards, Agent column, LIVE badge, expanded detail panel with Agent/Correlation/Timestamp, "Open full details" link. Agent Health: per-group meta (TPS/routes) in GroupCard header, proper HTML table with column headers for instance list. Agent Instance: stat card detail props (heap info, start date), scope trail with inline status/version/routes badges. Routes: 5th In-Flight stat card, enriched stat card props (detail/trend/sparkline), SLA threshold line on latency chart. Exchange Detail: Agent stat box in header. Also: vite proxy CORS fix, cross-env dev scripts. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/package-lock.json | 26 +++ ui/package.json | 3 + .../pages/AgentHealth/AgentHealth.module.css | 74 +++++++- ui/src/pages/AgentHealth/AgentHealth.tsx | 107 +++++++++--- .../AgentInstance/AgentInstance.module.css | 29 ++++ ui/src/pages/AgentInstance/AgentInstance.tsx | 29 +++- ui/src/pages/Dashboard/Dashboard.module.css | 16 ++ ui/src/pages/Dashboard/Dashboard.tsx | 159 ++++++++++++++---- .../pages/ExchangeDetail/ExchangeDetail.tsx | 4 + ui/src/pages/Routes/RoutesMetrics.module.css | 2 +- ui/src/pages/Routes/RoutesMetrics.tsx | 85 +++++++++- ui/vite.config.ts | 5 + 12 files changed, 466 insertions(+), 73 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 3e242ee0..1d28bae0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,6 +24,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "cross-env": "^10.1.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -323,6 +324,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1629,6 +1637,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/ui/package.json b/ui/package.json index c453df76..8efbfd69 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,6 +5,8 @@ "type": "module", "scripts": { "dev": "vite", + "dev:local": "cross-env VITE_API_TARGET=http://localhost:8081 vite", + "dev:remote": "cross-env VITE_API_TARGET=http://192.168.50.86:30090 vite", "build": "tsc -p tsconfig.app.json --noEmit && vite build", "lint": "eslint .", "preview": "vite preview", @@ -28,6 +30,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "cross-env": "^10.1.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index d151e599..f5e220a9 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -19,15 +19,54 @@ margin-bottom: 20px; } -.instanceRow { +/* GroupCard meta strip */ +.groupMeta { display: flex; + gap: 16px; align-items: center; - gap: 8px; - padding: 8px 12px; + font-size: 12px; + color: var(--text-muted); +} + +.groupMeta strong { + color: var(--text-primary); +} + +/* Instance table */ +.instanceTable { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.instanceTable thead tr { + border-bottom: 1px solid var(--border-subtle); +} + +.instanceTable thead th { + padding: 6px 8px; + text-align: left; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.thStatus { + width: 24px; +} + +.tdStatus { + width: 24px; + padding: 0 4px 0 8px; +} + +.instanceRow { cursor: pointer; transition: background 0.1s; border-bottom: 1px solid var(--border-subtle); - font-size: 12px; } .instanceRow:last-child { @@ -38,6 +77,15 @@ background: var(--bg-hover); } +.instanceRow td { + padding: 7px 8px; + vertical-align: middle; +} + +.instanceRowActive { + background: var(--bg-selected, var(--bg-hover)); +} + .instanceName { font-weight: 600; color: var(--text-primary); @@ -49,6 +97,24 @@ font-family: var(--font-mono); } +.instanceError { + font-size: 11px; + color: var(--error); + font-family: var(--font-mono); +} + +.instanceHeartbeatDead { + font-size: 11px; + color: var(--error); + font-family: var(--font-mono); +} + +.instanceHeartbeatStale { + font-size: 11px; + color: var(--warning); + font-family: var(--font-mono); +} + .instanceLink { color: var(--text-muted); text-decoration: none; diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 806bdc70..19d69ad1 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -246,43 +246,102 @@ export default function AgentHealth() {
{Object.entries(apps).map(([group, groupAgents]) => { const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD'); + const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0); + const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0); + const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0); + const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length; return ( } + headerRight={ + a.status === 'DEAD') ? 'error' + : groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' + : 'success' + } + variant="filled" + /> + } + meta={ +
+ {groupTps.toFixed(1)} msg/s + {groupActiveRoutes}/{groupTotalRoutes} routes +
+ } accent={ groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error' : groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' : 'success' } - onClick={() => navigate(`/agents/${group}`)} > {deadInGroup.length > 0 && ( {deadInGroup.length} instance(s) unreachable )} - {(groupAgents || []).map((agent: any) => ( -
{ - e.stopPropagation(); - setSelectedAgent(agent); - navigate(`/agents/${group}/${agent.id}`); - }} - > - - {agent.name} - - {formatUptime(agent.uptimeSeconds)} - {agent.tps != null && {(agent.tps || 0).toFixed(1)} tps} - {agent.errorRate != null && ( - {(agent.errorRate * 100).toFixed(1)}% err - )} - {formatRelativeTime(agent.lastHeartbeat)} - -
- ))} + + + + + + + + + + + + + {(groupAgents || []).map((agent: any) => ( + { + setSelectedAgent(agent); + navigate(`/agents/${group}/${agent.id}`); + }} + > + + + + + + + + + ))} + +
+ InstanceStateUptimeTPSErrorsHeartbeat
+ + + {agent.name ?? agent.id} + + + + {formatUptime(agent.uptimeSeconds)} + + {agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'} + + + {agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'} + + + + {formatRelativeTime(agent.lastHeartbeat)} + +
); })} diff --git a/ui/src/pages/AgentInstance/AgentInstance.module.css b/ui/src/pages/AgentInstance/AgentInstance.module.css index bcf68065..92016bc8 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.module.css +++ b/ui/src/pages/AgentInstance/AgentInstance.module.css @@ -110,6 +110,35 @@ flex-wrap: wrap; } +.scopeTrail { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 16px; + font-size: 13px; + flex-wrap: wrap; +} + +.scopeLink { + color: var(--text-accent, var(--text-primary)); + text-decoration: none; + font-weight: 500; +} + +.scopeLink:hover { + text-decoration: underline; +} + +.scopeSep { + color: var(--text-muted); + font-size: 10px; +} + +.scopeCurrent { + color: var(--text-primary); + font-weight: 600; +} + .paneTitle { font-size: 13px; font-weight: 700; diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index d20ea927..83df9f58 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -122,10 +122,35 @@ export default function AgentInstance() {
- + 0 ? 'error' : undefined} /> - + +
+ +
+ All Agents + + {appId} + + {agent.name} + + {agent.version && } +
diff --git a/ui/src/pages/Dashboard/Dashboard.module.css b/ui/src/pages/Dashboard/Dashboard.module.css index a0867aa2..2d4ad884 100644 --- a/ui/src/pages/Dashboard/Dashboard.module.css +++ b/ui/src/pages/Dashboard/Dashboard.module.css @@ -82,3 +82,19 @@ flex-shrink: 0; padding-top: 2px; } + +.openDetailLink { + display: inline-block; + font-size: 13px; + font-weight: 600; + color: var(--accent, #c6820e); + cursor: pointer; + background: none; + border: none; + padding: 0; + text-decoration: none; +} + +.openDetailLink:hover { + text-decoration: underline; +} diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 03a3365f..d371a30f 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -1,20 +1,28 @@ import { useState, useMemo } from 'react'; -import { useParams } from 'react-router'; +import { useParams, useNavigate } from 'react-router'; import { - StatCard, StatusDot, Badge, MonoText, Sparkline, + StatCard, StatusDot, Badge, MonoText, DataTable, DetailPanel, ProcessorTimeline, RouteFlow, - Alert, Collapsible, CodeBlock, + Alert, Collapsible, CodeBlock, ShortcutsBar, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'; +import { useDiagramByRoute } from '../../api/queries/diagrams'; import { useGlobalFilters } from '@cameleer/design-system'; import type { ExecutionSummary } from '../../api/types'; +import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'; import styles from './Dashboard.module.css'; interface Row extends ExecutionSummary { id: string } +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + export default function Dashboard() { const { appId, routeId } = useParams(); + const navigate = useNavigate(); const { timeRange } = useGlobalFilters(); const timeFrom = timeRange.start.toISOString(); const timeTo = timeRange.end.toISOString(); @@ -35,29 +43,58 @@ export default function Dashboard() { }, true); const { data: detail } = useExecutionDetail(selectedId); const { data: snapshot } = useProcessorSnapshot(selectedId, processorIdx); + const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId); const rows: Row[] = useMemo(() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })), [searchResult], ); - const sparklineData = useMemo(() => - (timeseries?.buckets || []).map((b: any) => b.totalCount as number), - [timeseries], - ); + const totalCount = stats?.totalCount ?? 0; + const failedCount = stats?.failedCount ?? 0; + const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100; + const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0; + + const sparkExchanges = useMemo(() => + (timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries]); + const sparkErrors = useMemo(() => + (timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries]); + const sparkLatency = useMemo(() => + (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries]); + const sparkThroughput = useMemo(() => + (timeseries?.buckets || []).map((b: any) => { + const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1); + return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0; + }), [timeseries, timeWindowSeconds]); + + const prevTotal = stats?.prevTotalCount ?? 0; + const prevFailed = stats?.prevFailedCount ?? 0; + const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal * 100) : 0; + const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal * 100) : 100; + const successRateDelta = successRate - prevSuccessRate; + const errorDelta = failedCount - prevFailed; const columns: Column[] = [ { key: 'status', header: 'Status', width: '80px', - render: (v) => , + render: (v, row) => ( + + + {v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'} + + ), }, - { key: 'routeId', header: 'Route', render: (v) => {String(v)} }, - { key: 'groupName', header: 'App', render: (v) => }, - { key: 'executionId', header: 'Exchange ID', render: (v) => {String(v).slice(0, 12)} }, - { key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() }, + { key: 'routeId', header: 'Route', sortable: true, render: (v) => {String(v)} }, + { key: 'groupName', header: 'Application', sortable: true, render: (v) => {String(v ?? '')} }, + { key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => {String(v)} }, + { key: 'startTime', header: 'Started', sortable: true, render: (v) => {new Date(v as string).toISOString().replace('T', ' ').slice(0, 19)} }, { key: 'durationMs', header: 'Duration', sortable: true, - render: (v) => `${v}ms`, + render: (v) => {formatDuration(v as number)}, + }, + { + key: 'agentId', header: 'Agent', + render: (v) => v ? : null, }, ]; @@ -67,23 +104,42 @@ export default function Dashboard() { content: ( <>
-
Details
+ +
+
+
Overview
- Exchange ID - {detail.executionId} + Status + + + {detail.status} +
- Status - + Duration + {formatDuration(detail.durationMs)}
Route {detail.routeId}
- Duration - {detail.durationMs}ms + Agent + {detail.agentId ?? '—'} +
+
+ Correlation + {detail.correlationId ?? '—'} +
+
+ Timestamp + {detail.startTime ? new Date(detail.startTime).toISOString().replace('T', ' ').slice(0, 19) : '—'}
@@ -118,42 +174,71 @@ export default function Dashboard() { ) :
No processor data
; })(), }, + { + label: 'Route Flow', value: 'flow', + content: diagram ? ( + { /* optionally select processor */ }} + /> + ) :
No diagram available
, + }, ] : []; return (
0 ? `${((stats?.totalCount ?? 0) / timeWindowSeconds).toFixed(2)} ex/s` : '0.00 ex/s'} - sparkline={sparklineData} + label="Exchanges" + value={totalCount.toLocaleString()} + detail={`${successRate.toFixed(1)}% success rate`} + trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'} + trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`} + sparkline={sparkExchanges} + accent="amber" /> 0 ? `${((stats?.failedCount ?? 0) / stats!.totalCount * 100).toFixed(1)}%` : '0.0%'} + label="Success Rate" + value={`${successRate.toFixed(1)}%`} + detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`} + trend={successRateDelta >= 0 ? 'up' : 'down'} + trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`} + accent="success" + /> + 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'} + trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`} + sparkline={sparkErrors} accent="error" /> - - +
Recent Exchanges
- {rows.length} results + {rows.length} of {searchResult?.total ?? 0} exchanges +
Duration
{detail.durationMs}ms
+
+
Agent
+
{detail.agentId}
+
Processors
{countProcessors(detail.processors || detail.children || [])}
diff --git a/ui/src/pages/Routes/RoutesMetrics.module.css b/ui/src/pages/Routes/RoutesMetrics.module.css index ee56946f..27298703 100644 --- a/ui/src/pages/Routes/RoutesMetrics.module.css +++ b/ui/src/pages/Routes/RoutesMetrics.module.css @@ -1,6 +1,6 @@ .statStrip { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 16px; } diff --git a/ui/src/pages/Routes/RoutesMetrics.tsx b/ui/src/pages/Routes/RoutesMetrics.tsx index e5045d3c..a11794ae 100644 --- a/ui/src/pages/Routes/RoutesMetrics.tsx +++ b/ui/src/pages/Routes/RoutesMetrics.tsx @@ -81,13 +81,84 @@ export default function RoutesMetrics() { }, ]; + const errorRate = stats?.totalCount + ? (((stats.failedCount ?? 0) / stats.totalCount) * 100) + : 0; + const prevErrorRate = stats?.prevTotalCount + ? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100) + : 0; + const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral'; + const errorTrendValue = stats?.prevTotalCount + ? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%` + : undefined; + + const p99Ms = stats?.p99LatencyMs ?? 0; + const prevP99Ms = stats?.prevP99LatencyMs ?? 0; + const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral'; + const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined; + + const totalCount = stats?.totalCount ?? 0; + const prevTotalCount = stats?.prevTotalCount ?? 0; + const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral'; + const throughputTrendValue = prevTotalCount + ? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%` + : undefined; + + const successRate = stats?.totalCount + ? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100) + : 100; + + const activeCount = stats?.activeCount ?? 0; + + const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number); + const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number); + return (
- - - - + + + 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'} + sparkline={latencySparkline} + /> + { + const failed = errorSparkline[i] ?? 0; + return v > 0 ? ((v - failed) / v) * 100 : 100; + })} + /> +
@@ -111,7 +182,11 @@ export default function RoutesMetrics() {
Latency
- ({ x: i, y: d.latency })) }]} height={200} /> + ({ x: i, y: d.latency })) }]} + height={200} + threshold={{ value: 300, label: 'SLA 300ms' }} + />
Errors
diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 38e2a590..cff36064 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -13,6 +13,11 @@ export default defineConfig({ target: apiTarget, changeOrigin: true, secure: false, + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + proxyReq.removeHeader('origin'); + }); + }, }, }, },