Add React UI with Execution Explorer, auth, and standalone deployment
Some checks failed
CI / build (push) Failing after 1m53s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped

- 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:
hsiegeln
2026-03-13 13:59:22 +01:00
parent 9c2391e5d4
commit 3eb83f97d3
65 changed files with 6449 additions and 22 deletions

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

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

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

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

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

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

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

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

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

View 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}>&#8984;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}>&rarr;</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}>&le; {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}>&times;</button>
</span>
))}
<button className={styles.clearAll} onClick={clearAll}>Clear all</button>
</div>
</div>
)}
</div>
);
}

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