UX overhaul: 1-click row navigation, Exchange tab, Applications page (#69)
Row click in ExecutionExplorer now navigates directly to RoutePage with View Transition instead of expanding an inline panel. Route column is a clickable link for context-free navigation. Search state syncs to URL params for back-nav preservation, and previously-visited rows flash on return. RoutePage gains an Exchange tab showing execution metadata/body/ errors. New /apps page lists application groups with status and route links, accessible from TopNav. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,11 @@ export function TopNav() {
|
||||
Transactions
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink to="/apps" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Applications
|
||||
</NavLink>
|
||||
</li>
|
||||
{roles.includes('ADMIN') && (
|
||||
<li>
|
||||
<NavLink to="/admin/oidc" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
|
||||
132
ui/src/pages/apps/ApplicationsPage.module.css
Normal file
132
ui/src/pages/apps/ApplicationsPage.module.css
Normal file
@@ -0,0 +1,132 @@
|
||||
.pageHeader {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pageHeader h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.live { background: var(--green); }
|
||||
.stale { background: var(--amber); }
|
||||
.dead { background: var(--text-muted); }
|
||||
|
||||
.groupName {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.agentCount {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.statusLive { color: var(--green); }
|
||||
.statusStale { color: var(--amber); }
|
||||
.statusDead { color: var(--text-muted); }
|
||||
|
||||
.routes {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.routesLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.routeList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.routeLink {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.routeLink:hover {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber-dim);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
107
ui/src/pages/apps/ApplicationsPage.tsx
Normal file
107
ui/src/pages/apps/ApplicationsPage.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
import styles from './ApplicationsPage.module.css';
|
||||
|
||||
interface GroupInfo {
|
||||
group: string;
|
||||
agents: AgentInstance[];
|
||||
routeIds: string[];
|
||||
liveCount: number;
|
||||
staleCount: number;
|
||||
deadCount: number;
|
||||
}
|
||||
|
||||
function groupStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
|
||||
if (g.liveCount > 0) return 'live';
|
||||
if (g.staleCount > 0) return 'stale';
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
export function ApplicationsPage() {
|
||||
const { data: agents, isLoading } = useAgents();
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!agents) return [];
|
||||
const map = new Map<string, GroupInfo>();
|
||||
for (const agent of agents) {
|
||||
const key = agent.group ?? 'default';
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { group: key, agents: [], routeIds: [], liveCount: 0, staleCount: 0, deadCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.agents.push(agent);
|
||||
if (agent.status === 'LIVE') entry.liveCount++;
|
||||
else if (agent.status === 'STALE') entry.staleCount++;
|
||||
else entry.deadCount++;
|
||||
|
||||
// Collect unique routeIds
|
||||
if (agent.routeIds) {
|
||||
for (const rid of agent.routeIds) {
|
||||
if (!entry.routeIds.includes(rid)) entry.routeIds.push(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
|
||||
}, [agents]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={styles.loading}>Loading applications...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.pageHeader} animate-in`}>
|
||||
<h1>Applications</h1>
|
||||
<div className={styles.subtitle}>Monitored Camel applications and their routes</div>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className={styles.empty}>No applications found. Agents will appear here once they connect.</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{groups.map((g) => {
|
||||
const status = groupStatus(g);
|
||||
return (
|
||||
<div key={g.group} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={`${styles.statusDot} ${styles[status]}`} />
|
||||
<span className={styles.groupName}>{g.group}</span>
|
||||
<span className={styles.agentCount}>
|
||||
{g.agents.length} instance{g.agents.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.statusBar}>
|
||||
{g.liveCount > 0 && <span className={styles.statusLive}>{g.liveCount} live</span>}
|
||||
{g.staleCount > 0 && <span className={styles.statusStale}>{g.staleCount} stale</span>}
|
||||
{g.deadCount > 0 && <span className={styles.statusDead}>{g.deadCount} dead</span>}
|
||||
</div>
|
||||
|
||||
{g.routeIds.length > 0 && (
|
||||
<div className={styles.routes}>
|
||||
<span className={styles.routesLabel}>Routes</span>
|
||||
<ul className={styles.routeList}>
|
||||
{g.routeIds.map((rid) => (
|
||||
<li key={rid}>
|
||||
<Link
|
||||
to={`/apps/${encodeURIComponent(g.group)}/routes/${encodeURIComponent(rid)}`}
|
||||
className={styles.routeLink}
|
||||
>
|
||||
{rid}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { useExecutionSearch } from './use-execution-search';
|
||||
import { useSearchParamsSync } from './use-search-params-sync';
|
||||
import { StatCard } from '../../components/shared/StatCard';
|
||||
import { Pagination } from '../../components/shared/Pagination';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
@@ -21,6 +22,7 @@ function pctChange(current: number, previous: number): { text: string; direction
|
||||
}
|
||||
|
||||
export function ExecutionExplorer() {
|
||||
useSearchParamsSync();
|
||||
const { toSearchRequest, offset, limit, setOffset, live, toggleLive } = useExecutionSearch();
|
||||
const searchRequest = toSearchRequest();
|
||||
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
|
||||
|
||||
@@ -72,77 +72,32 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdExpand {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.expanded .tdExpand {
|
||||
transform: rotate(90deg);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.correlationId {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ─── Detail Row ─── */
|
||||
.detailRow {
|
||||
display: none;
|
||||
/* ─── Route Link ─── */
|
||||
.routeLink {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.detailRowVisible {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.detailCell {
|
||||
padding: 0 !important;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detailContent {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.detailMain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detailSide {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagramBtn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--amber-dim);
|
||||
background: var(--amber-glow);
|
||||
.routeLink:hover {
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.diagramBtn:hover {
|
||||
background: var(--amber);
|
||||
color: var(--bg-deep);
|
||||
border-color: var(--amber);
|
||||
/* ─── Highlighted Row (back-nav flash) ─── */
|
||||
@keyframes flash {
|
||||
0% { background: var(--amber-glow); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
animation: flash 2s ease-out;
|
||||
}
|
||||
|
||||
/* ─── Loading / Empty ─── */
|
||||
@@ -160,8 +115,3 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.detailContent { flex-direction: column; }
|
||||
.detailSide { width: 100%; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { StatusPill } from '../../components/shared/StatusPill';
|
||||
import { DurationBar } from '../../components/shared/DurationBar';
|
||||
import { AppBadge } from '../../components/shared/AppBadge';
|
||||
import { ProcessorTree } from './ProcessorTree';
|
||||
import { ExchangeDetail } from './ExchangeDetail';
|
||||
import { useExecutionSearch } from './use-execution-search';
|
||||
import styles from './ResultsTable.module.css';
|
||||
|
||||
@@ -53,24 +51,45 @@ function SortableTh({ label, column, activeColumn, direction, onSort, style }: S
|
||||
}
|
||||
|
||||
export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const sortColumn = useExecutionSearch((s) => s.sortField);
|
||||
const sortDir = useExecutionSearch((s) => s.sortDir);
|
||||
const setSort = useExecutionSearch((s) => s.setSort);
|
||||
const navigate = useNavigate();
|
||||
const { data: agents } = useAgents();
|
||||
|
||||
const groupByAgent = useMemo(
|
||||
() => new Map(agents?.map((a) => [a.id, a.group]) ?? []),
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Highlight previously-visited row on back-nav
|
||||
const highlightRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const lastId = sessionStorage.getItem('lastExecId');
|
||||
if (lastId) {
|
||||
highlightRef.current = lastId;
|
||||
sessionStorage.removeItem('lastExecId');
|
||||
const timer = setTimeout(() => { highlightRef.current = null; }, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleSort(col: SortColumn) {
|
||||
setSort(col);
|
||||
}
|
||||
|
||||
/** Navigate to route diagram page with execution overlay */
|
||||
function handleDiagramNav(exec: ExecutionSummary) {
|
||||
// Resolve agentId → group from agent registry
|
||||
const agent = agents?.find((a) => a.id === exec.agentId);
|
||||
const group = agent?.group ?? 'default';
|
||||
const group = groupByAgent.get(exec.agentId) ?? 'default';
|
||||
sessionStorage.setItem('lastExecId', exec.executionId);
|
||||
|
||||
navigate(`/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}?exec=${encodeURIComponent(exec.executionId)}`);
|
||||
const url = `/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}?exec=${encodeURIComponent(exec.executionId)}`;
|
||||
const doc = document as Document & { startViewTransition?: (cb: () => void) => void };
|
||||
if (doc.startViewTransition) {
|
||||
doc.startViewTransition(() => navigate(url));
|
||||
} else {
|
||||
navigate(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && results.length === 0) {
|
||||
@@ -94,7 +113,6 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.thead}>
|
||||
<tr>
|
||||
<th className={styles.th} style={{ width: 32 }} />
|
||||
<SortableTh label="Timestamp" column="startTime" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
|
||||
<SortableTh label="Status" column="status" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
|
||||
<SortableTh label="Application" column="agentId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
|
||||
@@ -104,18 +122,15 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((exec) => {
|
||||
const isExpanded = expandedId === exec.executionId;
|
||||
return (
|
||||
<ResultRow
|
||||
key={exec.executionId}
|
||||
exec={exec}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)}
|
||||
onDiagramNav={() => handleDiagramNav(exec)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{results.map((exec) => (
|
||||
<ResultRow
|
||||
key={exec.executionId}
|
||||
exec={exec}
|
||||
groupByAgent={groupByAgent}
|
||||
highlighted={highlightRef.current === exec.executionId}
|
||||
onClick={() => handleDiagramNav(exec)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -124,54 +139,43 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
|
||||
function ResultRow({
|
||||
exec,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDiagramNav,
|
||||
groupByAgent,
|
||||
highlighted,
|
||||
onClick,
|
||||
}: {
|
||||
exec: ExecutionSummary;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDiagramNav: () => void;
|
||||
groupByAgent: Map<string, string>;
|
||||
highlighted: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const group = groupByAgent.get(exec.agentId) ?? 'default';
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`${styles.row} ${isExpanded ? styles.expanded : ''}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className={`${styles.td} ${styles.tdExpand}`}>›</td>
|
||||
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
|
||||
<td className={styles.td}>
|
||||
<StatusPill status={exec.status} />
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<AppBadge name={exec.agentId} />
|
||||
</td>
|
||||
<td className={`${styles.td} mono text-secondary`}>{exec.routeId}</td>
|
||||
<td className={`${styles.td} mono text-muted ${styles.correlationId}`} title={exec.correlationId ?? ''}>
|
||||
{exec.correlationId ?? '-'}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<DurationBar duration={exec.durationMs} />
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className={styles.detailRowVisible}>
|
||||
<td className={styles.detailCell} colSpan={7}>
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.detailMain}>
|
||||
<ProcessorTree executionId={exec.executionId} />
|
||||
</div>
|
||||
<div className={styles.detailSide}>
|
||||
<ExchangeDetail execution={exec} />
|
||||
<button className={styles.diagramBtn} onClick={(e) => { e.stopPropagation(); onDiagramNav(); }}>
|
||||
View Route Diagram
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
<tr
|
||||
className={`${styles.row} ${highlighted ? styles.highlighted : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
|
||||
<td className={styles.td}>
|
||||
<StatusPill status={exec.status} />
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<AppBadge name={exec.agentId} />
|
||||
</td>
|
||||
<td className={`${styles.td} mono text-secondary`}>
|
||||
<Link
|
||||
to={`/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}`}
|
||||
className={styles.routeLink}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{exec.routeId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`${styles.td} mono text-muted ${styles.correlationId}`} title={exec.correlationId ?? ''}>
|
||||
{exec.correlationId ?? '-'}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<DurationBar duration={exec.durationMs} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
80
ui/src/pages/executions/use-search-params-sync.ts
Normal file
80
ui/src/pages/executions/use-search-params-sync.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useExecutionSearch } from './use-execution-search';
|
||||
|
||||
const DEFAULTS = {
|
||||
status: 'COMPLETED,FAILED',
|
||||
sortField: 'startTime',
|
||||
sortDir: 'desc',
|
||||
offset: '0',
|
||||
};
|
||||
|
||||
/**
|
||||
* Two-way sync between Zustand execution-search store and URL search params.
|
||||
* - On mount: hydrates store from URL (if non-default values present).
|
||||
* - On store change: serializes non-default state to URL via replaceState (no history pollution).
|
||||
*/
|
||||
export function useSearchParamsSync() {
|
||||
const hydrated = useRef(false);
|
||||
|
||||
// Hydrate store from URL on mount
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const store = useExecutionSearch.getState();
|
||||
|
||||
const status = params.get('status');
|
||||
if (status) store.setStatus(status.split(','));
|
||||
|
||||
const text = params.get('text');
|
||||
if (text) store.setText(text);
|
||||
|
||||
const routeId = params.get('routeId');
|
||||
if (routeId) store.setRouteId(routeId);
|
||||
|
||||
const agentId = params.get('agentId');
|
||||
if (agentId) store.setAgentId(agentId);
|
||||
|
||||
const sort = params.get('sort');
|
||||
if (sort) {
|
||||
const [field, dir] = sort.split(':');
|
||||
if (field && dir) {
|
||||
// Set sortField and sortDir directly via the store
|
||||
useExecutionSearch.setState({
|
||||
sortField: field as 'startTime' | 'status' | 'agentId' | 'routeId' | 'correlationId' | 'durationMs',
|
||||
sortDir: dir as 'asc' | 'desc',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const offset = params.get('offset');
|
||||
if (offset) store.setOffset(Number(offset));
|
||||
|
||||
hydrated.current = true;
|
||||
}, []);
|
||||
|
||||
// Sync store → URL on changes
|
||||
useEffect(() => {
|
||||
const unsub = useExecutionSearch.subscribe((state) => {
|
||||
if (!hydrated.current) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const statusStr = state.status.join(',');
|
||||
if (statusStr !== DEFAULTS.status) params.set('status', statusStr);
|
||||
|
||||
if (state.text) params.set('text', state.text);
|
||||
if (state.routeId) params.set('routeId', state.routeId);
|
||||
if (state.agentId) params.set('agentId', state.agentId);
|
||||
|
||||
const sortStr = `${state.sortField}:${state.sortDir}`;
|
||||
if (sortStr !== `${DEFAULTS.sortField}:${DEFAULTS.sortDir}`) params.set('sort', sortStr);
|
||||
|
||||
if (state.offset > 0) params.set('offset', String(state.offset));
|
||||
|
||||
const qs = params.toString();
|
||||
const newUrl = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, []);
|
||||
}
|
||||
86
ui/src/pages/routes/ExchangeTab.module.css
Normal file
86
ui/src/pages/routes/ExchangeTab.module.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.wrap {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.key {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bodyPre {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.errorPanel {
|
||||
background: var(--rose-glow);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--rose);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
64
ui/src/pages/routes/ExchangeTab.tsx
Normal file
64
ui/src/pages/routes/ExchangeTab.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
||||
import styles from './ExchangeTab.module.css';
|
||||
|
||||
interface ExchangeTabProps {
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export function ExchangeTab({ executionId }: ExchangeTabProps) {
|
||||
const { data: execution, isLoading } = useExecutionDetail(executionId);
|
||||
const { data: snapshot } = useProcessorSnapshot(executionId, 0);
|
||||
|
||||
const body = snapshot?.['body'];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={styles.loading}>Loading exchange details...</div>;
|
||||
}
|
||||
|
||||
if (!execution) {
|
||||
return <div className={styles.empty}>Execution not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<h3 className={styles.heading}>Exchange Details</h3>
|
||||
|
||||
<dl className={styles.grid}>
|
||||
<dt className={styles.key}>Execution ID</dt>
|
||||
<dd className={styles.value}>{execution.executionId}</dd>
|
||||
|
||||
<dt className={styles.key}>Correlation ID</dt>
|
||||
<dd className={styles.value}>{execution.correlationId ?? '-'}</dd>
|
||||
|
||||
<dt className={styles.key}>Application</dt>
|
||||
<dd className={styles.value}>{execution.agentId}</dd>
|
||||
|
||||
<dt className={styles.key}>Route</dt>
|
||||
<dd className={styles.value}>{execution.routeId}</dd>
|
||||
|
||||
<dt className={styles.key}>Timestamp</dt>
|
||||
<dd className={styles.value}>{new Date(execution.startTime).toISOString()}</dd>
|
||||
|
||||
<dt className={styles.key}>Duration</dt>
|
||||
<dd className={styles.value}>{execution.durationMs}ms</dd>
|
||||
|
||||
<dt className={styles.key}>Status</dt>
|
||||
<dd className={styles.value}>{execution.status}</dd>
|
||||
</dl>
|
||||
|
||||
{body && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Input Body</span>
|
||||
<pre className={styles.bodyPre}>{body}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{execution.errorMessage && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Error</span>
|
||||
<div className={styles.errorPanel}>{execution.errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import { RouteHeader } from './RouteHeader';
|
||||
import { DiagramTab } from './DiagramTab';
|
||||
import { PerformanceTab } from './PerformanceTab';
|
||||
import { ProcessorTree } from '../executions/ProcessorTree';
|
||||
import { ExchangeTab } from './ExchangeTab';
|
||||
import { ExecutionPicker } from './diagram/ExecutionPicker';
|
||||
import styles from './RoutePage.module.css';
|
||||
|
||||
type Tab = 'diagram' | 'performance' | 'processors';
|
||||
type Tab = 'diagram' | 'performance' | 'processors' | 'exchange';
|
||||
|
||||
export function RoutePage() {
|
||||
const { group, routeId } = useParams<{ group: string; routeId: string }>();
|
||||
@@ -53,6 +54,8 @@ export function RoutePage() {
|
||||
return <div className={styles.error}>Missing group or routeId parameters</div>;
|
||||
}
|
||||
|
||||
const needsExecPicker = activeTab === 'diagram' || activeTab === 'processors' || activeTab === 'exchange';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Breadcrumb */}
|
||||
@@ -89,22 +92,32 @@ export function RoutePage() {
|
||||
>
|
||||
Processor Tree
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'exchange' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('exchange')}
|
||||
>
|
||||
Exchange
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'diagram' && (
|
||||
{needsExecPicker && (
|
||||
<div className={styles.toolbarRight}>
|
||||
<ExecutionPicker group={group} routeId={routeId} />
|
||||
<button
|
||||
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
|
||||
onClick={overlay.toggle}
|
||||
title="Toggle execution overlay (E)"
|
||||
>
|
||||
{overlay.isActive ? 'Hide' : 'Show'} Execution
|
||||
</button>
|
||||
{execution && (
|
||||
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
|
||||
{execution.status} · {execution.durationMs}ms
|
||||
</span>
|
||||
{activeTab === 'diagram' && (
|
||||
<>
|
||||
<button
|
||||
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
|
||||
onClick={overlay.toggle}
|
||||
title="Toggle execution overlay (E)"
|
||||
>
|
||||
{overlay.isActive ? 'Hide' : 'Show'} Execution
|
||||
</button>
|
||||
{execution && (
|
||||
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
|
||||
{execution.status} · {execution.durationMs}ms
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -134,6 +147,16 @@ export function RoutePage() {
|
||||
Select an execution to view the processor tree
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'exchange' && execId && (
|
||||
<ExchangeTab executionId={execId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'exchange' && !execId && (
|
||||
<div className={styles.emptyState}>
|
||||
Select an execution to view exchange details
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OidcCallback } from './auth/OidcCallback';
|
||||
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
||||
import { RoutePage } from './pages/routes/RoutePage';
|
||||
import { ApplicationsPage } from './pages/apps/ApplicationsPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -24,6 +25,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/executions" replace /> },
|
||||
{ path: 'executions', element: <ExecutionExplorer /> },
|
||||
{ path: 'apps', element: <ApplicationsPage /> },
|
||||
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
|
||||
{ path: 'admin/oidc', element: <OidcAdminPage /> },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user