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
|
Transactions
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink to="/apps" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||||
|
Applications
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
{roles.includes('ADMIN') && (
|
{roles.includes('ADMIN') && (
|
||||||
<li>
|
<li>
|
||||||
<NavLink to="/admin/oidc" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
<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 { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||||
import { useExecutionSearch } from './use-execution-search';
|
import { useExecutionSearch } from './use-execution-search';
|
||||||
|
import { useSearchParamsSync } from './use-search-params-sync';
|
||||||
import { StatCard } from '../../components/shared/StatCard';
|
import { StatCard } from '../../components/shared/StatCard';
|
||||||
import { Pagination } from '../../components/shared/Pagination';
|
import { Pagination } from '../../components/shared/Pagination';
|
||||||
import { SearchFilters } from './SearchFilters';
|
import { SearchFilters } from './SearchFilters';
|
||||||
@@ -21,6 +22,7 @@ function pctChange(current: number, previous: number): { text: string; direction
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExecutionExplorer() {
|
export function ExecutionExplorer() {
|
||||||
|
useSearchParamsSync();
|
||||||
const { toSearchRequest, offset, limit, setOffset, live, toggleLive } = useExecutionSearch();
|
const { toSearchRequest, offset, limit, setOffset, live, toggleLive } = useExecutionSearch();
|
||||||
const searchRequest = toSearchRequest();
|
const searchRequest = toSearchRequest();
|
||||||
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
|
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
|
||||||
|
|||||||
@@ -72,77 +72,32 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.correlationId {
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Detail Row ─── */
|
/* ─── Route Link ─── */
|
||||||
.detailRow {
|
.routeLink {
|
||||||
display: none;
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailRowVisible {
|
.routeLink:hover {
|
||||||
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);
|
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
font-size: 12px;
|
text-decoration: underline;
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagramBtn:hover {
|
/* ─── Highlighted Row (back-nav flash) ─── */
|
||||||
background: var(--amber);
|
@keyframes flash {
|
||||||
color: var(--bg-deep);
|
0% { background: var(--amber-glow); }
|
||||||
border-color: var(--amber);
|
100% { background: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted {
|
||||||
|
animation: flash 2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Loading / Empty ─── */
|
/* ─── Loading / Empty ─── */
|
||||||
@@ -160,8 +115,3 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.detailContent { flex-direction: column; }
|
|
||||||
.detailSide { width: 100%; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate, Link } from 'react-router';
|
||||||
import type { ExecutionSummary } from '../../api/types';
|
import type { ExecutionSummary } from '../../api/types';
|
||||||
import { useAgents } from '../../api/queries/agents';
|
import { useAgents } from '../../api/queries/agents';
|
||||||
import { StatusPill } from '../../components/shared/StatusPill';
|
import { StatusPill } from '../../components/shared/StatusPill';
|
||||||
import { DurationBar } from '../../components/shared/DurationBar';
|
import { DurationBar } from '../../components/shared/DurationBar';
|
||||||
import { AppBadge } from '../../components/shared/AppBadge';
|
import { AppBadge } from '../../components/shared/AppBadge';
|
||||||
import { ProcessorTree } from './ProcessorTree';
|
|
||||||
import { ExchangeDetail } from './ExchangeDetail';
|
|
||||||
import { useExecutionSearch } from './use-execution-search';
|
import { useExecutionSearch } from './use-execution-search';
|
||||||
import styles from './ResultsTable.module.css';
|
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) {
|
export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
||||||
const sortColumn = useExecutionSearch((s) => s.sortField);
|
const sortColumn = useExecutionSearch((s) => s.sortField);
|
||||||
const sortDir = useExecutionSearch((s) => s.sortDir);
|
const sortDir = useExecutionSearch((s) => s.sortDir);
|
||||||
const setSort = useExecutionSearch((s) => s.setSort);
|
const setSort = useExecutionSearch((s) => s.setSort);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: agents } = useAgents();
|
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) {
|
function handleSort(col: SortColumn) {
|
||||||
setSort(col);
|
setSort(col);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate to route diagram page with execution overlay */
|
/** Navigate to route diagram page with execution overlay */
|
||||||
function handleDiagramNav(exec: ExecutionSummary) {
|
function handleDiagramNav(exec: ExecutionSummary) {
|
||||||
// Resolve agentId → group from agent registry
|
const group = groupByAgent.get(exec.agentId) ?? 'default';
|
||||||
const agent = agents?.find((a) => a.id === exec.agentId);
|
sessionStorage.setItem('lastExecId', exec.executionId);
|
||||||
const group = agent?.group ?? 'default';
|
|
||||||
|
|
||||||
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) {
|
if (loading && results.length === 0) {
|
||||||
@@ -94,7 +113,6 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
|||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead className={styles.thead}>
|
<thead className={styles.thead}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={styles.th} style={{ width: 32 }} />
|
|
||||||
<SortableTh label="Timestamp" column="startTime" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
|
<SortableTh label="Timestamp" column="startTime" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
|
||||||
<SortableTh label="Status" column="status" 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} />
|
<SortableTh label="Application" column="agentId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
|
||||||
@@ -104,18 +122,15 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.map((exec) => {
|
{results.map((exec) => (
|
||||||
const isExpanded = expandedId === exec.executionId;
|
<ResultRow
|
||||||
return (
|
key={exec.executionId}
|
||||||
<ResultRow
|
exec={exec}
|
||||||
key={exec.executionId}
|
groupByAgent={groupByAgent}
|
||||||
exec={exec}
|
highlighted={highlightRef.current === exec.executionId}
|
||||||
isExpanded={isExpanded}
|
onClick={() => handleDiagramNav(exec)}
|
||||||
onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)}
|
/>
|
||||||
onDiagramNav={() => handleDiagramNav(exec)}
|
))}
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,54 +139,43 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
|||||||
|
|
||||||
function ResultRow({
|
function ResultRow({
|
||||||
exec,
|
exec,
|
||||||
isExpanded,
|
groupByAgent,
|
||||||
onToggle,
|
highlighted,
|
||||||
onDiagramNav,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
exec: ExecutionSummary;
|
exec: ExecutionSummary;
|
||||||
isExpanded: boolean;
|
groupByAgent: Map<string, string>;
|
||||||
onToggle: () => void;
|
highlighted: boolean;
|
||||||
onDiagramNav: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const group = groupByAgent.get(exec.agentId) ?? 'default';
|
||||||
return (
|
return (
|
||||||
<>
|
<tr
|
||||||
<tr
|
className={`${styles.row} ${highlighted ? styles.highlighted : ''}`}
|
||||||
className={`${styles.row} ${isExpanded ? styles.expanded : ''}`}
|
onClick={onClick}
|
||||||
onClick={onToggle}
|
>
|
||||||
>
|
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
|
||||||
<td className={`${styles.td} ${styles.tdExpand}`}>›</td>
|
<td className={styles.td}>
|
||||||
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
|
<StatusPill status={exec.status} />
|
||||||
<td className={styles.td}>
|
</td>
|
||||||
<StatusPill status={exec.status} />
|
<td className={styles.td}>
|
||||||
</td>
|
<AppBadge name={exec.agentId} />
|
||||||
<td className={styles.td}>
|
</td>
|
||||||
<AppBadge name={exec.agentId} />
|
<td className={`${styles.td} mono text-secondary`}>
|
||||||
</td>
|
<Link
|
||||||
<td className={`${styles.td} mono text-secondary`}>{exec.routeId}</td>
|
to={`/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}`}
|
||||||
<td className={`${styles.td} mono text-muted ${styles.correlationId}`} title={exec.correlationId ?? ''}>
|
className={styles.routeLink}
|
||||||
{exec.correlationId ?? '-'}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</td>
|
>
|
||||||
<td className={styles.td}>
|
{exec.routeId}
|
||||||
<DurationBar duration={exec.durationMs} />
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td className={`${styles.td} mono text-muted ${styles.correlationId}`} title={exec.correlationId ?? ''}>
|
||||||
{isExpanded && (
|
{exec.correlationId ?? '-'}
|
||||||
<tr className={styles.detailRowVisible}>
|
</td>
|
||||||
<td className={styles.detailCell} colSpan={7}>
|
<td className={styles.td}>
|
||||||
<div className={styles.detailContent}>
|
<DurationBar duration={exec.durationMs} />
|
||||||
<div className={styles.detailMain}>
|
</td>
|
||||||
<ProcessorTree executionId={exec.executionId} />
|
</tr>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { DiagramTab } from './DiagramTab';
|
||||||
import { PerformanceTab } from './PerformanceTab';
|
import { PerformanceTab } from './PerformanceTab';
|
||||||
import { ProcessorTree } from '../executions/ProcessorTree';
|
import { ProcessorTree } from '../executions/ProcessorTree';
|
||||||
|
import { ExchangeTab } from './ExchangeTab';
|
||||||
import { ExecutionPicker } from './diagram/ExecutionPicker';
|
import { ExecutionPicker } from './diagram/ExecutionPicker';
|
||||||
import styles from './RoutePage.module.css';
|
import styles from './RoutePage.module.css';
|
||||||
|
|
||||||
type Tab = 'diagram' | 'performance' | 'processors';
|
type Tab = 'diagram' | 'performance' | 'processors' | 'exchange';
|
||||||
|
|
||||||
export function RoutePage() {
|
export function RoutePage() {
|
||||||
const { group, routeId } = useParams<{ group: string; routeId: string }>();
|
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>;
|
return <div className={styles.error}>Missing group or routeId parameters</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsExecPicker = activeTab === 'diagram' || activeTab === 'processors' || activeTab === 'exchange';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
@@ -89,22 +92,32 @@ export function RoutePage() {
|
|||||||
>
|
>
|
||||||
Processor Tree
|
Processor Tree
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.tab} ${activeTab === 'exchange' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('exchange')}
|
||||||
|
>
|
||||||
|
Exchange
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'diagram' && (
|
{needsExecPicker && (
|
||||||
<div className={styles.toolbarRight}>
|
<div className={styles.toolbarRight}>
|
||||||
<ExecutionPicker group={group} routeId={routeId} />
|
<ExecutionPicker group={group} routeId={routeId} />
|
||||||
<button
|
{activeTab === 'diagram' && (
|
||||||
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
|
<>
|
||||||
onClick={overlay.toggle}
|
<button
|
||||||
title="Toggle execution overlay (E)"
|
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
|
||||||
>
|
onClick={overlay.toggle}
|
||||||
{overlay.isActive ? 'Hide' : 'Show'} Execution
|
title="Toggle execution overlay (E)"
|
||||||
</button>
|
>
|
||||||
{execution && (
|
{overlay.isActive ? 'Hide' : 'Show'} Execution
|
||||||
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
|
</button>
|
||||||
{execution.status} · {execution.durationMs}ms
|
{execution && (
|
||||||
</span>
|
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
|
||||||
|
{execution.status} · {execution.durationMs}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -134,6 +147,16 @@ export function RoutePage() {
|
|||||||
Select an execution to view the processor tree
|
Select an execution to view the processor tree
|
||||||
</div>
|
</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 { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||||
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
||||||
import { RoutePage } from './pages/routes/RoutePage';
|
import { RoutePage } from './pages/routes/RoutePage';
|
||||||
|
import { ApplicationsPage } from './pages/apps/ApplicationsPage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,7 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/executions" replace /> },
|
{ index: true, element: <Navigate to="/executions" replace /> },
|
||||||
{ path: 'executions', element: <ExecutionExplorer /> },
|
{ path: 'executions', element: <ExecutionExplorer /> },
|
||||||
|
{ path: 'apps', element: <ApplicationsPage /> },
|
||||||
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
|
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
|
||||||
{ path: 'admin/oidc', element: <OidcAdminPage /> },
|
{ path: 'admin/oidc', element: <OidcAdminPage /> },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user