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)}
- ›
-
- ))}
+
+
+
+ |
+ Instance |
+ State |
+ Uptime |
+ TPS |
+ Errors |
+ Heartbeat |
+
+
+
+ {(groupAgents || []).map((agent: any) => (
+ {
+ setSelectedAgent(agent);
+ navigate(`/agents/${group}/${agent.id}`);
+ }}
+ >
+ |
+
+ |
+
+ {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} />
-
+
+
+
+
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');
+ });
+ },
},
},
},