chore(ui): remove dead code from navigation redesign
Deleted: - ScopeTrail component (replaced by inline breadcrumb in TopBar) - ExchangeList component (replaced by Dashboard DataTable) - ExchangeDetail page (replaced by inline split view) Removed from Dashboard: - flattenProcessors() function (unused after detail panel removal) - 11 dead CSS classes (panelSection, overviewGrid, errorBlock, inspectLink, openDetailLink, filterBar, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
.trail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--amber);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 0.375rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { Scope } from '../hooks/useScope';
|
||||
import styles from './ScopeTrail.module.css';
|
||||
|
||||
interface ScopeTrailProps {
|
||||
scope: Scope;
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
export function ScopeTrail({ scope, onNavigate }: ScopeTrailProps) {
|
||||
const segments: { label: string; path: string }[] = [
|
||||
{ label: 'All Applications', path: `/${scope.tab}` },
|
||||
];
|
||||
|
||||
if (scope.appId) {
|
||||
segments.push({ label: scope.appId, path: `/${scope.tab}/${scope.appId}` });
|
||||
}
|
||||
|
||||
if (scope.routeId) {
|
||||
segments.push({ label: scope.routeId, path: `/${scope.tab}/${scope.appId}/${scope.routeId}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={styles.trail}>
|
||||
{segments.map((seg, i) => (
|
||||
<span key={seg.path} className={styles.segment}>
|
||||
{i > 0 && <span className={styles.separator}>></span>}
|
||||
{i < segments.length - 1 ? (
|
||||
<button className={styles.link} onClick={() => onNavigate(seg.path)}>
|
||||
{seg.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.current}>{seg.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,6 @@
|
||||
/* Table section — stretches to fill and scrolls internally */
|
||||
|
||||
|
||||
/* Filter bar spacing */
|
||||
.filterBar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tableSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -166,112 +161,6 @@
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Detail panel sections */
|
||||
.panelSection {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.panelSection:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.panelSectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panelSectionMeta {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Overview grid */
|
||||
.overviewGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overviewRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overviewLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Error block */
|
||||
.errorBlock {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.errorClass {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--error);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Inspect exchange icon in table */
|
||||
.inspectLink {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.inspectLink:hover {
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Attributes cell in table */
|
||||
.attrCell {
|
||||
display: flex;
|
||||
@@ -288,20 +177,3 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Open full details link in panel */
|
||||
.openDetailLink {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.openDetailLink:hover {
|
||||
color: var(--amber-deep);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -66,24 +66,6 @@ function durationClass(ms: number, status: string): string {
|
||||
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>[] {
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EXCHANGE HEADER CARD
|
||||
========================================================================== */
|
||||
.exchangeHeader {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exchangeId {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.exchangeRoute {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.routeLink {
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.routeLink:hover {
|
||||
color: var(--amber-deep);
|
||||
}
|
||||
|
||||
.headerDivider {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerStat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerStatLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.headerStatValue {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
CORRELATION CHAIN
|
||||
========================================================================== */
|
||||
.correlationChain {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chainLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chainNode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.chainNode:hover {
|
||||
border-color: var(--text-faint);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chainNodeCurrent {
|
||||
background: var(--amber-bg);
|
||||
border-color: var(--amber-light);
|
||||
color: var(--amber-deep);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chainNodeSuccess {
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.chainNodeError {
|
||||
border-left: 3px solid var(--error);
|
||||
}
|
||||
|
||||
.chainNodeRunning {
|
||||
border-left: 3px solid var(--running);
|
||||
}
|
||||
|
||||
.chainNodeWarning {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.chainMore {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
ATTRIBUTES STRIP
|
||||
========================================================================== */
|
||||
.attributesStrip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.attributesLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
TIMELINE SECTION
|
||||
========================================================================== */
|
||||
.timelineSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timelineHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.timelineTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.procCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.timelineToggle {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggleBtn {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-body);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.toggleBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.toggleBtnActive {
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggleBtnActive:hover {
|
||||
background: var(--amber-deep);
|
||||
}
|
||||
|
||||
.timelineBody {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EXECUTION DIAGRAM CONTAINER (Flow view)
|
||||
========================================================================== */
|
||||
.executionDiagramContainer {
|
||||
height: 600px;
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
DETAIL SPLIT (IN / OUT panels)
|
||||
========================================================================== */
|
||||
.detailSplit {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detailPanel {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailPanelError {
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-raised);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detailPanelError .panelHeader {
|
||||
background: var(--error-bg);
|
||||
border-bottom-color: var(--error-border);
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.arrowIn {
|
||||
color: var(--success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.arrowOut {
|
||||
color: var(--running);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.arrowError {
|
||||
color: var(--error);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panelTag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panelBody {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Headers section */
|
||||
.headersSection {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.headerList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.headerKvRow {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.headerKvRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.headerKey {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headerValue {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Body section */
|
||||
.bodySection {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Error panel styles */
|
||||
.errorBadgeRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.errorHttpBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.errorMessageBox {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--error-bg);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--error-border);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.errorDetailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.errorDetailLabel {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.errorDetailValue {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Snapshot loading */
|
||||
.snapshotLoading {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Exchange log section */
|
||||
.logSection {
|
||||
margin-top: 20px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.logSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.logMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.logToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.logSearchWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logSearchInput {
|
||||
width: 100%;
|
||||
padding: 5px 28px 5px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.logSearchInput:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.logSearchInput::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.logSearchClear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logClearFilters {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logClearFilters:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logEmpty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
REPLAY MODAL
|
||||
========================================================================== */
|
||||
.replayWarning {
|
||||
background: var(--warning-bg, #3d3520);
|
||||
border: 1px solid var(--warning-border, #6b5c2a);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--warning, #e6b84f);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.replayAgentRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.replayFieldLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.replayHeadersTable {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.replayHeadersHead {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 28px;
|
||||
gap: 8px;
|
||||
padding-bottom: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.replayHeaderRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 28px;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.replayRemoveBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.replayRemoveBtn:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.replayAddHeader {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.replayAddHeader:hover {
|
||||
color: var(--amber-deep);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.replayBodyArea {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.replayBodyTextarea {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.replayFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
@@ -1,819 +0,0 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||
ProcessorTimeline, Spinner, useToast,
|
||||
LogViewer, ButtonGroup, SectionHeader, useBreadcrumb,
|
||||
Modal, Tabs, Button, Select, Input, Textarea,
|
||||
useGlobalFilters,
|
||||
} from '@cameleer/design-system'
|
||||
import type { ProcessorStep, NodeBadge, LogEntry, ButtonGroupItem } from '@cameleer/design-system'
|
||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
||||
import { useCorrelationChain } from '../../api/queries/correlation'
|
||||
import { useTracingStore } from '../../stores/tracing-store'
|
||||
import { useApplicationConfig, useUpdateApplicationConfig, useReplayExchange } from '../../api/queries/commands'
|
||||
import { useAgents } from '../../api/queries/agents'
|
||||
import { useApplicationLogs } from '../../api/queries/logs'
|
||||
import { useRouteCatalog } from '../../api/queries/catalog'
|
||||
import { ExecutionDiagram } from '../../components/ExecutionDiagram'
|
||||
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types'
|
||||
import styles from './ExchangeDetail.module.css'
|
||||
|
||||
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'error', label: 'Error', color: 'var(--error)' },
|
||||
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
|
||||
{ value: 'info', label: 'Info', color: 'var(--success)' },
|
||||
{ value: 'debug', label: 'Debug', color: 'var(--running)' },
|
||||
{ value: 'trace', label: 'Trace', color: 'var(--text-muted)' },
|
||||
]
|
||||
|
||||
function mapLogLevel(level: string): LogEntry['level'] {
|
||||
switch (level?.toUpperCase()) {
|
||||
case 'ERROR': return 'error'
|
||||
case 'WARN': case 'WARNING': return 'warn'
|
||||
case 'DEBUG': return 'debug'
|
||||
case 'TRACE': return 'trace'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 backendStatusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED': return 'success'
|
||||
case 'FAILED': return 'error'
|
||||
case 'RUNNING': return 'running'
|
||||
default: return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
function backendStatusToLabel(status: string): string {
|
||||
return status.toUpperCase()
|
||||
}
|
||||
|
||||
function procStatusToStep(status: string): 'ok' | 'slow' | 'fail' {
|
||||
const s = status.toUpperCase()
|
||||
if (s === 'FAILED') return 'fail'
|
||||
if (s === 'RUNNING') return 'slow'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function parseHeaders(raw: string | undefined | null): Record<string, string> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
result[k] = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function countProcessors(nodes: Array<{ children?: any[] }>): number {
|
||||
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0)
|
||||
}
|
||||
|
||||
// ── ExchangeDetail ───────────────────────────────────────────────────────────
|
||||
export default function ExchangeDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: detail, isLoading } = useExecutionDetail(id ?? null)
|
||||
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null)
|
||||
|
||||
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
||||
const [logSearch, setLogSearch] = useState('')
|
||||
const [logLevels, setLogLevels] = useState<Set<string>>(new Set())
|
||||
|
||||
// Replay modal state
|
||||
const [replayOpen, setReplayOpen] = useState(false)
|
||||
const [replayHeaders, setReplayHeaders] = useState<Array<{ key: string; value: string }>>([])
|
||||
const [replayBody, setReplayBody] = useState('')
|
||||
const [replayAgent, setReplayAgent] = useState('')
|
||||
const [replayTab, setReplayTab] = useState('headers')
|
||||
|
||||
const procList = detail
|
||||
? (detail.processors ?? [])
|
||||
: []
|
||||
|
||||
// Subscribe to tracing state for badge rendering
|
||||
const tracedMap = useTracingStore((s) => s.tracedProcessors[detail?.applicationName ?? ''])
|
||||
|
||||
function badgesFor(processorId: string): NodeBadge[] | undefined {
|
||||
if (!tracedMap || !(processorId in tracedMap)) return undefined
|
||||
return [{ label: 'Traced', variant: 'info' }]
|
||||
}
|
||||
|
||||
// Flatten processor tree into ProcessorStep[]
|
||||
const processors: ProcessorStep[] = useMemo(() => {
|
||||
if (!procList.length) return []
|
||||
const result: ProcessorStep[] = []
|
||||
let offset = 0
|
||||
function walk(node: any) {
|
||||
const pid = node.processorId || node.processorType
|
||||
result.push({
|
||||
name: pid,
|
||||
type: node.processorType,
|
||||
durationMs: node.durationMs ?? 0,
|
||||
status: procStatusToStep(node.status ?? ''),
|
||||
startMs: offset,
|
||||
badges: badgesFor(node.processorId || ''),
|
||||
})
|
||||
offset += node.durationMs ?? 0
|
||||
if (node.children) node.children.forEach(walk)
|
||||
}
|
||||
procList.forEach(walk)
|
||||
return result
|
||||
}, [procList, tracedMap])
|
||||
|
||||
// Flatten processor tree into raw node objects (for attribute access)
|
||||
const flatProcNodes = useMemo(() => {
|
||||
const nodes: any[] = []
|
||||
function walk(node: any) {
|
||||
nodes.push(node)
|
||||
if (node.children) node.children.forEach(walk)
|
||||
}
|
||||
procList.forEach(walk)
|
||||
return nodes
|
||||
}, [procList])
|
||||
|
||||
// Default selected processor: first failed, or 0
|
||||
const defaultIndex = useMemo(() => {
|
||||
if (!processors.length) return 0
|
||||
const failIdx = processors.findIndex((p) => p.status === 'fail')
|
||||
return failIdx >= 0 ? failIdx : 0
|
||||
}, [processors])
|
||||
|
||||
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
|
||||
const activeIndex = selectedProcessorIndex ?? defaultIndex
|
||||
|
||||
const { data: snapshot } = useProcessorSnapshot(
|
||||
id ?? null,
|
||||
procList.length > 0 ? activeIndex : null,
|
||||
)
|
||||
|
||||
const selectedProc = processors[activeIndex]
|
||||
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||
|
||||
// Parse snapshot data
|
||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders)
|
||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders)
|
||||
const inputBody = snapshot?.inputBody ?? null
|
||||
const outputBody = snapshot?.outputBody ?? null
|
||||
|
||||
// ProcessorId lookup: timeline index → processorId
|
||||
const processorIds: string[] = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
function walk(node: any) {
|
||||
ids.push(node.processorId || '')
|
||||
if (node.children) node.children.forEach(walk)
|
||||
}
|
||||
procList.forEach(walk)
|
||||
return ids
|
||||
}, [procList])
|
||||
|
||||
|
||||
// ── Tracing toggle ──────────────────────────────────────────────────────
|
||||
const { toast } = useToast()
|
||||
const tracingStore = useTracingStore()
|
||||
const app = detail?.applicationName ?? ''
|
||||
const { data: appConfig } = useApplicationConfig(app || undefined)
|
||||
const updateConfig = useUpdateApplicationConfig()
|
||||
|
||||
// Sync tracing store with server config
|
||||
useEffect(() => {
|
||||
if (appConfig?.tracedProcessors && app) {
|
||||
tracingStore.syncFromServer(app, appConfig.tracedProcessors)
|
||||
}
|
||||
}, [appConfig, app])
|
||||
|
||||
const handleToggleTracing = useCallback((processorId: string) => {
|
||||
if (!processorId || !detail?.applicationName || !appConfig) return
|
||||
const newMap = tracingStore.toggleProcessor(app, processorId)
|
||||
const updatedConfig = {
|
||||
...appConfig,
|
||||
tracedProcessors: { ...newMap },
|
||||
}
|
||||
updateConfig.mutate(updatedConfig, {
|
||||
onSuccess: (saved) => {
|
||||
const action = processorId in newMap ? 'enabled' : 'disabled'
|
||||
toast({ title: `Tracing ${action}`, description: `${processorId} — config v${saved.version}`, variant: 'success' })
|
||||
},
|
||||
onError: () => {
|
||||
tracingStore.toggleProcessor(app, processorId)
|
||||
toast({ title: 'Config update failed', description: 'Could not save configuration', variant: 'error' })
|
||||
},
|
||||
})
|
||||
}, [detail, app, appConfig, tracingStore, updateConfig, toast])
|
||||
|
||||
// ── ExecutionDiagram support ──────────────────────────────────────────
|
||||
const { timeRange } = useGlobalFilters()
|
||||
const { data: catalog } = useRouteCatalog(
|
||||
timeRange.start.toISOString(),
|
||||
timeRange.end.toISOString(),
|
||||
)
|
||||
|
||||
const knownRouteIds = useMemo(() => {
|
||||
if (!catalog || !app) return new Set<string>()
|
||||
const appEntry = (catalog as Array<{ appId: string; routes?: Array<{ routeId: string }> }>)
|
||||
.find(a => a.appId === app)
|
||||
return new Set((appEntry?.routes ?? []).map(r => r.routeId))
|
||||
}, [catalog, app])
|
||||
|
||||
const nodeConfigs = useMemo(() => {
|
||||
const map = new Map<string, NodeConfig>()
|
||||
if (tracedMap) {
|
||||
for (const pid of Object.keys(tracedMap)) {
|
||||
map.set(pid, { traceEnabled: true })
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [tracedMap])
|
||||
|
||||
const handleNodeAction = useCallback((nodeId: string, action: NodeAction) => {
|
||||
if (action === 'toggle-trace') {
|
||||
handleToggleTracing(nodeId)
|
||||
} else if (action === 'configure-tap' && detail?.applicationName) {
|
||||
navigate(`/admin/appconfig?app=${encodeURIComponent(detail.applicationName)}&processor=${encodeURIComponent(nodeId)}`)
|
||||
}
|
||||
}, [handleToggleTracing, detail?.applicationName, navigate])
|
||||
|
||||
// ── Replay ─────────────────────────────────────────────────────────────
|
||||
const { data: liveAgents } = useAgents('LIVE', detail?.applicationName)
|
||||
const replay = useReplayExchange()
|
||||
|
||||
// Pre-populate replay form when modal opens
|
||||
useEffect(() => {
|
||||
if (!replayOpen || !detail) return
|
||||
try {
|
||||
const parsed = JSON.parse(detail.inputHeaders ?? '{}')
|
||||
const entries = Object.entries(parsed).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: typeof v === 'string' ? v : JSON.stringify(v),
|
||||
}))
|
||||
setReplayHeaders(entries.length > 0 ? entries : [{ key: '', value: '' }])
|
||||
} catch {
|
||||
setReplayHeaders([{ key: '', value: '' }])
|
||||
}
|
||||
setReplayBody(detail.inputBody ?? '')
|
||||
// Default to current agent if it is live
|
||||
const agentIds = (liveAgents ?? []).map((a: any) => a.id)
|
||||
setReplayAgent(agentIds.includes(detail.agentId) ? detail.agentId : (agentIds[0] ?? ''))
|
||||
setReplayTab('headers')
|
||||
}, [replayOpen])
|
||||
|
||||
function handleReplay() {
|
||||
const headers: Record<string, string> = {}
|
||||
replayHeaders.forEach((h) => { if (h.key) headers[h.key] = h.value })
|
||||
replay.mutate(
|
||||
{ agentId: replayAgent, headers, body: replayBody },
|
||||
{
|
||||
onSuccess: () => { toast({ title: 'Replay command sent', variant: 'success' }); setReplayOpen(false) },
|
||||
onError: (err) => { toast({ title: `Replay failed: ${err.message}`, variant: 'error' }) },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Correlation chain
|
||||
const correlatedExchanges = useMemo(() => {
|
||||
if (!correlationData?.data || correlationData.data.length <= 1) return []
|
||||
return correlationData.data
|
||||
}, [correlationData])
|
||||
|
||||
// Set semantic breadcrumb in TopBar when detail is loaded
|
||||
const breadcrumbItems = useMemo(() => detail ? [
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
|
||||
{ label: detail.routeId, href: `/apps/${detail.applicationName}/${detail.routeId}` },
|
||||
{ label: detail.executionId || '' },
|
||||
] : null, [detail?.applicationName, detail?.routeId, detail?.executionId])
|
||||
useBreadcrumb(breadcrumbItems)
|
||||
|
||||
// Exchange logs from OpenSearch (filtered by exchangeId via MDC)
|
||||
const { data: rawLogs } = useApplicationLogs(
|
||||
detail?.applicationName,
|
||||
undefined,
|
||||
{ exchangeId: detail?.exchangeId ?? undefined },
|
||||
)
|
||||
const logEntries = useMemo<LogEntry[]>(
|
||||
() => (rawLogs || []).map((l) => ({
|
||||
timestamp: l.timestamp ?? '',
|
||||
level: mapLogLevel(l.level),
|
||||
message: l.message ?? '',
|
||||
})),
|
||||
[rawLogs],
|
||||
)
|
||||
const logSearchLower = logSearch.toLowerCase()
|
||||
const filteredLogs = logEntries
|
||||
.filter((l) => logLevels.size === 0 || logLevels.has(l.level))
|
||||
.filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower))
|
||||
|
||||
// ── Loading state ────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Not found state ──────────────────────────────────────────────────────
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<InfoCallout variant="warning">Exchange "{id}" not found.</InfoCallout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusVariant = backendStatusToVariant(detail.status)
|
||||
const statusLabel = backendStatusToLabel(detail.status)
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
|
||||
{/* Exchange header card */}
|
||||
<div className={styles.exchangeHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.headerLeft}>
|
||||
<StatusDot variant={statusVariant} />
|
||||
<div>
|
||||
<div className={styles.exchangeId}>
|
||||
<MonoText size="md">{detail.executionId}</MonoText>
|
||||
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||
</div>
|
||||
<div className={styles.exchangeRoute}>
|
||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
|
||||
{detail.applicationName && (
|
||||
<>
|
||||
<span className={styles.headerDivider}>·</span>
|
||||
App: <MonoText size="xs">{detail.applicationName}</MonoText>
|
||||
</>
|
||||
)}
|
||||
{detail.correlationId && (
|
||||
<>
|
||||
<span className={styles.headerDivider}>·</span>
|
||||
Correlation: <MonoText size="xs">{detail.correlationId}</MonoText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Duration</div>
|
||||
<div className={styles.headerStatValue}>{formatDuration(detail.durationMs)}</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Agent</div>
|
||||
<div className={styles.headerStatValue}>{detail.agentId}</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Started</div>
|
||||
<div className={styles.headerStatValue}>
|
||||
{detail.startTime
|
||||
? new Date(detail.startTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Processors</div>
|
||||
<div className={styles.headerStatValue}>{countProcessors(procList)}</div>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={() => setReplayOpen(true)}>
|
||||
↻ Replay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route-level Attributes */}
|
||||
{detail.attributes && Object.keys(detail.attributes).length > 0 && (
|
||||
<div className={styles.attributesStrip}>
|
||||
<span className={styles.attributesLabel}>Attributes</span>
|
||||
{Object.entries(detail.attributes).map(([key, value]) => (
|
||||
<Badge key={key} label={`${key}: ${value}`} color="auto" variant="filled" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Correlation Chain */}
|
||||
{correlatedExchanges.length > 1 && (
|
||||
<div className={styles.correlationChain}>
|
||||
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
||||
{correlatedExchanges.map((ce) => {
|
||||
const isCurrent = ce.executionId === id
|
||||
const variant = backendStatusToVariant(ce.status)
|
||||
const statusCls =
|
||||
variant === 'success' ? styles.chainNodeSuccess
|
||||
: variant === 'error' ? styles.chainNodeError
|
||||
: variant === 'running' ? styles.chainNodeRunning
|
||||
: styles.chainNodeWarning
|
||||
return (
|
||||
<button
|
||||
key={ce.executionId}
|
||||
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
|
||||
}}
|
||||
title={`${ce.executionId} \u2014 ${ce.routeId}`}
|
||||
>
|
||||
<StatusDot variant={variant} />
|
||||
<span>{ce.routeId}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{correlationData && correlationData.total > 20 && (
|
||||
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processor Timeline Section */}
|
||||
<div className={styles.timelineSection}>
|
||||
<div className={styles.timelineHeader}>
|
||||
<span className={styles.timelineTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.procCount}>{processors.length} processors</span>
|
||||
</span>
|
||||
<div className={styles.timelineToggle}>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
|
||||
onClick={() => setTimelineView('gantt')}
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
|
||||
onClick={() => setTimelineView('flow')}
|
||||
>
|
||||
Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{timelineView === 'gantt' && (
|
||||
<div className={styles.timelineBody}>
|
||||
{processors.length > 0 ? (
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={detail.durationMs}
|
||||
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
|
||||
selectedIndex={activeIndex}
|
||||
getActions={(_proc, index) => {
|
||||
const pid = processorIds[index]
|
||||
if (!pid || !detail?.applicationName) return []
|
||||
return [{
|
||||
label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing',
|
||||
onClick: () => handleToggleTracing(pid),
|
||||
disabled: updateConfig.isPending,
|
||||
}]
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InfoCallout>No processor data available</InfoCallout>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{timelineView === 'flow' && detail && (
|
||||
<div className={styles.executionDiagramContainer}>
|
||||
<ExecutionDiagram
|
||||
executionId={id!}
|
||||
executionDetail={detail}
|
||||
knownRouteIds={knownRouteIds}
|
||||
onNodeAction={handleNodeAction}
|
||||
nodeConfigs={nodeConfigs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange-level body (start/end of route) */}
|
||||
{detail && (detail.inputBody || detail.outputBody) && (
|
||||
<div className={styles.detailSplit}>
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowIn}>→</span> Exchange Input
|
||||
</span>
|
||||
<span className={styles.panelTag}>at route entry</span>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
{detail.inputHeaders && (
|
||||
<div className={styles.headersSection}>
|
||||
<div className={styles.sectionLabel}>Headers</div>
|
||||
<div className={styles.headerList}>
|
||||
{Object.entries(parseHeaders(detail.inputHeaders)).map(([key, value]) => (
|
||||
<div key={key} className={styles.headerKvRow}>
|
||||
<span className={styles.headerKey}>{key}</span>
|
||||
<span className={styles.headerValue}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={detail.inputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowOut}>←</span> Exchange Output
|
||||
</span>
|
||||
<span className={styles.panelTag}>at route exit</span>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
{detail.outputHeaders && (
|
||||
<div className={styles.headersSection}>
|
||||
<div className={styles.sectionLabel}>Headers</div>
|
||||
<div className={styles.headerList}>
|
||||
{Object.entries(parseHeaders(detail.outputHeaders)).map(([key, value]) => (
|
||||
<div key={key} className={styles.headerKvRow}>
|
||||
<span className={styles.headerKey}>{key}</span>
|
||||
<span className={styles.headerValue}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={detail.outputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processor Attributes */}
|
||||
{selectedProc && (() => {
|
||||
const procNode = flatProcNodes[activeIndex]
|
||||
return procNode?.attributes && Object.keys(procNode.attributes).length > 0 ? (
|
||||
<div className={styles.attributesStrip}>
|
||||
<span className={styles.attributesLabel}>Processor Attributes</span>
|
||||
{Object.entries(procNode.attributes).map(([key, value]) => (
|
||||
<Badge key={key} label={`${key}: ${value}`} color="auto" variant="filled" />
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* Processor Detail Panel (split IN / OUT) */}
|
||||
{selectedProc && snapshot && (
|
||||
<div className={styles.detailSplit}>
|
||||
{/* Message IN */}
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowIn}>→</span> Message IN
|
||||
</span>
|
||||
<span className={styles.panelTag}>at processor #{activeIndex + 1} entry</span>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
{Object.keys(inputHeaders).length > 0 && (
|
||||
<div className={styles.headersSection}>
|
||||
<div className={styles.sectionLabel}>
|
||||
Headers <span className={styles.count}>{Object.keys(inputHeaders).length}</span>
|
||||
</div>
|
||||
<div className={styles.headerList}>
|
||||
{Object.entries(inputHeaders).map(([key, value]) => (
|
||||
<div key={key} className={styles.headerKvRow}>
|
||||
<span className={styles.headerKey}>{key}</span>
|
||||
<span className={styles.headerValue}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message OUT or Error */}
|
||||
{isSelectedFailed ? (
|
||||
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowError}>×</span> Error at Processor #{activeIndex + 1}
|
||||
</span>
|
||||
<Badge label="FAILED" color="error" variant="filled" />
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
{detail.errorMessage && (
|
||||
<div className={styles.errorMessageBox}>{detail.errorMessage}</div>
|
||||
)}
|
||||
<div className={styles.errorDetailGrid}>
|
||||
<span className={styles.errorDetailLabel}>Processor</span>
|
||||
<span className={styles.errorDetailValue}>{selectedProc.name}</span>
|
||||
<span className={styles.errorDetailLabel}>Duration</span>
|
||||
<span className={styles.errorDetailValue}>{formatDuration(selectedProc.durationMs)}</span>
|
||||
<span className={styles.errorDetailLabel}>Status</span>
|
||||
<span className={styles.errorDetailValue}>{selectedProc.status.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowOut}>←</span> Message OUT
|
||||
</span>
|
||||
<span className={styles.panelTag}>after processor #{activeIndex + 1}</span>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
{Object.keys(outputHeaders).length > 0 && (
|
||||
<div className={styles.headersSection}>
|
||||
<div className={styles.sectionLabel}>
|
||||
Headers <span className={styles.count}>{Object.keys(outputHeaders).length}</span>
|
||||
</div>
|
||||
<div className={styles.headerList}>
|
||||
{Object.entries(outputHeaders).map(([key, value]) => (
|
||||
<div key={key} className={styles.headerKvRow}>
|
||||
<span className={styles.headerKey}>{key}</span>
|
||||
<span className={styles.headerValue}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshot loading indicator */}
|
||||
{selectedProc && !snapshot && procList.length > 0 && (
|
||||
<div className={styles.snapshotLoading}>
|
||||
Loading exchange snapshot...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange Application Log */}
|
||||
{detail && (
|
||||
<div className={styles.logSection}>
|
||||
<div className={styles.logSectionHeader}>
|
||||
<SectionHeader>Application Log</SectionHeader>
|
||||
<span className={styles.logMeta}>{logEntries.length} entries</span>
|
||||
</div>
|
||||
<div className={styles.logToolbar}>
|
||||
<div className={styles.logSearchWrap}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.logSearchInput}
|
||||
placeholder="Search logs\u2026"
|
||||
value={logSearch}
|
||||
onChange={(e) => setLogSearch(e.target.value)}
|
||||
aria-label="Search logs"
|
||||
/>
|
||||
{logSearch && (
|
||||
<button type="button" className={styles.logSearchClear} onClick={() => setLogSearch('')} aria-label="Clear search">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
|
||||
{logLevels.size > 0 && (
|
||||
<button className={styles.logClearFilters} onClick={() => setLogLevels(new Set())}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredLogs.length > 0 ? (
|
||||
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>
|
||||
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No logs captured for this exchange'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replay Modal */}
|
||||
<Modal open={replayOpen} onClose={() => setReplayOpen(false)} title="Replay Exchange" size="lg">
|
||||
<div className={styles.replayWarning}>
|
||||
This will re-send the exchange to a live agent. The agent will process
|
||||
it as a new exchange. Use with caution in production environments.
|
||||
</div>
|
||||
|
||||
<div className={styles.replayAgentRow}>
|
||||
<span className={styles.replayFieldLabel}>Target Agent</span>
|
||||
<Select
|
||||
value={replayAgent}
|
||||
onChange={(e) => setReplayAgent(e.target.value)}
|
||||
options={(liveAgents ?? []).map((a: any) => ({
|
||||
value: a.id,
|
||||
label: a.name || a.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: 'Headers', value: 'headers', count: replayHeaders.filter((h) => h.key).length },
|
||||
{ label: 'Body', value: 'body' },
|
||||
]}
|
||||
active={replayTab}
|
||||
onChange={setReplayTab}
|
||||
/>
|
||||
|
||||
{replayTab === 'headers' && (
|
||||
<div className={styles.replayHeadersTable}>
|
||||
<div className={styles.replayHeadersHead}>
|
||||
<span>Key</span>
|
||||
<span>Value</span>
|
||||
<span />
|
||||
</div>
|
||||
{replayHeaders.map((h, i) => (
|
||||
<div key={i} className={styles.replayHeaderRow}>
|
||||
<Input
|
||||
value={h.key}
|
||||
placeholder="Header name"
|
||||
onChange={(e) => {
|
||||
const next = [...replayHeaders]
|
||||
next[i] = { ...next[i], key: e.target.value }
|
||||
setReplayHeaders(next)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={h.value}
|
||||
placeholder="Value"
|
||||
onChange={(e) => {
|
||||
const next = [...replayHeaders]
|
||||
next[i] = { ...next[i], value: e.target.value }
|
||||
setReplayHeaders(next)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.replayRemoveBtn}
|
||||
onClick={() => {
|
||||
const next = replayHeaders.filter((_, j) => j !== i)
|
||||
setReplayHeaders(next.length > 0 ? next : [{ key: '', value: '' }])
|
||||
}}
|
||||
aria-label="Remove header"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.replayAddHeader}
|
||||
onClick={() => setReplayHeaders([...replayHeaders, { key: '', value: '' }])}
|
||||
>
|
||||
+ Add header
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replayTab === 'body' && (
|
||||
<div className={styles.replayBodyArea}>
|
||||
<Textarea
|
||||
className={styles.replayBodyTextarea}
|
||||
value={replayBody}
|
||||
onChange={(e) => setReplayBody(e.target.value)}
|
||||
rows={12}
|
||||
resize="vertical"
|
||||
placeholder="Request body (JSON, XML, text, etc.)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.replayFooter}>
|
||||
<Button variant="secondary" size="sm" onClick={() => setReplayOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleReplay}
|
||||
loading={replay.isPending}
|
||||
disabled={!replayAgent}
|
||||
>
|
||||
↻ Replay
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 0.8125rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.itemSelected {
|
||||
background: var(--surface-active);
|
||||
border-left: 3px solid var(--amber);
|
||||
padding-left: calc(0.75rem - 3px);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotOk { background: var(--success); }
|
||||
.dotErr { background: var(--error); }
|
||||
.dotRun { background: var(--running); }
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exchangeId {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import styles from './ExchangeList.module.css';
|
||||
|
||||
interface ExchangeListProps {
|
||||
exchanges: ExecutionSummary[];
|
||||
selectedId?: string;
|
||||
onSelect: (exchange: ExecutionSummary) => void;
|
||||
}
|
||||
|
||||
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 formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
function dotClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return styles.dotOk;
|
||||
case 'FAILED': return styles.dotErr;
|
||||
case 'RUNNING': return styles.dotRun;
|
||||
default: return styles.dotOk;
|
||||
}
|
||||
}
|
||||
|
||||
export function ExchangeList({ exchanges, selectedId, onSelect }: ExchangeListProps) {
|
||||
if (exchanges.length === 0) {
|
||||
return <div className={styles.empty}>No exchanges found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{exchanges.map((ex) => (
|
||||
<div
|
||||
key={ex.executionId}
|
||||
className={`${styles.item} ${selectedId === ex.executionId ? styles.itemSelected : ''}`}
|
||||
onClick={() => onSelect(ex)}
|
||||
>
|
||||
<span className={`${styles.dot} ${dotClass(ex.status)}`} />
|
||||
<div className={styles.meta}>
|
||||
<div className={styles.exchangeId}>{ex.executionId.slice(0, 12)}</div>
|
||||
</div>
|
||||
<span className={styles.duration}>{formatDuration(ex.durationMs)}</span>
|
||||
<span className={styles.timestamp}>{formatTime(ex.startTime)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user