From 61a9549853bfd58397b4804ae76f87f95c3d2a33 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:40:03 +0100 Subject: [PATCH] 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) --- ui/src/components/layout/TopNav.tsx | 5 + ui/src/pages/apps/ApplicationsPage.module.css | 132 +++++++++++++++++ ui/src/pages/apps/ApplicationsPage.tsx | 107 ++++++++++++++ ui/src/pages/executions/ExecutionExplorer.tsx | 2 + .../pages/executions/ResultsTable.module.css | 80 ++-------- ui/src/pages/executions/ResultsTable.tsx | 138 +++++++++--------- .../executions/use-search-params-sync.ts | 80 ++++++++++ ui/src/pages/routes/ExchangeTab.module.css | 86 +++++++++++ ui/src/pages/routes/ExchangeTab.tsx | 64 ++++++++ ui/src/pages/routes/RoutePage.tsx | 49 +++++-- ui/src/router.tsx | 2 + 11 files changed, 600 insertions(+), 145 deletions(-) create mode 100644 ui/src/pages/apps/ApplicationsPage.module.css create mode 100644 ui/src/pages/apps/ApplicationsPage.tsx create mode 100644 ui/src/pages/executions/use-search-params-sync.ts create mode 100644 ui/src/pages/routes/ExchangeTab.module.css create mode 100644 ui/src/pages/routes/ExchangeTab.tsx diff --git a/ui/src/components/layout/TopNav.tsx b/ui/src/components/layout/TopNav.tsx index d19e547f..121acc0a 100644 --- a/ui/src/components/layout/TopNav.tsx +++ b/ui/src/components/layout/TopNav.tsx @@ -23,6 +23,11 @@ export function TopNav() { Transactions +
  • + isActive ? styles.navLinkActive : styles.navLink}> + Applications + +
  • {roles.includes('ADMIN') && (
  • isActive ? styles.navLinkActive : styles.navLink}> diff --git a/ui/src/pages/apps/ApplicationsPage.module.css b/ui/src/pages/apps/ApplicationsPage.module.css new file mode 100644 index 00000000..94843f7d --- /dev/null +++ b/ui/src/pages/apps/ApplicationsPage.module.css @@ -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; + } +} diff --git a/ui/src/pages/apps/ApplicationsPage.tsx b/ui/src/pages/apps/ApplicationsPage.tsx new file mode 100644 index 00000000..28e71dcd --- /dev/null +++ b/ui/src/pages/apps/ApplicationsPage.tsx @@ -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(); + 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
    Loading applications...
    ; + } + + return ( + <> +
    +

    Applications

    +
    Monitored Camel applications and their routes
    +
    + + {groups.length === 0 ? ( +
    No applications found. Agents will appear here once they connect.
    + ) : ( +
    + {groups.map((g) => { + const status = groupStatus(g); + return ( +
    +
    +
    + {g.group} + + {g.agents.length} instance{g.agents.length !== 1 ? 's' : ''} + +
    + +
    + {g.liveCount > 0 && {g.liveCount} live} + {g.staleCount > 0 && {g.staleCount} stale} + {g.deadCount > 0 && {g.deadCount} dead} +
    + + {g.routeIds.length > 0 && ( +
    + Routes +
      + {g.routeIds.map((rid) => ( +
    • + + {rid} + +
    • + ))} +
    +
    + )} +
    + ); + })} +
    + )} + + ); +} diff --git a/ui/src/pages/executions/ExecutionExplorer.tsx b/ui/src/pages/executions/ExecutionExplorer.tsx index 4a860807..ce6e0978 100644 --- a/ui/src/pages/executions/ExecutionExplorer.tsx +++ b/ui/src/pages/executions/ExecutionExplorer.tsx @@ -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); diff --git a/ui/src/pages/executions/ResultsTable.module.css b/ui/src/pages/executions/ResultsTable.module.css index 90b5a33a..454987d3 100644 --- a/ui/src/pages/executions/ResultsTable.module.css +++ b/ui/src/pages/executions/ResultsTable.module.css @@ -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%; } -} diff --git a/ui/src/pages/executions/ResultsTable.tsx b/ui/src/pages/executions/ResultsTable.tsx index 3dcc8f5e..ad2af2b5 100644 --- a/ui/src/pages/executions/ResultsTable.tsx +++ b/ui/src/pages/executions/ResultsTable.tsx @@ -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(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(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) { - - {results.map((exec) => { - const isExpanded = expandedId === exec.executionId; - return ( - setExpandedId(isExpanded ? null : exec.executionId)} - onDiagramNav={() => handleDiagramNav(exec)} - /> - ); - })} + {results.map((exec) => ( + handleDiagramNav(exec)} + /> + ))}
    @@ -104,18 +122,15 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
    @@ -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; + highlighted: boolean; + onClick: () => void; }) { + const group = groupByAgent.get(exec.agentId) ?? 'default'; return ( - <> - - › - {formatTime(exec.startTime)} - - - - - - - {exec.routeId} - - {exec.correlationId ?? '-'} - - - - - - {isExpanded && ( - - -
    -
    - -
    -
    - - -
    -
    - - - )} - + + {formatTime(exec.startTime)} + + + + + + + + e.stopPropagation()} + > + {exec.routeId} + + + + {exec.correlationId ?? '-'} + + + + + ); } diff --git a/ui/src/pages/executions/use-search-params-sync.ts b/ui/src/pages/executions/use-search-params-sync.ts new file mode 100644 index 00000000..ccc95b5c --- /dev/null +++ b/ui/src/pages/executions/use-search-params-sync.ts @@ -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; + }, []); +} diff --git a/ui/src/pages/routes/ExchangeTab.module.css b/ui/src/pages/routes/ExchangeTab.module.css new file mode 100644 index 00000000..ce2bbe33 --- /dev/null +++ b/ui/src/pages/routes/ExchangeTab.module.css @@ -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; +} diff --git a/ui/src/pages/routes/ExchangeTab.tsx b/ui/src/pages/routes/ExchangeTab.tsx new file mode 100644 index 00000000..4ac26075 --- /dev/null +++ b/ui/src/pages/routes/ExchangeTab.tsx @@ -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
    Loading exchange details...
    ; + } + + if (!execution) { + return
    Execution not found
    ; + } + + return ( +
    +

    Exchange Details

    + +
    +
    Execution ID
    +
    {execution.executionId}
    + +
    Correlation ID
    +
    {execution.correlationId ?? '-'}
    + +
    Application
    +
    {execution.agentId}
    + +
    Route
    +
    {execution.routeId}
    + +
    Timestamp
    +
    {new Date(execution.startTime).toISOString()}
    + +
    Duration
    +
    {execution.durationMs}ms
    + +
    Status
    +
    {execution.status}
    +
    + + {body && ( +
    + Input Body +
    {body}
    +
    + )} + + {execution.errorMessage && ( +
    + Error +
    {execution.errorMessage}
    +
    + )} +
    + ); +} diff --git a/ui/src/pages/routes/RoutePage.tsx b/ui/src/pages/routes/RoutePage.tsx index 999d4110..55327feb 100644 --- a/ui/src/pages/routes/RoutePage.tsx +++ b/ui/src/pages/routes/RoutePage.tsx @@ -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
    Missing group or routeId parameters
    ; } + const needsExecPicker = activeTab === 'diagram' || activeTab === 'processors' || activeTab === 'exchange'; + return ( <> {/* Breadcrumb */} @@ -89,22 +92,32 @@ export function RoutePage() { > Processor Tree + - {activeTab === 'diagram' && ( + {needsExecPicker && (
    - - {execution && ( - - {execution.status} · {execution.durationMs}ms - + {activeTab === 'diagram' && ( + <> + + {execution && ( + + {execution.status} · {execution.durationMs}ms + + )} + )}
    )} @@ -134,6 +147,16 @@ export function RoutePage() { Select an execution to view the processor tree )} + + {activeTab === 'exchange' && execId && ( + + )} + + {activeTab === 'exchange' && !execId && ( +
    + Select an execution to view exchange details +
    + )} ); } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index faeeeb80..fd2a601a 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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: }, { path: 'executions', element: }, + { path: 'apps', element: }, { path: 'apps/:group/routes/:routeId', element: }, { path: 'admin/oidc', element: }, ],