Files
cameleer-server/ui/src/pages/Dashboard/Dashboard.tsx
hsiegeln 29f4be542b
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s
fix(ui): exchange selection uses state, not URL navigation
Row click no longer navigates to /exchanges/:app/:route/:id which was
changing the search scope. Instead, Dashboard calls onExchangeSelect
callback and ExchangesPage manages the selected exchange as local state.
The search criteria and scope are preserved when selecting an exchange.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:20:17 +01:00

313 lines
10 KiB
TypeScript

import { useState, useMemo, useCallback } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router'
import { AlertTriangle, X, Search } from 'lucide-react'
import {
DataTable,
StatusDot,
MonoText,
Badge,
useGlobalFilters,
} from '@cameleer/design-system'
import type { Column } from '@cameleer/design-system'
import {
useSearchExecutions,
} from '../../api/queries/executions'
import type { ExecutionSummary } from '../../api/types'
import styles from './Dashboard.module.css'
// Row type extends ExecutionSummary with an `id` field for DataTable
interface Row extends ExecutionSummary {
id: string
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function formatTimestamp(iso: string): string {
const date = new Date(iso)
const y = date.getFullYear()
const mo = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
}
function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'COMPLETED': return 'success'
case 'FAILED': return 'error'
case 'RUNNING': return 'running'
default: return 'warning'
}
}
function statusLabel(status: string): string {
switch (status) {
case 'COMPLETED': return 'OK'
case 'FAILED': return 'ERR'
case 'RUNNING': return 'RUN'
default: return 'WARN'
}
}
function durationClass(ms: number, status: string): string {
if (status === 'FAILED') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
function flattenProcessors(nodes: any[]): any[] {
const result: any[] = []
let offset = 0
function walk(node: any) {
result.push({
name: node.processorId || node.processorType,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
startMs: offset,
})
offset += node.durationMs ?? 0
if (node.children) node.children.forEach(walk)
}
nodes.forEach(walk)
return result
}
// ─── Table columns (base, without inspect action) ────────────────────────────
function buildBaseColumns(): Column<Row>[] {
return [
{
key: 'status',
header: 'Status',
width: '80px',
render: (_: unknown, row: Row) => (
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(row.status)} />
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
</span>
),
},
{
key: 'routeId',
header: 'Route',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.routeName}>{row.routeId}</span>
),
},
{
key: 'applicationName',
header: 'Application',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.appName}>{row.applicationName ?? ''}</span>
),
},
{
key: 'attributes',
header: 'Attributes',
render: (_, row) => {
const attrs = row.attributes;
if (!attrs || Object.keys(attrs).length === 0) return <span className={styles.muted}></span>;
const entries = Object.entries(attrs);
const shown = entries.slice(0, 2);
const overflow = entries.length - 2;
return (
<div className={styles.attrCell}>
{shown.map(([k, v]) => (
<span key={k} title={k}>
<Badge label={String(v)} color="auto" />
</span>
))}
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
</div>
);
},
},
{
key: 'executionId',
header: 'Exchange ID',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="xs">{row.executionId}</MonoText>
),
},
{
key: 'startTime',
header: 'Started',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="xs">{formatTimestamp(row.startTime)}</MonoText>
),
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
render: (_: unknown, row: Row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
{formatDuration(row.durationMs)}
</MonoText>
),
},
{
key: 'agentId',
header: 'Agent',
render: (_: unknown, row: Row) => (
<span className={styles.agentBadge}>
<span className={styles.agentDot} />
{row.agentId}
</span>
),
},
]
}
// ─── Dashboard component ─────────────────────────────────────────────────────
export interface SelectedExchange {
executionId: string;
applicationName: string;
routeId: string;
}
interface DashboardProps {
onExchangeSelect?: (exchange: SelectedExchange) => void;
}
export default function Dashboard({ onExchangeSelect }: DashboardProps = {}) {
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const textFilter = searchParams.get('text') || undefined
const [selectedId, setSelectedId] = useState<string | undefined>()
const [sortField, setSortField] = useState<string>('startTime')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const { timeRange, statusFilters } = useGlobalFilters()
const timeFrom = timeRange.start.toISOString()
const timeTo = timeRange.end.toISOString()
const handleSortChange = useCallback((key: string, dir: 'asc' | 'desc') => {
setSortField(key)
setSortDir(dir)
}, [])
// ─── API hooks ───────────────────────────────────────────────────────────
// Convert design-system status filters (lowercase) to API status param (uppercase)
const statusParam = statusFilters.size > 0
? [...statusFilters].map(s => s.toUpperCase()).join(',')
: undefined
const { data: searchResult } = useSearchExecutions(
{
timeFrom,
timeTo,
routeId: routeId || undefined,
application: appId || undefined,
status: statusParam,
text: textFilter,
sortField,
sortDir,
offset: 0,
limit: textFilter ? 200 : 50,
},
!textFilter,
)
// ─── Rows ────────────────────────────────────────────────────────────────
const rows: Row[] = useMemo(
() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult],
)
// ─── Table columns ──────────────────────────────────────────────────────
const columns: Column<Row>[] = useMemo(() => buildBaseColumns(), [])
// ─── Row click → navigate to diagram view ────────────────────────────────
function handleRowClick(row: Row) {
setSelectedId(row.id)
if (onExchangeSelect) {
onExchangeSelect({
executionId: row.executionId,
applicationName: row.applicationName ?? '',
routeId: row.routeId,
})
}
}
function handleRowAccent(row: Row): 'error' | 'warning' | undefined {
if (row.status === 'FAILED') return 'error'
return undefined
}
return (
<>
{/* Scrollable content */}
<div className={styles.content}>
{/* Exchanges table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>
{textFilter ? (
<>
<Search size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
Search: &ldquo;{textFilter}&rdquo;
<button
className={styles.clearSearch}
onClick={() => setSearchParams({})}
title="Clear search"
>
<X size={12} />
</button>
</>
) : 'Recent Exchanges'}
</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
</span>
{!textFilter && <Badge label="LIVE" color="success" />}
</div>
</div>
<div className={styles.tableScroll}>
<DataTable
columns={columns}
data={rows}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
onSortChange={handleSortChange}
rowAccent={handleRowAccent}
expandedContent={(row: Row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
<div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
</div>
</div>
) : null
}
/>
</div>
</div>
</div>
</>
)
}