Adds JSON-based tracing system for local development that captures
the full request flow through Tatlock's multi-agent architecture.
- Trace/Span dataclasses with automatic timing and nesting
- Context-var based propagation for async-safe tracing
- trace_span async context manager for clean instrumentation
- Traces written to logs/traces/{trace_id}.json
- REST API for listing and retrieving traces (/traces)
- Standalone HTML viewer with timeline visualization
Enabled via DEBUG=true environment variable.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1214 lines
39 KiB
HTML
1214 lines
39 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tatlock Trace Viewer</title>
|
|
<style>
|
|
/* Twilight Color Palette */
|
|
:root {
|
|
--bg-primary: #1a1b26;
|
|
--bg-secondary: #24283b;
|
|
--bg-tertiary: #414868;
|
|
--bg-hover: #343b58;
|
|
--text-primary: #c0caf5;
|
|
--text-secondary: #9aa5ce;
|
|
--text-muted: #565f89;
|
|
--accent-blue: #7aa2f7;
|
|
--accent-green: #9ece6a;
|
|
--accent-orange: #ff9e64;
|
|
--accent-purple: #bb9af7;
|
|
--accent-gray: #565f89;
|
|
--accent-red: #f7768e;
|
|
--accent-cyan: #7dcfff;
|
|
--border: #3b4261;
|
|
--shadow: rgba(0, 0, 0, 0.3);
|
|
--glass: rgba(36, 40, 59, 0.8);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
min-height: 100vh;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Header */
|
|
header {
|
|
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 1rem 2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2rem;
|
|
box-shadow: 0 4px 20px var(--shadow);
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
font-size: 0.875rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.btn:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--accent-blue);
|
|
box-shadow: 0 0 15px rgba(122, 162, 247, 0.2);
|
|
}
|
|
|
|
.btn.primary {
|
|
background: var(--accent-blue);
|
|
border-color: var(--accent-blue);
|
|
}
|
|
|
|
.btn.primary:hover {
|
|
background: #6b93e0;
|
|
}
|
|
|
|
.file-input-wrapper input[type="file"] {
|
|
display: none;
|
|
}
|
|
|
|
#trace-info {
|
|
margin-left: auto;
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Main Layout */
|
|
main {
|
|
display: grid;
|
|
grid-template-columns: 280px 1fr 400px;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
height: calc(100vh - 80px);
|
|
}
|
|
|
|
/* Trace List Panel */
|
|
#trace-list-panel {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.panel-header {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.panel-header h2 {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.trace-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.trace-item {
|
|
padding: 0.75rem;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
margin-bottom: 0.25rem;
|
|
transition: all 0.2s ease;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
.trace-item:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.trace-item.selected {
|
|
background: var(--bg-tertiary);
|
|
border-color: var(--accent-blue);
|
|
}
|
|
|
|
.trace-item-time {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.trace-item-preview {
|
|
font-size: 0.8125rem;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.trace-item-meta {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 0.375rem;
|
|
font-size: 0.6875rem;
|
|
}
|
|
|
|
.trace-item-tag {
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 4px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.trace-item-tag.completed { color: var(--accent-green); }
|
|
.trace-item-tag.error { color: var(--accent-red); }
|
|
.trace-item-tag.streaming { color: var(--accent-cyan); }
|
|
|
|
/* Timeline Section */
|
|
#timeline {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.timeline-header {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.timeline-header h2 {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.timeline-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
}
|
|
|
|
/* Span Row - Gantt Layout */
|
|
.span-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.375rem 0.75rem;
|
|
margin-bottom: 0.125rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.span-row:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.span-row.selected {
|
|
background: var(--bg-tertiary);
|
|
box-shadow: 0 0 0 1px var(--accent-blue);
|
|
}
|
|
|
|
.span-bar-row {
|
|
display: flex;
|
|
align-items: center;
|
|
width: 100%;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.span-indent {
|
|
width: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Duration Bar (Gantt-style - full width) */
|
|
.span-bar-wrapper {
|
|
flex: 1;
|
|
height: 12px;
|
|
background: var(--bg-primary);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.span-bar {
|
|
height: 100%;
|
|
border-radius: 4px;
|
|
transition: left 0.3s ease, width 0.3s ease;
|
|
position: absolute;
|
|
top: 0;
|
|
min-width: 4px;
|
|
}
|
|
|
|
.span-bar.router { background: var(--accent-gray); }
|
|
.span-bar.steward { background: var(--accent-blue); }
|
|
.span-bar.tatlock { background: var(--accent-green); }
|
|
.span-bar.expert { background: var(--accent-orange); }
|
|
.span-bar.tool { background: var(--accent-purple); }
|
|
|
|
.span-duration {
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
font-size: 0.6875rem;
|
|
color: var(--text-secondary);
|
|
padding: 0.125rem 0.375rem;
|
|
background: var(--bg-primary);
|
|
border-radius: 4px;
|
|
white-space: nowrap;
|
|
width: 54px;
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.span-status {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.span-indent {
|
|
width: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.span-status.ok { background: var(--accent-green); }
|
|
.span-status.error { background: var(--accent-red); }
|
|
|
|
/* Span label row (below bar) */
|
|
.span-label-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.span-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 3px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.625rem;
|
|
}
|
|
|
|
.span-icon.router { background: var(--accent-gray); }
|
|
.span-icon.steward { background: var(--accent-blue); }
|
|
.span-icon.tatlock { background: var(--accent-green); }
|
|
.span-icon.expert { background: var(--accent-orange); }
|
|
.span-icon.tool { background: var(--accent-purple); }
|
|
|
|
.span-name {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* Details Panel */
|
|
#details {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.details-header {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.details-header h2 {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.details-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.detail-section {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.detail-section h3 {
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.detail-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.375rem 0;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.detail-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.detail-key {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.detail-value {
|
|
color: var(--text-primary);
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
/* Expandable Details */
|
|
.expandable {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
margin-top: 0.5rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.expandable-header {
|
|
padding: 0.75rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
transition: background 0.2s ease;
|
|
}
|
|
|
|
.expandable-header:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.expandable-arrow {
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.expandable.open .expandable-arrow {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.expandable-content {
|
|
display: none;
|
|
padding: 0.75rem;
|
|
border-top: 1px solid var(--border);
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
font-size: 0.75rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
color: var(--text-secondary);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.expandable.open .expandable-content {
|
|
display: block;
|
|
}
|
|
|
|
/* Stats Footer */
|
|
footer {
|
|
background: var(--bg-secondary);
|
|
border-top: 1px solid var(--border);
|
|
padding: 0.75rem 2rem;
|
|
display: flex;
|
|
gap: 2rem;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.stat {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.stat-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--bg-tertiary);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
/* Legend */
|
|
.legend {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* Drop Zone */
|
|
.drop-zone {
|
|
border: 2px dashed var(--border);
|
|
border-radius: 12px;
|
|
padding: 2rem;
|
|
text-align: center;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.drop-zone.drag-over {
|
|
border-color: var(--accent-blue);
|
|
background: rgba(122, 162, 247, 0.1);
|
|
}
|
|
|
|
/* Loading */
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.spinner {
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent-blue);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin-right: 0.75rem;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Refresh button */
|
|
.refresh-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 0.25rem;
|
|
border-radius: 4px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.refresh-btn:hover {
|
|
color: var(--accent-blue);
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
/* Filter Controls */
|
|
.filter-bar {
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filter-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filter-select,
|
|
.filter-input {
|
|
flex: 1;
|
|
padding: 0.375rem 0.5rem;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text-primary);
|
|
font-size: 0.75rem;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.filter-select:focus,
|
|
.filter-input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-blue);
|
|
box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.2);
|
|
}
|
|
|
|
.filter-select option {
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.filter-input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.filter-label {
|
|
font-size: 0.625rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.125rem;
|
|
}
|
|
|
|
.filter-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Tatlock Trace Viewer</h1>
|
|
<div class="header-actions">
|
|
<div class="file-input-wrapper">
|
|
<input type="file" id="file-picker" accept=".json">
|
|
<label for="file-picker" class="btn">
|
|
<span>Open File</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<span id="trace-info"></span>
|
|
</header>
|
|
|
|
<main>
|
|
<section id="trace-list-panel">
|
|
<div class="panel-header">
|
|
<h2>Recent Traces</h2>
|
|
<button class="refresh-btn" onclick="loadTraceList()" title="Refresh">↻</button>
|
|
</div>
|
|
<div class="filter-bar">
|
|
<div class="filter-row">
|
|
<div class="filter-group">
|
|
<label class="filter-label">Time</label>
|
|
<select id="filter-time" class="filter-select" onchange="loadTraceList()">
|
|
<option value="">All time</option>
|
|
<option value="5">Last 5 min</option>
|
|
<option value="30">Last 30 min</option>
|
|
<option value="60">Last hour</option>
|
|
<option value="1440">Last 24 hours</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label class="filter-label">Status</label>
|
|
<select id="filter-status" class="filter-select" onchange="loadTraceList()">
|
|
<option value="">All</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="error">Error</option>
|
|
<option value="streaming">Streaming</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="filter-row">
|
|
<input type="text" id="filter-search" class="filter-input" placeholder="Search requests..." onkeyup="debounceSearch()">
|
|
</div>
|
|
</div>
|
|
<div class="trace-list" id="trace-list">
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
Loading traces...
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="timeline">
|
|
<div class="timeline-header">
|
|
<h2>Timeline</h2>
|
|
<div class="legend">
|
|
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-gray)"></div>Router</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-blue)"></div>Steward</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-green)"></div>Tatlock</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-orange)"></div>Expert</div>
|
|
<div class="legend-item"><div class="legend-dot" style="background: var(--accent-purple)"></div>Tool</div>
|
|
</div>
|
|
</div>
|
|
<div class="timeline-content" id="timeline-content">
|
|
<div class="empty-state drop-zone" id="drop-zone">
|
|
<div class="empty-state-icon">📊</div>
|
|
<p>Select a trace from the list<br>or drop a JSON file here</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="details">
|
|
<div class="details-header">
|
|
<h2>Details</h2>
|
|
</div>
|
|
<div class="details-content" id="details-content">
|
|
<div class="empty-state">
|
|
<p>Select a span to view details</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<footer id="stats">
|
|
<div class="stat">
|
|
<span class="stat-label">Total Duration:</span>
|
|
<span class="stat-value" id="stat-duration">-</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">Spans:</span>
|
|
<span class="stat-value" id="stat-spans">-</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-dot" style="background: var(--accent-blue)"></span>
|
|
<span class="stat-label">Steward:</span>
|
|
<span class="stat-value" id="stat-steward">-</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-dot" style="background: var(--accent-green)"></span>
|
|
<span class="stat-label">Tatlock:</span>
|
|
<span class="stat-value" id="stat-tatlock">-</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-dot" style="background: var(--accent-orange)"></span>
|
|
<span class="stat-label">Expert:</span>
|
|
<span class="stat-value" id="stat-expert">-</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-dot" style="background: var(--accent-purple)"></span>
|
|
<span class="stat-label">Tool:</span>
|
|
<span class="stat-value" id="stat-tool">-</span>
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
// State
|
|
let currentTrace = null;
|
|
let selectedSpanId = null;
|
|
let selectedTraceId = null;
|
|
|
|
// API Base URL (same origin)
|
|
const API_BASE = '/traces';
|
|
|
|
// DOM Elements
|
|
const filePicker = document.getElementById('file-picker');
|
|
const traceInfo = document.getElementById('trace-info');
|
|
const traceList = document.getElementById('trace-list');
|
|
const timelineContent = document.getElementById('timeline-content');
|
|
const detailsContent = document.getElementById('details-content');
|
|
const dropZone = document.getElementById('drop-zone');
|
|
const filterTime = document.getElementById('filter-time');
|
|
const filterStatus = document.getElementById('filter-status');
|
|
const filterSearch = document.getElementById('filter-search');
|
|
|
|
// Debounce timer for search
|
|
let searchDebounceTimer = null;
|
|
|
|
function debounceSearch() {
|
|
clearTimeout(searchDebounceTimer);
|
|
searchDebounceTimer = setTimeout(loadTraceList, 300);
|
|
}
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadTraceList();
|
|
});
|
|
|
|
// File handling (local file support)
|
|
filePicker.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) loadTraceFromFile(file);
|
|
});
|
|
|
|
// Drag & Drop
|
|
dropZone?.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.add('drag-over');
|
|
});
|
|
|
|
dropZone?.addEventListener('dragleave', () => {
|
|
dropZone.classList.remove('drag-over');
|
|
});
|
|
|
|
dropZone?.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('drag-over');
|
|
const file = e.dataTransfer.files[0];
|
|
if (file && file.name.endsWith('.json')) {
|
|
loadTraceFromFile(file);
|
|
}
|
|
});
|
|
|
|
// Load trace list from API
|
|
async function loadTraceList() {
|
|
traceList.innerHTML = '<div class="loading"><div class="spinner"></div>Loading traces...</div>';
|
|
|
|
try {
|
|
// Build query params from filters
|
|
const params = new URLSearchParams();
|
|
params.append('limit', '50');
|
|
|
|
const timeVal = filterTime?.value;
|
|
if (timeVal) {
|
|
params.append('since_minutes', timeVal);
|
|
}
|
|
|
|
const statusVal = filterStatus?.value;
|
|
if (statusVal) {
|
|
params.append('status', statusVal);
|
|
}
|
|
|
|
const searchVal = filterSearch?.value?.trim();
|
|
if (searchVal) {
|
|
params.append('search', searchVal);
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}/list?${params.toString()}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
renderTraceList(data.traces);
|
|
} catch (error) {
|
|
console.error('Failed to load trace list:', error);
|
|
traceList.innerHTML = `
|
|
<div class="empty-state">
|
|
<p>Could not load traces<br><small>${error.message}</small></p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function renderTraceList(traces) {
|
|
if (!traces || traces.length === 0) {
|
|
traceList.innerHTML = `
|
|
<div class="empty-state">
|
|
<p>No traces yet</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
traceList.innerHTML = traces.map(trace => `
|
|
<div class="trace-item ${trace.trace_id === selectedTraceId ? 'selected' : ''}"
|
|
onclick="loadTraceFromAPI('${trace.trace_id}')"
|
|
data-trace-id="${trace.trace_id}">
|
|
<div class="trace-item-time">${formatTimestamp(trace.timestamp)}</div>
|
|
<div class="trace-item-preview">${escapeHtml(trace.request_preview || 'No preview')}</div>
|
|
<div class="trace-item-meta">
|
|
<span class="trace-item-tag ${trace.status}">${trace.status}</span>
|
|
<span class="trace-item-tag">${formatDuration(trace.total_duration_ms)}</span>
|
|
<span class="trace-item-tag">${trace.span_count} spans</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Load trace from API
|
|
async function loadTraceFromAPI(traceId) {
|
|
selectedTraceId = traceId;
|
|
|
|
// Update selection in list
|
|
document.querySelectorAll('.trace-item').forEach(item => {
|
|
item.classList.toggle('selected', item.dataset.traceId === traceId);
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/${traceId}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const trace = await response.json();
|
|
currentTrace = trace;
|
|
renderTrace(trace);
|
|
traceInfo.textContent = `${trace.trace_id} | ${formatTimestamp(trace.timestamp)}`;
|
|
} catch (error) {
|
|
console.error('Failed to load trace:', error);
|
|
traceInfo.textContent = `Error: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
// Load trace from local file
|
|
async function loadTraceFromFile(file) {
|
|
try {
|
|
const text = await file.text();
|
|
const trace = JSON.parse(text);
|
|
currentTrace = trace;
|
|
selectedTraceId = null;
|
|
renderTrace(trace);
|
|
traceInfo.textContent = `${file.name} | ${formatTimestamp(trace.timestamp)}`;
|
|
|
|
// Clear list selection
|
|
document.querySelectorAll('.trace-item').forEach(item => {
|
|
item.classList.remove('selected');
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load trace:', error);
|
|
traceInfo.textContent = 'Error loading trace file';
|
|
}
|
|
}
|
|
|
|
function renderTrace(trace) {
|
|
// Build span tree
|
|
const spanMap = new Map();
|
|
trace.spans.forEach(span => spanMap.set(span.span_id, span));
|
|
|
|
// Find root spans (no parent)
|
|
const rootSpans = trace.spans.filter(s => !s.parent_id);
|
|
|
|
// Calculate timeline bounds for Gantt chart positioning
|
|
let traceStart = Infinity;
|
|
let traceEnd = 0;
|
|
trace.spans.forEach(span => {
|
|
const startMs = new Date(span.start_time).getTime();
|
|
const endMs = span.end_time ? new Date(span.end_time).getTime() : startMs + (span.duration_ms || 0);
|
|
if (startMs < traceStart) traceStart = startMs;
|
|
if (endMs > traceEnd) traceEnd = endMs;
|
|
});
|
|
const totalDuration = traceEnd - traceStart || 1; // Avoid division by zero
|
|
|
|
// Render timeline
|
|
timelineContent.innerHTML = '';
|
|
rootSpans.forEach(span => {
|
|
renderSpanTree(span, spanMap, 0, traceStart, totalDuration);
|
|
});
|
|
|
|
// Update stats
|
|
updateStats(trace);
|
|
|
|
// Clear details
|
|
selectedSpanId = null;
|
|
detailsContent.innerHTML = '<div class="empty-state"><p>Select a span to view details</p></div>';
|
|
}
|
|
|
|
function renderSpanTree(span, spanMap, depth, traceStart, totalDuration) {
|
|
const row = document.createElement('div');
|
|
row.className = 'span-row';
|
|
row.dataset.spanId = span.span_id;
|
|
|
|
// === Bar Row (bar + duration + status) - NO indentation for alignment ===
|
|
const barRow = document.createElement('div');
|
|
barRow.className = 'span-bar-row';
|
|
|
|
// Gantt-style duration bar (full width, positioned by start time)
|
|
const barWrapper = document.createElement('div');
|
|
barWrapper.className = 'span-bar-wrapper';
|
|
|
|
const bar = document.createElement('div');
|
|
bar.className = `span-bar ${span.type}`;
|
|
|
|
// Calculate position and width relative to trace timeline
|
|
const spanStartMs = new Date(span.start_time).getTime();
|
|
const spanDurationMs = span.duration_ms || 0;
|
|
const leftPercent = ((spanStartMs - traceStart) / totalDuration) * 100;
|
|
const widthPercent = (spanDurationMs / totalDuration) * 100;
|
|
|
|
bar.style.left = `${leftPercent}%`;
|
|
bar.style.width = `${Math.max(widthPercent, 2)}%`; // Minimum 2% width for visibility
|
|
|
|
barWrapper.appendChild(bar);
|
|
barRow.appendChild(barWrapper);
|
|
|
|
// Duration label (right of bar, fixed width)
|
|
const duration = document.createElement('div');
|
|
duration.className = 'span-duration';
|
|
duration.textContent = formatDuration(span.duration_ms);
|
|
barRow.appendChild(duration);
|
|
|
|
// Status indicator
|
|
const status = document.createElement('div');
|
|
status.className = `span-status ${span.status}`;
|
|
barRow.appendChild(status);
|
|
|
|
row.appendChild(barRow);
|
|
|
|
// === Label Row (indents + icon + name, below bar) ===
|
|
const labelRow = document.createElement('div');
|
|
labelRow.className = 'span-label-row';
|
|
|
|
// Indentation only on label row to show hierarchy
|
|
for (let i = 0; i < depth; i++) {
|
|
const indent = document.createElement('div');
|
|
indent.className = 'span-indent';
|
|
labelRow.appendChild(indent);
|
|
}
|
|
|
|
// Icon
|
|
const icon = document.createElement('div');
|
|
icon.className = `span-icon ${span.type}`;
|
|
icon.textContent = getTypeIcon(span.type);
|
|
labelRow.appendChild(icon);
|
|
|
|
// Name
|
|
const name = document.createElement('div');
|
|
name.className = 'span-name';
|
|
name.textContent = span.name;
|
|
labelRow.appendChild(name);
|
|
|
|
row.appendChild(labelRow);
|
|
|
|
// Click handler
|
|
row.addEventListener('click', () => selectSpan(span));
|
|
|
|
timelineContent.appendChild(row);
|
|
|
|
// Render children
|
|
if (span.children) {
|
|
span.children.forEach(childId => {
|
|
const childSpan = spanMap.get(childId);
|
|
if (childSpan) {
|
|
renderSpanTree(childSpan, spanMap, depth + 1, traceStart, totalDuration);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function selectSpan(span) {
|
|
selectedSpanId = span.span_id;
|
|
|
|
// Update selection UI
|
|
document.querySelectorAll('.span-row').forEach(row => {
|
|
row.classList.toggle('selected', row.dataset.spanId === span.span_id);
|
|
});
|
|
|
|
// Render details
|
|
renderDetails(span);
|
|
}
|
|
|
|
function renderDetails(span) {
|
|
detailsContent.innerHTML = '';
|
|
|
|
// Basic info section
|
|
const basicSection = createDetailSection('Basic Info');
|
|
addDetailItem(basicSection, 'Name', span.name);
|
|
addDetailItem(basicSection, 'Type', span.type);
|
|
addDetailItem(basicSection, 'Status', span.status);
|
|
addDetailItem(basicSection, 'Duration', formatDuration(span.duration_ms));
|
|
addDetailItem(basicSection, 'Start', formatTimestamp(span.start_time));
|
|
if (span.end_time) {
|
|
addDetailItem(basicSection, 'End', formatTimestamp(span.end_time));
|
|
}
|
|
detailsContent.appendChild(basicSection);
|
|
|
|
// Metadata section
|
|
if (span.metadata && Object.keys(span.metadata).length > 0) {
|
|
const metaSection = createDetailSection('Metadata');
|
|
Object.entries(span.metadata).forEach(([key, value]) => {
|
|
addDetailItem(metaSection, key, formatValue(value));
|
|
});
|
|
detailsContent.appendChild(metaSection);
|
|
}
|
|
|
|
// Details section (expandable)
|
|
if (span.details && Object.keys(span.details).length > 0) {
|
|
const detailSection = createDetailSection('Expandable Details');
|
|
Object.entries(span.details).forEach(([key, value]) => {
|
|
const expandable = createExpandable(key, formatValue(value, true));
|
|
detailSection.appendChild(expandable);
|
|
});
|
|
detailsContent.appendChild(detailSection);
|
|
}
|
|
|
|
// Error section
|
|
if (span.error) {
|
|
const errorSection = createDetailSection('Error');
|
|
const errorBox = document.createElement('div');
|
|
errorBox.style.cssText = 'background: rgba(247, 118, 142, 0.1); border: 1px solid var(--accent-red); border-radius: 8px; padding: 0.75rem; color: var(--accent-red); font-size: 0.875rem;';
|
|
errorBox.textContent = span.error;
|
|
errorSection.appendChild(errorBox);
|
|
detailsContent.appendChild(errorSection);
|
|
}
|
|
}
|
|
|
|
function createDetailSection(title) {
|
|
const section = document.createElement('div');
|
|
section.className = 'detail-section';
|
|
const h3 = document.createElement('h3');
|
|
h3.textContent = title;
|
|
section.appendChild(h3);
|
|
return section;
|
|
}
|
|
|
|
function addDetailItem(section, key, value) {
|
|
const item = document.createElement('div');
|
|
item.className = 'detail-item';
|
|
item.innerHTML = `
|
|
<span class="detail-key">${escapeHtml(key)}</span>
|
|
<span class="detail-value">${escapeHtml(value)}</span>
|
|
`;
|
|
section.appendChild(item);
|
|
}
|
|
|
|
function createExpandable(title, content) {
|
|
const expandable = document.createElement('div');
|
|
expandable.className = 'expandable';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'expandable-header';
|
|
header.innerHTML = `
|
|
<span class="expandable-arrow">▶</span>
|
|
<span>${escapeHtml(title)}</span>
|
|
`;
|
|
|
|
const contentDiv = document.createElement('div');
|
|
contentDiv.className = 'expandable-content';
|
|
contentDiv.textContent = content;
|
|
|
|
header.addEventListener('click', () => {
|
|
expandable.classList.toggle('open');
|
|
});
|
|
|
|
expandable.appendChild(header);
|
|
expandable.appendChild(contentDiv);
|
|
return expandable;
|
|
}
|
|
|
|
function updateStats(trace) {
|
|
document.getElementById('stat-duration').textContent = formatDuration(trace.total_duration_ms);
|
|
document.getElementById('stat-spans').textContent = trace.spans.length;
|
|
|
|
const typeCounts = { steward: 0, tatlock: 0, expert: 0, tool: 0 };
|
|
trace.spans.forEach(span => {
|
|
if (typeCounts.hasOwnProperty(span.type)) {
|
|
typeCounts[span.type]++;
|
|
}
|
|
});
|
|
|
|
document.getElementById('stat-steward').textContent = typeCounts.steward;
|
|
document.getElementById('stat-tatlock').textContent = typeCounts.tatlock;
|
|
document.getElementById('stat-expert').textContent = typeCounts.expert;
|
|
document.getElementById('stat-tool').textContent = typeCounts.tool;
|
|
}
|
|
|
|
// Utility functions
|
|
function formatDuration(ms) {
|
|
if (ms == null) return '-';
|
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
}
|
|
|
|
function formatTimestamp(ts) {
|
|
if (!ts) return '-';
|
|
try {
|
|
const date = new Date(ts);
|
|
return date.toLocaleString();
|
|
} catch {
|
|
return ts;
|
|
}
|
|
}
|
|
|
|
function formatValue(value, full = false) {
|
|
if (value === null || value === undefined) return '-';
|
|
if (typeof value === 'object') {
|
|
return full ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
}
|
|
if (typeof value === 'string' && !full && value.length > 50) {
|
|
return value.substring(0, 50) + '...';
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function getTypeIcon(type) {
|
|
const icons = {
|
|
router: '🚪',
|
|
steward: '🎩',
|
|
tatlock: '🤵',
|
|
expert: '👨💼',
|
|
tool: '🔧'
|
|
};
|
|
return icons[type] || '•';
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (typeof str !== 'string') return str;
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|