UX overhaul: 1-click row navigation, Exchange tab, Applications page (#69)
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 49s
CI / deploy (push) Successful in 32s

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:
hsiegeln
2026-03-15 11:40:03 +01:00
parent 5ad0c75da8
commit 61a9549853
11 changed files with 600 additions and 145 deletions

View File

@@ -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}>

View 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;
}
}

View 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>
)}
</>
);
}

View File

@@ -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);

View File

@@ -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%; }
}

View File

@@ -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}`}>&rsaquo;</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>
);
}

View 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;
}, []);
}

View 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;
}

View 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>
);
}

View File

@@ -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} &middot; {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} &middot; {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>
)}
</>
);
}

View File

@@ -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 /> },
],