The backend identity rename (applicationName → applicationId, agentId → instanceId) was not reflected in the frontend. This caused drilldown to fail (detail.applicationName was undefined, disabling the diagram fetch) and various display issues. Updated schema.d.ts, ExchangeHeader, ExecutionDiagram, Dashboard, AgentHealth, AgentInstance, LayoutShell, LogTab, InfoTab, DetailPanel, ExchangesPage, and tracing-store. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
163 lines
7.5 KiB
TypeScript
163 lines
7.5 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { GitBranch, Server, RotateCcw } from 'lucide-react';
|
|
import { StatusDot, MonoText, Badge } from '@cameleer/design-system';
|
|
import { useCorrelationChain } from '../../api/queries/correlation';
|
|
import { useAgents } from '../../api/queries/agents';
|
|
import { useAuthStore } from '../../auth/auth-store';
|
|
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
|
|
import { attributeBadgeColor } from '../../utils/attribute-color';
|
|
import { RouteControlBar } from './RouteControlBar';
|
|
import styles from './ExchangeHeader.module.css';
|
|
|
|
interface ExchangeHeaderProps {
|
|
detail: ExecutionDetail;
|
|
onCorrelatedSelect?: (executionId: string, applicationId: string, routeId: string) => void;
|
|
onClearSelection?: () => void;
|
|
}
|
|
|
|
type StatusVariant = 'success' | 'error' | 'running' | 'warning';
|
|
|
|
function statusVariant(s: string): StatusVariant {
|
|
switch (s) {
|
|
case 'COMPLETED': return 'success';
|
|
case 'FAILED': return 'error';
|
|
case 'RUNNING': return 'running';
|
|
default: return 'warning';
|
|
}
|
|
}
|
|
|
|
function statusLabel(s: string): string {
|
|
switch (s) {
|
|
case 'COMPLETED': return 'OK';
|
|
case 'FAILED': return 'ERR';
|
|
case 'RUNNING': return 'RUN';
|
|
default: return s;
|
|
}
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }: ExchangeHeaderProps) {
|
|
const navigate = useNavigate();
|
|
const { data: chainResult } = useCorrelationChain(detail.correlationId ?? null);
|
|
const chain = chainResult?.data;
|
|
const showChain = chain && chain.length > 1;
|
|
const attrs = Object.entries(detail.attributes ?? {});
|
|
|
|
// Look up agent state for icon coloring + route control capability
|
|
const { data: agents } = useAgents(undefined, detail.applicationId);
|
|
const { agentState, hasRouteControl, hasReplay } = useMemo(() => {
|
|
if (!agents) return { agentState: undefined, hasRouteControl: false, hasReplay: false };
|
|
const agentList = agents as any[];
|
|
const agent = detail.instanceId ? agentList.find((a: any) => a.id === detail.instanceId) : undefined;
|
|
return {
|
|
agentState: agent?.state?.toLowerCase() as 'live' | 'stale' | 'dead' | undefined,
|
|
hasRouteControl: agentList.some((a: any) => a.capabilities?.routeControl === true),
|
|
hasReplay: agentList.some((a: any) => a.capabilities?.replay === true),
|
|
};
|
|
}, [agents, detail.instanceId]);
|
|
|
|
const roles = useAuthStore((s) => s.roles);
|
|
const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN');
|
|
|
|
return (
|
|
<div className={styles.header}>
|
|
{/* Exchange info — always shown */}
|
|
<div className={styles.info}>
|
|
<StatusDot variant={statusVariant(detail.status)} />
|
|
<Badge label={statusLabel(detail.status)} color={statusVariant(detail.status)} />
|
|
{attrs.length > 0 && (
|
|
<>
|
|
<span className={styles.separator} />
|
|
{attrs.map(([k, v]) => (
|
|
<Badge key={k} label={`${k}: ${v}`} color={attributeBadgeColor(String(v))} />
|
|
))}
|
|
</>
|
|
)}
|
|
<span className={styles.separator} />
|
|
<button className={styles.linkBtn} onClick={() => { onClearSelection?.(); navigate(`/exchanges/${detail.applicationId}`); }} title="Show all exchanges for this application">
|
|
<span className={styles.app}>{detail.applicationId}</span>
|
|
</button>
|
|
<button className={styles.linkBtn} onClick={() => { onClearSelection?.(); navigate(`/exchanges/${detail.applicationId}/${detail.routeId}`); }} title="Show all exchanges for this route">
|
|
<span className={styles.route}>{detail.routeId}</span>
|
|
<GitBranch size={12} className={styles.icon} />
|
|
</button>
|
|
{detail.instanceId && (
|
|
<>
|
|
<span className={styles.separator} />
|
|
<button className={styles.linkBtn} onClick={() => navigate(`/runtime/${detail.applicationId}`)} title="All agents for this application">
|
|
<span className={styles.app}>{detail.applicationId}</span>
|
|
</button>
|
|
<button className={styles.linkBtn} onClick={() => navigate(`/runtime/${detail.applicationId}/${detail.instanceId}`)} title="Agent details">
|
|
<MonoText size="xs">{detail.instanceId}</MonoText>
|
|
<Server size={12} className={agentState === 'live' ? styles.iconLive : agentState === 'stale' ? styles.iconStale : agentState === 'dead' ? styles.iconDead : styles.icon} />
|
|
</button>
|
|
</>
|
|
)}
|
|
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
|
|
</div>
|
|
|
|
{/* Route control / replay — only if agent supports it AND user has operator+ role */}
|
|
{canControl && (hasRouteControl || hasReplay) && (
|
|
<RouteControlBar
|
|
application={detail.applicationId}
|
|
routeId={detail.routeId}
|
|
hasRouteControl={hasRouteControl}
|
|
hasReplay={hasReplay}
|
|
agentId={detail.instanceId}
|
|
exchangeId={detail.exchangeId}
|
|
inputHeaders={detail.inputHeaders}
|
|
inputBody={detail.inputBody}
|
|
/>
|
|
)}
|
|
|
|
{/* Correlation chain */}
|
|
<div className={styles.chain}>
|
|
<span className={styles.chainLabel}>Correlated</span>
|
|
{showChain ? chain.map((ce: any, i: number) => {
|
|
const isCurrent = ce.executionId === detail.executionId;
|
|
const variant = statusVariant(ce.status);
|
|
const isReplay = !!ce.isReplay;
|
|
const statusCls =
|
|
variant === 'success' ? styles.chainNodeSuccess
|
|
: variant === 'error' ? styles.chainNodeError
|
|
: variant === 'running' ? styles.chainNodeRunning
|
|
: styles.chainNodeWarning;
|
|
return (
|
|
<span key={ce.executionId} className={styles.chainEntry}>
|
|
{i > 0 && <span className={styles.chainArrow}>→</span>}
|
|
<button
|
|
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
|
onClick={() => {
|
|
if (!isCurrent && onCorrelatedSelect) {
|
|
onCorrelatedSelect(ce.executionId, ce.applicationId ?? detail.applicationId, ce.routeId);
|
|
}
|
|
}}
|
|
title={`${ce.executionId}\n${ce.routeId} \u2014 ${formatDuration(ce.durationMs)}${isReplay ? '\n(replay)' : ''}`}
|
|
>
|
|
<StatusDot variant={variant} />
|
|
{isReplay && <RotateCcw size={9} className={styles.replayIcon} />}
|
|
<span className={styles.chainRoute}>{ce.routeId}</span>
|
|
<span className={styles.chainDuration}>{formatDuration(ce.durationMs)}</span>
|
|
</button>
|
|
</span>
|
|
);
|
|
}) : (
|
|
<span className={styles.chainNone}>no correlated exchanges found</span>
|
|
)}
|
|
{showChain && (() => {
|
|
const starts = chain.map((ce: any) => new Date(ce.startTime).getTime()).filter((t: number) => !isNaN(t));
|
|
const ends = chain.map((ce: any) => new Date(ce.endTime ?? ce.startTime).getTime() + (ce.durationMs ?? 0)).filter((t: number) => !isNaN(t));
|
|
const totalMs = starts.length > 0 && ends.length > 0 ? Math.max(...ends) - Math.min(...starts) : 0;
|
|
return totalMs > 0 ? <span className={styles.chainTotal}>{formatDuration(totalMs)}</span> : null;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|