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>
313 lines
10 KiB
TypeScript
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: “{textFilter}”
|
|
<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>
|
|
</>
|
|
)
|
|
}
|