Files
cameleer-server/ui/src/pages/Dashboard/Dashboard.tsx
hsiegeln 191d4f39c1
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m56s
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m58s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m12s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 37s
fix: resolve 4 TypeScript compilation errors from CI
- AuditLogPage: e.details -> e.detail (correct property name)
- AgentInstance: BarChart x: number -> x: String(i) (BarSeries requires string)
- AppsTab: add missing CatalogRoute import
- Dashboard: wrap MonoText in span for title attribute (MonoText lacks title prop)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:57:42 +02:00

305 lines
11 KiB
TypeScript

import React, { useState, useMemo, useCallback, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router'
import { AlertTriangle, X, Search, Footprints, RotateCcw } 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 { useEnvironmentStore } from '../../api/environment-store'
import type { ExecutionSummary } from '../../api/types'
import { attributeBadgeColor } from '../../utils/attribute-color'
import { formatDuration, statusLabel } from '../../utils/format-utils'
import styles from './Dashboard.module.css'
import tableStyles from '../../styles/table-section.module.css'
// Row type extends ExecutionSummary with an `id` field for DataTable
interface Row extends ExecutionSummary {
id: string
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
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 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 shortAgentName(name: string): string {
const parts = name.split('-')
if (parts.length >= 3) return parts.slice(-2).join('-')
return name
}
// ─── Table columns ────────────────────────────────────────────────────────────
function buildColumns(hasAttributes: boolean): 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>
{row.hasTraceData && <Footprints size={11} color="var(--success)" style={{ marginLeft: 2, flexShrink: 0 }} />}
{row.isReplay && <RotateCcw size={11} color="var(--amber)" style={{ marginLeft: 2, flexShrink: 0 }} />}
</span>
),
},
{
key: 'routeId',
header: 'Route',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.routeName}>{row.routeId}</span>
),
},
{
key: 'applicationId',
header: 'Application',
sortable: true,
render: (_: unknown, row: Row) => (
<span className={styles.appName}>{row.applicationId ?? ''}</span>
),
},
...(hasAttributes ? [{
key: 'attributes' as const,
header: 'Attributes',
render: (_: unknown, row: 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={attributeBadgeColor(String(v))} />
</span>
))}
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
</div>
);
},
}] : []),
{
key: 'executionId',
header: 'Exchange ID',
sortable: true,
render: (_: unknown, row: Row) => (
<span
title={row.executionId}
style={{ cursor: 'pointer' }}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(row.executionId);
}}
>
<MonoText size="xs">...{row.executionId.slice(-8)}</MonoText>
</span>
),
},
{
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: 'instanceId',
header: 'Agent',
render: (_: unknown, row: Row) => (
<span className={styles.agentBadge}>
<span className={styles.agentDot} />
<span title={row.instanceId}>{shortAgentName(row.instanceId)}</span>
</span>
),
},
]
}
// ─── Dashboard component ─────────────────────────────────────────────────────
export interface SelectedExchange {
executionId: string;
applicationId: string;
routeId: string;
}
interface DashboardProps {
onExchangeSelect?: (exchange: SelectedExchange) => void;
activeExchangeId?: string;
}
export default function Dashboard({ onExchangeSelect, activeExchangeId }: 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>(activeExchangeId)
const [sortField, setSortField] = useState<string>('startTime')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Sync selection from parent (survives remount when split view toggles)
useEffect(() => {
if (activeExchangeId !== undefined) setSelectedId(activeExchangeId);
}, [activeExchangeId]);
const selectedEnv = useEnvironmentStore((s) => s.environment);
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,
applicationId: appId || undefined,
environment: selectedEnv,
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 hasAttributes = rows.some(r => r.attributes && Object.keys(r.attributes).length > 0)
const columns: Column<Row>[] = useMemo(() => buildColumns(hasAttributes), [hasAttributes])
// ─── Row click → navigate to diagram view ────────────────────────────────
function handleRowClick(row: Row) {
setSelectedId(row.id)
if (onExchangeSelect) {
onExchangeSelect({
executionId: row.executionId,
applicationId: row.applicationId ?? '',
routeId: row.routeId,
})
}
}
function handleRowAccent(row: Row): 'error' | 'warning' | undefined {
if (row.status === 'FAILED') return 'error'
return undefined
}
return (
<div className={styles.content}>
<div className={tableStyles.tableSection}>
<div className={tableStyles.tableHeader}>
<span className={tableStyles.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={tableStyles.tableRight}>
<span className={tableStyles.tableMeta}>
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
</span>
{!textFilter && <Badge label="AUTO" color="success" />}
</div>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={handleRowClick}
selectedId={selectedId}
sortable
flush
fillHeight
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>
)
}