Add React UI with Execution Explorer, auth, and standalone deployment
- Scaffold Vite + React + TypeScript frontend in ui/ with full design system (dark/light themes) matching the HTML mockups - Implement Execution Explorer page: search filters, results table with expandable processor tree and exchange detail sidebar, pagination - Add UI authentication: UiAuthController (login/refresh endpoints), JWT filter handles ui: subject prefix, CORS configuration - Shared components: StatusPill, DurationBar, StatCard, AppBadge, FilterChip, Pagination — all using CSS Modules with design tokens - API client layer: openapi-fetch with auth middleware, TanStack Query hooks for search/detail/snapshot queries, Zustand for state - Standalone deployment: Nginx Dockerfile, K8s Deployment + ConfigMap + NodePort (30080), runtime config.js for API base URL - Embedded mode: maven-resources-plugin copies ui/dist into JAR static resources, SPA forward controller for client-side routing - CI/CD: UI build step, Docker build/push for server-ui image, K8s deploy step for UI, UI credential secrets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
75
ui/src/pages/executions/ExchangeDetail.module.css
Normal file
75
ui/src/pages/executions/ExchangeDetail.module.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kvKey {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kvValue {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bodyPreview {
|
||||
margin-top: 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bodyLabel {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.errorPreview {
|
||||
margin-top: 12px;
|
||||
background: var(--rose-glow);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--rose);
|
||||
max-height: 80px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.sidebar { width: 100%; }
|
||||
}
|
||||
45
ui/src/pages/executions/ExchangeDetail.tsx
Normal file
45
ui/src/pages/executions/ExchangeDetail.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useProcessorSnapshot } from '../../api/queries/executions';
|
||||
import type { ExecutionSummary } from '../../api/schema';
|
||||
import styles from './ExchangeDetail.module.css';
|
||||
|
||||
interface ExchangeDetailProps {
|
||||
execution: ExecutionSummary;
|
||||
}
|
||||
|
||||
export function ExchangeDetail({ execution }: ExchangeDetailProps) {
|
||||
// Fetch the first processor's snapshot (index 0) for body preview
|
||||
const { data: snapshot } = useProcessorSnapshot(execution.executionId, 0);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<h4 className={styles.title}>Exchange Details</h4>
|
||||
<dl className={styles.kv}>
|
||||
<dt className={styles.kvKey}>Exchange ID</dt>
|
||||
<dd className={styles.kvValue}>{execution.executionId}</dd>
|
||||
<dt className={styles.kvKey}>Correlation</dt>
|
||||
<dd className={styles.kvValue}>{execution.correlationId ?? '-'}</dd>
|
||||
<dt className={styles.kvKey}>Application</dt>
|
||||
<dd className={styles.kvValue}>{execution.agentId}</dd>
|
||||
<dt className={styles.kvKey}>Route</dt>
|
||||
<dd className={styles.kvValue}>{execution.routeId}</dd>
|
||||
<dt className={styles.kvKey}>Timestamp</dt>
|
||||
<dd className={styles.kvValue}>{new Date(execution.startTime).toISOString()}</dd>
|
||||
<dt className={styles.kvKey}>Duration</dt>
|
||||
<dd className={styles.kvValue}>{execution.duration}ms</dd>
|
||||
<dt className={styles.kvKey}>Processors</dt>
|
||||
<dd className={styles.kvValue}>{execution.processorCount}</dd>
|
||||
</dl>
|
||||
|
||||
{snapshot?.body && (
|
||||
<div className={styles.bodyPreview}>
|
||||
<span className={styles.bodyLabel}>Input Body</span>
|
||||
{snapshot.body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{execution.errorMessage && (
|
||||
<div className={styles.errorPreview}>{execution.errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
ui/src/pages/executions/ExecutionExplorer.module.css
Normal file
72
ui/src/pages/executions/ExecutionExplorer.module.css
Normal file
@@ -0,0 +1,72 @@
|
||||
.pageHeader {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pageHeader h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.liveIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--green);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.statsBar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.resultsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.resultsCount {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.resultsCount strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1200px) {
|
||||
.statsBar { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.statsBar { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
67
ui/src/pages/executions/ExecutionExplorer.tsx
Normal file
67
ui/src/pages/executions/ExecutionExplorer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useSearchExecutions } from '../../api/queries/executions';
|
||||
import { useExecutionSearch } from './use-execution-search';
|
||||
import { StatCard } from '../../components/shared/StatCard';
|
||||
import { Pagination } from '../../components/shared/Pagination';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { ResultsTable } from './ResultsTable';
|
||||
import styles from './ExecutionExplorer.module.css';
|
||||
|
||||
export function ExecutionExplorer() {
|
||||
const { toSearchRequest, offset, limit, setOffset } = useExecutionSearch();
|
||||
const searchRequest = toSearchRequest();
|
||||
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const results = data?.results ?? [];
|
||||
|
||||
// Derive stats from current search results
|
||||
const failedCount = results.filter((r) => r.status === 'FAILED').length;
|
||||
const avgDuration = results.length > 0
|
||||
? Math.round(results.reduce((sum, r) => sum + r.duration, 0) / results.length)
|
||||
: 0;
|
||||
|
||||
const showFrom = total > 0 ? offset + 1 : 0;
|
||||
const showTo = Math.min(offset + limit, total);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
<div className={`${styles.pageHeader} animate-in`}>
|
||||
<div>
|
||||
<h1>Transaction Explorer</h1>
|
||||
<div className={styles.subtitle}>Search and analyze route executions</div>
|
||||
</div>
|
||||
<div className={styles.liveIndicator}>
|
||||
<span className={styles.liveDot} />
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className={styles.statsBar}>
|
||||
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={`from current search`} />
|
||||
<StatCard label="Avg Duration" value={`${avgDuration}ms`} accent="cyan" />
|
||||
<StatCard label="Failed (page)" value={failedCount.toString()} accent="rose" />
|
||||
<StatCard label="P99 Latency" value="--" accent="green" change="stats endpoint coming soon" />
|
||||
<StatCard label="Active Now" value="--" accent="blue" change="stats endpoint coming soon" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<SearchFilters />
|
||||
|
||||
{/* Results Header */}
|
||||
<div className={`${styles.resultsHeader} animate-in delay-4`}>
|
||||
<span className={styles.resultsCount}>
|
||||
Showing <strong>{showFrom}–{showTo}</strong> of <strong>{total.toLocaleString()}</strong> results
|
||||
{isFetching && !isLoading && ' · updating...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<ResultsTable results={results} loading={isLoading} />
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination total={total} offset={offset} limit={limit} onChange={setOffset} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
97
ui/src/pages/executions/ProcessorTree.module.css
Normal file
97
ui/src/pages/executions/ProcessorTree.module.css
Normal file
@@ -0,0 +1,97 @@
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.procNode {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.procNode:hover { background: var(--bg-surface); }
|
||||
|
||||
.procConnector {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 28px;
|
||||
bottom: -4px;
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.procNode:last-child .procConnector { display: none; }
|
||||
|
||||
.procIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.iconEndpoint { background: rgba(59, 130, 246, 0.15); color: var(--blue); border: 1px solid rgba(59, 130, 246, 0.3); }
|
||||
.iconProcessor { background: var(--green-glow); color: var(--green); border: 1px solid rgba(16, 185, 129, 0.3); }
|
||||
.iconEip { background: rgba(168, 85, 247, 0.12); color: #a855f7; border: 1px solid rgba(168, 85, 247, 0.3); }
|
||||
.iconError { background: var(--rose-glow); color: var(--rose); border: 1px solid rgba(244, 63, 94, 0.3); }
|
||||
|
||||
.procInfo { flex: 1; min-width: 0; }
|
||||
|
||||
.procType {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.procUri {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.procTiming {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.procDuration {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nested {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 12px;
|
||||
}
|
||||
70
ui/src/pages/executions/ProcessorTree.tsx
Normal file
70
ui/src/pages/executions/ProcessorTree.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useExecutionDetail } from '../../api/queries/executions';
|
||||
import type { ProcessorNode as ProcessorNodeType } from '../../api/schema';
|
||||
import styles from './ProcessorTree.module.css';
|
||||
|
||||
const ICON_MAP: Record<string, { label: string; className: string }> = {
|
||||
from: { label: 'EP', className: styles.iconEndpoint },
|
||||
to: { label: 'EP', className: styles.iconEndpoint },
|
||||
toD: { label: 'EP', className: styles.iconEndpoint },
|
||||
choice: { label: 'CB', className: styles.iconEip },
|
||||
when: { label: 'CB', className: styles.iconEip },
|
||||
otherwise: { label: 'CB', className: styles.iconEip },
|
||||
split: { label: 'CB', className: styles.iconEip },
|
||||
aggregate: { label: 'CB', className: styles.iconEip },
|
||||
filter: { label: 'CB', className: styles.iconEip },
|
||||
multicast: { label: 'CB', className: styles.iconEip },
|
||||
recipientList: { label: 'CB', className: styles.iconEip },
|
||||
routingSlip: { label: 'CB', className: styles.iconEip },
|
||||
dynamicRouter: { label: 'CB', className: styles.iconEip },
|
||||
exception: { label: '!!', className: styles.iconError },
|
||||
onException: { label: '!!', className: styles.iconError },
|
||||
};
|
||||
|
||||
function getIcon(type: string, status: string) {
|
||||
if (status === 'FAILED') return { label: '!!', className: styles.iconError };
|
||||
const key = type.toLowerCase();
|
||||
return ICON_MAP[key] ?? { label: 'PR', className: styles.iconProcessor };
|
||||
}
|
||||
|
||||
export function ProcessorTree({ executionId }: { executionId: string }) {
|
||||
const { data, isLoading } = useExecutionDetail(executionId);
|
||||
|
||||
if (isLoading) return <div className={styles.tree}><div className={styles.loading}>Loading processor tree...</div></div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.tree}>
|
||||
<h4 className={styles.title}>Processor Execution Tree</h4>
|
||||
{data.processors.map((proc) => (
|
||||
<ProcessorNodeView key={proc.index} node={proc} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessorNodeView({ node }: { node: ProcessorNodeType }) {
|
||||
const icon = getIcon(node.processorType, node.status);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.procNode}>
|
||||
<div className={styles.procConnector} />
|
||||
<div className={`${styles.procIcon} ${icon.className}`}>{icon.label}</div>
|
||||
<div className={styles.procInfo}>
|
||||
<div className={styles.procType}>{node.processorType}</div>
|
||||
{node.uri && <div className={styles.procUri}>{node.uri}</div>}
|
||||
</div>
|
||||
<div className={styles.procTiming}>
|
||||
<span className={styles.procDuration}>{node.duration}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
{node.children.length > 0 && (
|
||||
<div className={styles.nested}>
|
||||
{node.children.map((child) => (
|
||||
<ProcessorNodeView key={child.index} node={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
ui/src/pages/executions/ResultsTable.module.css
Normal file
104
ui/src/pages/executions/ResultsTable.module.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.tableWrap {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.thead {
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row:hover { background: var(--bg-raised); }
|
||||
|
||||
.td {
|
||||
padding: 12px 16px;
|
||||
vertical-align: middle;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ─── Loading / Empty ─── */
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loadingOverlay {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.detailContent { flex-direction: column; }
|
||||
}
|
||||
120
ui/src/pages/executions/ResultsTable.tsx
Normal file
120
ui/src/pages/executions/ResultsTable.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import type { ExecutionSummary } from '../../api/schema';
|
||||
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 styles from './ResultsTable.module.css';
|
||||
|
||||
interface ResultsTableProps {
|
||||
results: ExecutionSummary[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (loading && results.length === 0) {
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.loadingOverlay}>Loading executions...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.emptyState}>No executions found matching your filters.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.thead}>
|
||||
<tr>
|
||||
<th className={styles.th} style={{ width: 32 }} />
|
||||
<th className={styles.th}>Timestamp</th>
|
||||
<th className={styles.th}>Status</th>
|
||||
<th className={styles.th}>Application</th>
|
||||
<th className={styles.th}>Route</th>
|
||||
<th className={styles.th}>Correlation ID</th>
|
||||
<th className={styles.th}>Duration</th>
|
||||
<th className={styles.th}>Processors</th>
|
||||
</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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
exec,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
exec: ExecutionSummary;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
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.duration} />
|
||||
</td>
|
||||
<td className={`${styles.td} mono text-muted`}>{exec.processorCount}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className={styles.detailRowVisible}>
|
||||
<td className={styles.detailCell} colSpan={8}>
|
||||
<div className={styles.detailContent}>
|
||||
<ProcessorTree executionId={exec.executionId} />
|
||||
<ExchangeDetail execution={exec} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
214
ui/src/pages/executions/SearchFilters.module.css
Normal file
214
ui/src/pages/executions/SearchFilters.module.css
Normal file
@@ -0,0 +1,214 @@
|
||||
.filterBar {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.searchInputWrap {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px 10px 40px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber-dim);
|
||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterChips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
width: 180px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.dateInput:focus { border-color: var(--amber-dim); }
|
||||
|
||||
.dateArrow {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.durationRange {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rangeInput {
|
||||
width: 100px;
|
||||
accent-color: var(--amber);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rangeLabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--text-muted); }
|
||||
|
||||
.btnPrimary {
|
||||
composes: btn;
|
||||
background: var(--amber);
|
||||
color: #0a0e17;
|
||||
border-color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btnPrimary:hover { background: var(--amber-hover); border-color: var(--amber-hover); color: #0a0e17; }
|
||||
|
||||
.filterTags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--amber-glow);
|
||||
border: 1px solid rgba(240, 180, 41, 0.2);
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
color: var(--amber);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.filterTagRemove {
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.filterTagRemove:hover { opacity: 1; }
|
||||
|
||||
.clearAll {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.clearAll:hover { color: var(--rose); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterRow { flex-direction: column; align-items: stretch; }
|
||||
.searchInputWrap { min-width: unset; }
|
||||
}
|
||||
124
ui/src/pages/executions/SearchFilters.tsx
Normal file
124
ui/src/pages/executions/SearchFilters.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useExecutionSearch } from './use-execution-search';
|
||||
import { FilterChip } from '../../components/shared/FilterChip';
|
||||
import styles from './SearchFilters.module.css';
|
||||
|
||||
export function SearchFilters() {
|
||||
const {
|
||||
status, toggleStatus,
|
||||
timeFrom, setTimeFrom,
|
||||
timeTo, setTimeTo,
|
||||
durationMax, setDurationMax,
|
||||
text, setText,
|
||||
clearAll,
|
||||
} = useExecutionSearch();
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(value: string) => {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setText(value), 300);
|
||||
},
|
||||
[setText],
|
||||
);
|
||||
|
||||
const activeTags: { label: string; onRemove: () => void }[] = [];
|
||||
if (text) activeTags.push({ label: `text:"${text}"`, onRemove: () => setText('') });
|
||||
if (timeFrom) activeTags.push({ label: `from:${timeFrom}`, onRemove: () => setTimeFrom('') });
|
||||
if (timeTo) activeTags.push({ label: `to:${timeTo}`, onRemove: () => setTimeTo('') });
|
||||
if (durationMax && durationMax < 5000) {
|
||||
activeTags.push({ label: `duration:≤${durationMax}ms`, onRemove: () => setDurationMax(null) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.filterBar} animate-in delay-3`}>
|
||||
{/* Row 1: Search */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.searchInputWrap}>
|
||||
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Search by correlation ID, error message, route ID..."
|
||||
defaultValue={text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
<span className={styles.searchHint}>⌘K</span>
|
||||
</div>
|
||||
<button className={styles.btnPrimary}>Search</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Status chips + date + duration */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Status</label>
|
||||
<div className={styles.filterChips}>
|
||||
<FilterChip label="Completed" accent="green" active={status.includes('COMPLETED')} onClick={() => toggleStatus('COMPLETED')} />
|
||||
<FilterChip label="Failed" accent="rose" active={status.includes('FAILED')} onClick={() => toggleStatus('FAILED')} />
|
||||
<FilterChip label="Running" accent="blue" active={status.includes('RUNNING')} onClick={() => toggleStatus('RUNNING')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Date</label>
|
||||
<input
|
||||
className={styles.dateInput}
|
||||
type="datetime-local"
|
||||
value={timeFrom}
|
||||
onChange={(e) => setTimeFrom(e.target.value)}
|
||||
/>
|
||||
<span className={styles.dateArrow}>→</span>
|
||||
<input
|
||||
className={styles.dateInput}
|
||||
type="datetime-local"
|
||||
value={timeTo}
|
||||
onChange={(e) => setTimeTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Duration</label>
|
||||
<div className={styles.durationRange}>
|
||||
<span className={styles.rangeLabel}>0ms</span>
|
||||
<input
|
||||
className={styles.rangeInput}
|
||||
type="range"
|
||||
min="0"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={durationMax ?? 5000}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setDurationMax(v >= 5000 ? null : v);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.rangeLabel}>≤ {durationMax ?? 5000}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Active filter tags */}
|
||||
{activeTags.length > 0 && (
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterTags}>
|
||||
{activeTags.map((tag) => (
|
||||
<span key={tag.label} className={styles.filterTag}>
|
||||
{tag.label}
|
||||
<button className={styles.filterTagRemove} onClick={tag.onRemove}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<button className={styles.clearAll} onClick={clearAll}>Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
ui/src/pages/executions/use-execution-search.ts
Normal file
77
ui/src/pages/executions/use-execution-search.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SearchRequest } from '../../api/schema';
|
||||
|
||||
interface ExecutionSearchState {
|
||||
status: string[];
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
durationMin: number | null;
|
||||
durationMax: number | null;
|
||||
text: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
|
||||
setStatus: (statuses: string[]) => void;
|
||||
toggleStatus: (s: string) => void;
|
||||
setTimeFrom: (v: string) => void;
|
||||
setTimeTo: (v: string) => void;
|
||||
setDurationMin: (v: number | null) => void;
|
||||
setDurationMax: (v: number | null) => void;
|
||||
setText: (v: string) => void;
|
||||
setOffset: (v: number) => void;
|
||||
clearAll: () => void;
|
||||
toSearchRequest: () => SearchRequest;
|
||||
}
|
||||
|
||||
export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
||||
status: ['COMPLETED', 'FAILED'],
|
||||
timeFrom: '',
|
||||
timeTo: '',
|
||||
durationMin: null,
|
||||
durationMax: null,
|
||||
text: '',
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
|
||||
setStatus: (statuses) => set({ status: statuses, offset: 0 }),
|
||||
toggleStatus: (s) =>
|
||||
set((state) => ({
|
||||
status: state.status.includes(s)
|
||||
? state.status.filter((x) => x !== s)
|
||||
: [...state.status, s],
|
||||
offset: 0,
|
||||
})),
|
||||
setTimeFrom: (v) => set({ timeFrom: v, offset: 0 }),
|
||||
setTimeTo: (v) => set({ timeTo: v, offset: 0 }),
|
||||
setDurationMin: (v) => set({ durationMin: v, offset: 0 }),
|
||||
setDurationMax: (v) => set({ durationMax: v, offset: 0 }),
|
||||
setText: (v) => set({ text: v, offset: 0 }),
|
||||
setOffset: (v) => set({ offset: v }),
|
||||
clearAll: () =>
|
||||
set({
|
||||
status: ['COMPLETED', 'FAILED', 'RUNNING'],
|
||||
timeFrom: '',
|
||||
timeTo: '',
|
||||
durationMin: null,
|
||||
durationMax: null,
|
||||
text: '',
|
||||
offset: 0,
|
||||
}),
|
||||
|
||||
toSearchRequest: (): SearchRequest => {
|
||||
const s = get();
|
||||
const statusStr = s.status.length > 0 && s.status.length < 3
|
||||
? s.status.join(',')
|
||||
: undefined;
|
||||
return {
|
||||
status: statusStr ?? undefined,
|
||||
timeFrom: s.timeFrom ? new Date(s.timeFrom).toISOString() : undefined,
|
||||
timeTo: s.timeTo ? new Date(s.timeTo).toISOString() : undefined,
|
||||
durationMin: s.durationMin,
|
||||
durationMax: s.durationMax,
|
||||
text: s.text || undefined,
|
||||
offset: s.offset,
|
||||
limit: s.limit,
|
||||
};
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user