timetracer 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- timetracer/__init__.py +29 -0
- timetracer/cassette/__init__.py +6 -0
- timetracer/cassette/io.py +421 -0
- timetracer/cassette/naming.py +69 -0
- timetracer/catalog/__init__.py +288 -0
- timetracer/cli/__init__.py +5 -0
- timetracer/cli/commands/__init__.py +1 -0
- timetracer/cli/main.py +692 -0
- timetracer/config.py +297 -0
- timetracer/constants.py +129 -0
- timetracer/context.py +93 -0
- timetracer/dashboard/__init__.py +14 -0
- timetracer/dashboard/generator.py +229 -0
- timetracer/dashboard/server.py +244 -0
- timetracer/dashboard/template.py +874 -0
- timetracer/diff/__init__.py +6 -0
- timetracer/diff/engine.py +311 -0
- timetracer/diff/report.py +113 -0
- timetracer/exceptions.py +113 -0
- timetracer/integrations/__init__.py +27 -0
- timetracer/integrations/fastapi.py +537 -0
- timetracer/integrations/flask.py +507 -0
- timetracer/plugins/__init__.py +42 -0
- timetracer/plugins/base.py +73 -0
- timetracer/plugins/httpx_plugin.py +413 -0
- timetracer/plugins/redis_plugin.py +297 -0
- timetracer/plugins/requests_plugin.py +333 -0
- timetracer/plugins/sqlalchemy_plugin.py +280 -0
- timetracer/policies/__init__.py +16 -0
- timetracer/policies/capture.py +64 -0
- timetracer/policies/redaction.py +165 -0
- timetracer/replay/__init__.py +6 -0
- timetracer/replay/engine.py +75 -0
- timetracer/replay/errors.py +9 -0
- timetracer/replay/matching.py +83 -0
- timetracer/session.py +390 -0
- timetracer/storage/__init__.py +18 -0
- timetracer/storage/s3.py +364 -0
- timetracer/timeline/__init__.py +6 -0
- timetracer/timeline/generator.py +150 -0
- timetracer/timeline/template.py +370 -0
- timetracer/types.py +197 -0
- timetracer/utils/__init__.py +6 -0
- timetracer/utils/hashing.py +68 -0
- timetracer/utils/time.py +106 -0
- timetracer-1.1.0.dist-info/METADATA +286 -0
- timetracer-1.1.0.dist-info/RECORD +51 -0
- timetracer-1.1.0.dist-info/WHEEL +5 -0
- timetracer-1.1.0.dist-info/entry_points.txt +2 -0
- timetracer-1.1.0.dist-info/licenses/LICENSE +21 -0
- timetracer-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTML template for dashboard visualization.
|
|
3
|
+
|
|
4
|
+
Generates a self-contained HTML file with embedded CSS and JavaScript.
|
|
5
|
+
Features: sortable table, filters, expandable rows, search.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from timetracer.dashboard.generator import DashboardData
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_dashboard_html(data: DashboardData) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Render dashboard data as a standalone HTML file.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
data: Dashboard data to visualize.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Complete HTML string.
|
|
25
|
+
"""
|
|
26
|
+
# Convert data to JSON for JavaScript
|
|
27
|
+
data_json = json.dumps(data.to_dict(), indent=2)
|
|
28
|
+
|
|
29
|
+
return f"""<!DOCTYPE html>
|
|
30
|
+
<html lang="en">
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="UTF-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
34
|
+
<title>Timetracer Dashboard</title>
|
|
35
|
+
<style>
|
|
36
|
+
{_get_css()}
|
|
37
|
+
</style>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<div class="container">
|
|
41
|
+
<header class="header">
|
|
42
|
+
<h1>Timetracer Dashboard</h1>
|
|
43
|
+
<p class="subtitle">Generated: {html.escape(data.generated_at[:19].replace('T', ' '))}</p>
|
|
44
|
+
</header>
|
|
45
|
+
|
|
46
|
+
<div class="stats-row">
|
|
47
|
+
<div class="stat-card">
|
|
48
|
+
<div class="stat-value">{data.total_count}</div>
|
|
49
|
+
<div class="stat-label">Total Requests</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="stat-card stat-success">
|
|
52
|
+
<div class="stat-value">{data.success_count}</div>
|
|
53
|
+
<div class="stat-label">Success</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="stat-card stat-error">
|
|
56
|
+
<div class="stat-value">{data.error_count}</div>
|
|
57
|
+
<div class="stat-label">Errors</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="filters-row">
|
|
62
|
+
<input type="text" id="search" placeholder="Search endpoints..." class="search-input">
|
|
63
|
+
<select id="method-filter" class="filter-select">
|
|
64
|
+
<option value="">All Methods</option>
|
|
65
|
+
</select>
|
|
66
|
+
<select id="status-filter" class="filter-select">
|
|
67
|
+
<option value="">All Statuses</option>
|
|
68
|
+
<option value="error">Errors Only</option>
|
|
69
|
+
<option value="success">Success Only</option>
|
|
70
|
+
</select>
|
|
71
|
+
<select id="duration-filter" class="filter-select">
|
|
72
|
+
<option value="">All Durations</option>
|
|
73
|
+
<option value="slow">Slow (>1s)</option>
|
|
74
|
+
<option value="medium">Medium (300ms-1s)</option>
|
|
75
|
+
<option value="fast">Fast (<300ms)</option>
|
|
76
|
+
</select>
|
|
77
|
+
<select id="time-filter" class="filter-select">
|
|
78
|
+
<option value="">All Time</option>
|
|
79
|
+
<option value="1">Last 1 min</option>
|
|
80
|
+
<option value="5">Last 5 mins</option>
|
|
81
|
+
<option value="10">Last 10 mins</option>
|
|
82
|
+
<option value="15">Last 15 mins</option>
|
|
83
|
+
<option value="30">Last 30 mins</option>
|
|
84
|
+
<option value="60">Last 1 hour</option>
|
|
85
|
+
<option value="custom">Custom Range</option>
|
|
86
|
+
</select>
|
|
87
|
+
<div id="custom-time-range" style="display:none;gap:8px;align-items:center;">
|
|
88
|
+
<input type="datetime-local" id="time-from" class="filter-select" style="width:auto;">
|
|
89
|
+
<span style="color:#888;">to</span>
|
|
90
|
+
<input type="datetime-local" id="time-to" class="filter-select" style="width:auto;">
|
|
91
|
+
</div>
|
|
92
|
+
<button id="clear-filters" class="btn btn-secondary">Clear Filters</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="table-container">
|
|
96
|
+
<table class="cassette-table">
|
|
97
|
+
<thead>
|
|
98
|
+
<tr>
|
|
99
|
+
<th class="sortable" data-sort="recorded_at">Time</th>
|
|
100
|
+
<th class="sortable" data-sort="method">Method</th>
|
|
101
|
+
<th class="sortable" data-sort="endpoint">Endpoint</th>
|
|
102
|
+
<th class="sortable" data-sort="status">Status</th>
|
|
103
|
+
<th class="sortable" data-sort="duration_ms">Duration</th>
|
|
104
|
+
<th class="sortable" data-sort="event_count">Events</th>
|
|
105
|
+
<th>Actions</th>
|
|
106
|
+
</tr>
|
|
107
|
+
</thead>
|
|
108
|
+
<tbody id="cassette-tbody">
|
|
109
|
+
<!-- Populated by JavaScript -->
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div id="showing-count" class="showing-count"></div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<!-- Detail Modal -->
|
|
118
|
+
<div id="detail-modal" class="modal">
|
|
119
|
+
<div class="modal-content">
|
|
120
|
+
<span class="modal-close">×</span>
|
|
121
|
+
<div id="modal-body"></div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<script>
|
|
126
|
+
const dashboardData = {data_json};
|
|
127
|
+
{_get_js()}
|
|
128
|
+
</script>
|
|
129
|
+
</body>
|
|
130
|
+
</html>"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_css() -> str:
|
|
134
|
+
"""Get embedded CSS styles."""
|
|
135
|
+
return """
|
|
136
|
+
* {
|
|
137
|
+
margin: 0;
|
|
138
|
+
padding: 0;
|
|
139
|
+
box-sizing: border-box;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
body {
|
|
143
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
144
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
145
|
+
min-height: 100vh;
|
|
146
|
+
color: #e0e0e0;
|
|
147
|
+
line-height: 1.6;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.container {
|
|
151
|
+
max-width: 1400px;
|
|
152
|
+
margin: 0 auto;
|
|
153
|
+
padding: 24px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.header {
|
|
157
|
+
text-align: center;
|
|
158
|
+
margin-bottom: 32px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.header h1 {
|
|
162
|
+
font-size: 2.5rem;
|
|
163
|
+
font-weight: 700;
|
|
164
|
+
background: linear-gradient(90deg, #00d9ff, #00ff88);
|
|
165
|
+
-webkit-background-clip: text;
|
|
166
|
+
-webkit-text-fill-color: transparent;
|
|
167
|
+
background-clip: text;
|
|
168
|
+
margin-bottom: 8px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.subtitle {
|
|
172
|
+
color: #888;
|
|
173
|
+
font-size: 0.9rem;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.stats-row {
|
|
177
|
+
display: flex;
|
|
178
|
+
gap: 16px;
|
|
179
|
+
margin-bottom: 24px;
|
|
180
|
+
justify-content: center;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.stat-card {
|
|
184
|
+
background: rgba(255, 255, 255, 0.05);
|
|
185
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
186
|
+
border-radius: 12px;
|
|
187
|
+
padding: 20px 40px;
|
|
188
|
+
text-align: center;
|
|
189
|
+
backdrop-filter: blur(10px);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.stat-value {
|
|
193
|
+
font-size: 2rem;
|
|
194
|
+
font-weight: 700;
|
|
195
|
+
color: #fff;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.stat-label {
|
|
199
|
+
font-size: 0.85rem;
|
|
200
|
+
color: #888;
|
|
201
|
+
text-transform: uppercase;
|
|
202
|
+
letter-spacing: 1px;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.stat-success .stat-value { color: #00ff88; }
|
|
206
|
+
.stat-error .stat-value { color: #ff6b6b; }
|
|
207
|
+
|
|
208
|
+
.filters-row {
|
|
209
|
+
display: flex;
|
|
210
|
+
gap: 12px;
|
|
211
|
+
margin-bottom: 20px;
|
|
212
|
+
flex-wrap: wrap;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.search-input, .filter-select {
|
|
216
|
+
background: rgba(255, 255, 255, 0.05);
|
|
217
|
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
218
|
+
border-radius: 8px;
|
|
219
|
+
padding: 10px 16px;
|
|
220
|
+
color: #fff;
|
|
221
|
+
font-size: 0.9rem;
|
|
222
|
+
outline: none;
|
|
223
|
+
transition: border-color 0.2s;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.search-input {
|
|
227
|
+
flex: 1;
|
|
228
|
+
min-width: 200px;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.search-input:focus, .filter-select:focus {
|
|
232
|
+
border-color: #00d9ff;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.filter-select option {
|
|
236
|
+
background: #1a1a2e;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.btn {
|
|
240
|
+
padding: 10px 20px;
|
|
241
|
+
border-radius: 8px;
|
|
242
|
+
border: none;
|
|
243
|
+
cursor: pointer;
|
|
244
|
+
font-size: 0.9rem;
|
|
245
|
+
transition: all 0.2s;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.btn-secondary {
|
|
249
|
+
background: rgba(255, 255, 255, 0.1);
|
|
250
|
+
color: #fff;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.btn-secondary:hover {
|
|
254
|
+
background: rgba(255, 255, 255, 0.2);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.btn-primary {
|
|
258
|
+
background: linear-gradient(90deg, #00d9ff, #00ff88);
|
|
259
|
+
color: #1a1a2e;
|
|
260
|
+
font-weight: 600;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.table-container {
|
|
264
|
+
background: rgba(255, 255, 255, 0.03);
|
|
265
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
266
|
+
border-radius: 12px;
|
|
267
|
+
overflow: hidden;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.cassette-table {
|
|
271
|
+
width: 100%;
|
|
272
|
+
border-collapse: collapse;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.cassette-table th {
|
|
276
|
+
background: rgba(0, 0, 0, 0.3);
|
|
277
|
+
padding: 14px 16px;
|
|
278
|
+
text-align: left;
|
|
279
|
+
font-weight: 600;
|
|
280
|
+
font-size: 0.85rem;
|
|
281
|
+
text-transform: uppercase;
|
|
282
|
+
letter-spacing: 0.5px;
|
|
283
|
+
color: #aaa;
|
|
284
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.cassette-table th.sortable {
|
|
288
|
+
cursor: pointer;
|
|
289
|
+
user-select: none;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.cassette-table th.sortable:hover {
|
|
293
|
+
color: #00d9ff;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.cassette-table th.sortable::after {
|
|
297
|
+
content: ' ↕';
|
|
298
|
+
opacity: 0.3;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.cassette-table th.sort-asc::after { content: ' ↑'; opacity: 1; }
|
|
302
|
+
.cassette-table th.sort-desc::after { content: ' ↓'; opacity: 1; }
|
|
303
|
+
|
|
304
|
+
.cassette-table td {
|
|
305
|
+
padding: 12px 16px;
|
|
306
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
307
|
+
font-size: 0.9rem;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.cassette-table tr:hover {
|
|
311
|
+
background: rgba(255, 255, 255, 0.03);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.cassette-table tr.error-row {
|
|
315
|
+
background: rgba(255, 80, 80, 0.08);
|
|
316
|
+
border-left: 3px solid #ff6b6b;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.cassette-table tr.error-row:hover {
|
|
320
|
+
background: rgba(255, 80, 80, 0.15);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.slow-warning {
|
|
324
|
+
color: #ff6b6b;
|
|
325
|
+
font-weight: 600;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.slow-warning::after {
|
|
329
|
+
content: ' ⚠';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.method-badge {
|
|
333
|
+
display: inline-block;
|
|
334
|
+
padding: 4px 10px;
|
|
335
|
+
border-radius: 4px;
|
|
336
|
+
font-size: 0.75rem;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
text-transform: uppercase;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.method-GET { background: rgba(0, 200, 150, 0.2); color: #00c896; }
|
|
342
|
+
.method-POST { background: rgba(0, 150, 255, 0.2); color: #0096ff; }
|
|
343
|
+
.method-PUT { background: rgba(255, 180, 0, 0.2); color: #ffb400; }
|
|
344
|
+
.method-DELETE { background: rgba(255, 100, 100, 0.2); color: #ff6464; }
|
|
345
|
+
.method-PATCH { background: rgba(180, 100, 255, 0.2); color: #b464ff; }
|
|
346
|
+
|
|
347
|
+
.status-badge {
|
|
348
|
+
display: inline-block;
|
|
349
|
+
padding: 4px 10px;
|
|
350
|
+
border-radius: 4px;
|
|
351
|
+
font-size: 0.8rem;
|
|
352
|
+
font-weight: 600;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.status-success { background: rgba(0, 255, 136, 0.15); color: #00ff88; }
|
|
356
|
+
.status-error { background: rgba(255, 107, 107, 0.15); color: #ff6b6b; }
|
|
357
|
+
.status-redirect { background: rgba(255, 200, 0, 0.15); color: #ffc800; }
|
|
358
|
+
|
|
359
|
+
.endpoint-cell {
|
|
360
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
361
|
+
font-size: 0.85rem;
|
|
362
|
+
max-width: 300px;
|
|
363
|
+
overflow: hidden;
|
|
364
|
+
text-overflow: ellipsis;
|
|
365
|
+
white-space: nowrap;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.duration-cell {
|
|
369
|
+
color: #888;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.duration-slow { color: #ff6b6b; }
|
|
373
|
+
.duration-medium { color: #ffc800; }
|
|
374
|
+
.duration-fast { color: #00ff88; }
|
|
375
|
+
|
|
376
|
+
.action-btn {
|
|
377
|
+
padding: 6px 12px;
|
|
378
|
+
border-radius: 6px;
|
|
379
|
+
border: none;
|
|
380
|
+
background: rgba(0, 217, 255, 0.15);
|
|
381
|
+
color: #00d9ff;
|
|
382
|
+
cursor: pointer;
|
|
383
|
+
font-size: 0.8rem;
|
|
384
|
+
transition: all 0.2s;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.action-btn:hover {
|
|
388
|
+
background: rgba(0, 217, 255, 0.3);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.showing-count {
|
|
392
|
+
text-align: center;
|
|
393
|
+
padding: 16px;
|
|
394
|
+
color: #666;
|
|
395
|
+
font-size: 0.85rem;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* Modal */
|
|
399
|
+
.modal {
|
|
400
|
+
display: none;
|
|
401
|
+
position: fixed;
|
|
402
|
+
top: 0;
|
|
403
|
+
left: 0;
|
|
404
|
+
width: 100%;
|
|
405
|
+
height: 100%;
|
|
406
|
+
background: rgba(0, 0, 0, 0.8);
|
|
407
|
+
z-index: 1000;
|
|
408
|
+
overflow-y: auto;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.modal.show {
|
|
412
|
+
display: flex;
|
|
413
|
+
align-items: flex-start;
|
|
414
|
+
justify-content: center;
|
|
415
|
+
padding: 40px 20px;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.modal-content {
|
|
419
|
+
background: #1a1a2e;
|
|
420
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
421
|
+
border-radius: 16px;
|
|
422
|
+
max-width: 900px;
|
|
423
|
+
width: 100%;
|
|
424
|
+
padding: 24px;
|
|
425
|
+
position: relative;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.modal-close {
|
|
429
|
+
position: absolute;
|
|
430
|
+
top: 16px;
|
|
431
|
+
right: 20px;
|
|
432
|
+
font-size: 1.5rem;
|
|
433
|
+
cursor: pointer;
|
|
434
|
+
color: #888;
|
|
435
|
+
transition: color 0.2s;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.modal-close:hover {
|
|
439
|
+
color: #fff;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.detail-section {
|
|
443
|
+
margin-bottom: 24px;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.detail-section h3 {
|
|
447
|
+
font-size: 1rem;
|
|
448
|
+
color: #00d9ff;
|
|
449
|
+
margin-bottom: 12px;
|
|
450
|
+
padding-bottom: 8px;
|
|
451
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.detail-grid {
|
|
455
|
+
display: grid;
|
|
456
|
+
grid-template-columns: 120px 1fr;
|
|
457
|
+
gap: 8px;
|
|
458
|
+
font-size: 0.9rem;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.detail-label {
|
|
462
|
+
color: #888;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.detail-value {
|
|
466
|
+
color: #fff;
|
|
467
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
468
|
+
word-break: break-all;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.events-list {
|
|
472
|
+
display: flex;
|
|
473
|
+
flex-direction: column;
|
|
474
|
+
gap: 8px;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.event-item {
|
|
478
|
+
background: rgba(255, 255, 255, 0.03);
|
|
479
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
480
|
+
border-radius: 8px;
|
|
481
|
+
padding: 12px;
|
|
482
|
+
display: grid;
|
|
483
|
+
grid-template-columns: 80px 1fr 80px 80px;
|
|
484
|
+
gap: 12px;
|
|
485
|
+
align-items: center;
|
|
486
|
+
font-size: 0.85rem;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.event-url {
|
|
490
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
491
|
+
font-size: 0.8rem;
|
|
492
|
+
overflow: hidden;
|
|
493
|
+
text-overflow: ellipsis;
|
|
494
|
+
white-space: nowrap;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.copy-btn {
|
|
498
|
+
padding: 8px 16px;
|
|
499
|
+
background: rgba(0, 217, 255, 0.1);
|
|
500
|
+
border: 1px solid rgba(0, 217, 255, 0.3);
|
|
501
|
+
border-radius: 6px;
|
|
502
|
+
color: #00d9ff;
|
|
503
|
+
cursor: pointer;
|
|
504
|
+
font-size: 0.85rem;
|
|
505
|
+
margin-top: 12px;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.copy-btn:hover {
|
|
509
|
+
background: rgba(0, 217, 255, 0.2);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* JSON syntax highlighting */
|
|
513
|
+
.json-key { color: #00d9ff; }
|
|
514
|
+
.json-string { color: #98c379; }
|
|
515
|
+
.json-number { color: #d19a66; }
|
|
516
|
+
.json-boolean { color: #c678dd; }
|
|
517
|
+
.json-null { color: #888; }
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _get_js() -> str:
|
|
522
|
+
"""Get embedded JavaScript."""
|
|
523
|
+
return """
|
|
524
|
+
let cassettes = dashboardData.cassettes;
|
|
525
|
+
let sortColumn = 'recorded_at';
|
|
526
|
+
let sortDirection = 'desc';
|
|
527
|
+
|
|
528
|
+
function init() {
|
|
529
|
+
populateFilters();
|
|
530
|
+
renderTable();
|
|
531
|
+
setupEventListeners();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function populateFilters() {
|
|
535
|
+
const methodSelect = document.getElementById('method-filter');
|
|
536
|
+
dashboardData.filters.methods.forEach(method => {
|
|
537
|
+
const opt = document.createElement('option');
|
|
538
|
+
opt.value = method;
|
|
539
|
+
opt.textContent = method;
|
|
540
|
+
methodSelect.appendChild(opt);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
let displayedCassettes = [];
|
|
545
|
+
|
|
546
|
+
function renderTable() {
|
|
547
|
+
const tbody = document.getElementById('cassette-tbody');
|
|
548
|
+
const filtered = getFilteredCassettes();
|
|
549
|
+
displayedCassettes = sortCassettes(filtered);
|
|
550
|
+
|
|
551
|
+
tbody.innerHTML = displayedCassettes.map((c, idx) => `
|
|
552
|
+
<tr class="${c.status >= 400 ? 'error-row' : ''}">
|
|
553
|
+
<td>${formatTime(c.recorded_at)}</td>
|
|
554
|
+
<td><span class="method-badge method-${c.method}">${c.method}</span></td>
|
|
555
|
+
<td class="endpoint-cell" title="${escapeHtml(c.endpoint)}">${escapeHtml(c.endpoint)}</td>
|
|
556
|
+
<td><span class="status-badge ${getStatusClass(c.status)}">${c.status}</span></td>
|
|
557
|
+
<td class="${getDurationClass(c.duration_ms)} ${c.duration_ms > 1000 ? 'slow-warning' : ''}">${c.duration_ms.toFixed(0)}ms</td>
|
|
558
|
+
<td>${c.event_count}</td>
|
|
559
|
+
<td>
|
|
560
|
+
<button class="action-btn view-btn" data-idx="${idx}">View</button>
|
|
561
|
+
<button class="action-btn replay-btn" data-idx="${idx}" style="background:rgba(0,255,136,0.15);color:#00ff88;margin-left:4px;">Replay</button>
|
|
562
|
+
</td>
|
|
563
|
+
</tr>
|
|
564
|
+
`).join('');
|
|
565
|
+
|
|
566
|
+
// Add click handlers for view buttons
|
|
567
|
+
document.querySelectorAll('.view-btn').forEach(btn => {
|
|
568
|
+
btn.onclick = function() {
|
|
569
|
+
const idx = parseInt(this.dataset.idx);
|
|
570
|
+
if (displayedCassettes[idx]) {
|
|
571
|
+
showDetail(displayedCassettes[idx]);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Add click handlers for replay buttons
|
|
577
|
+
document.querySelectorAll('.replay-btn').forEach(btn => {
|
|
578
|
+
btn.onclick = function() {
|
|
579
|
+
const idx = parseInt(this.dataset.idx);
|
|
580
|
+
if (displayedCassettes[idx]) {
|
|
581
|
+
const c = displayedCassettes[idx];
|
|
582
|
+
|
|
583
|
+
// Check if we're in live server mode (try API first)
|
|
584
|
+
if (typeof liveReplay === 'function') {
|
|
585
|
+
liveReplay(c.path);
|
|
586
|
+
} else {
|
|
587
|
+
// Static mode: copy command
|
|
588
|
+
const cmd = 'TIMETRACER_MODE=replay TIMETRACER_CASSETTE="' + c.path + '" uvicorn app:app';
|
|
589
|
+
navigator.clipboard.writeText(cmd).then(() => {
|
|
590
|
+
this.textContent = 'Copied!';
|
|
591
|
+
this.style.background = 'rgba(0,255,136,0.4)';
|
|
592
|
+
setTimeout(() => {
|
|
593
|
+
this.textContent = 'Replay';
|
|
594
|
+
this.style.background = 'rgba(0,255,136,0.15)';
|
|
595
|
+
}, 1500);
|
|
596
|
+
}).catch(() => {
|
|
597
|
+
prompt('Copy this command:', cmd);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
document.getElementById('showing-count').textContent =
|
|
605
|
+
`Showing ${displayedCassettes.length} of ${cassettes.length} cassettes`;
|
|
606
|
+
|
|
607
|
+
updateSortIndicators();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function getFilteredCassettes() {
|
|
611
|
+
const search = document.getElementById('search').value.toLowerCase();
|
|
612
|
+
const method = document.getElementById('method-filter').value;
|
|
613
|
+
const status = document.getElementById('status-filter').value;
|
|
614
|
+
const duration = document.getElementById('duration-filter').value;
|
|
615
|
+
const timeFilter = document.getElementById('time-filter').value;
|
|
616
|
+
const timeFrom = document.getElementById('time-from').value;
|
|
617
|
+
const timeTo = document.getElementById('time-to').value;
|
|
618
|
+
|
|
619
|
+
const now = new Date();
|
|
620
|
+
|
|
621
|
+
return cassettes.filter(c => {
|
|
622
|
+
if (search && !c.endpoint.toLowerCase().includes(search)) return false;
|
|
623
|
+
if (method && c.method !== method) return false;
|
|
624
|
+
if (status === 'error' && c.status < 400) return false;
|
|
625
|
+
if (status === 'success' && c.status >= 400) return false;
|
|
626
|
+
if (duration === 'slow' && c.duration_ms <= 1000) return false;
|
|
627
|
+
if (duration === 'medium' && (c.duration_ms < 300 || c.duration_ms > 1000)) return false;
|
|
628
|
+
if (duration === 'fast' && c.duration_ms >= 300) return false;
|
|
629
|
+
|
|
630
|
+
// Time filter
|
|
631
|
+
if (timeFilter && timeFilter !== 'custom') {
|
|
632
|
+
const recordedAt = new Date(c.recorded_at);
|
|
633
|
+
const diffMs = now - recordedAt;
|
|
634
|
+
const diffMins = diffMs / (1000 * 60);
|
|
635
|
+
if (diffMins > parseInt(timeFilter)) return false;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Custom time range
|
|
639
|
+
if (timeFilter === 'custom') {
|
|
640
|
+
const recordedAt = new Date(c.recorded_at);
|
|
641
|
+
if (timeFrom && recordedAt < new Date(timeFrom)) return false;
|
|
642
|
+
if (timeTo && recordedAt > new Date(timeTo)) return false;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return true;
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function sortCassettes(arr) {
|
|
650
|
+
return [...arr].sort((a, b) => {
|
|
651
|
+
let aVal = a[sortColumn];
|
|
652
|
+
let bVal = b[sortColumn];
|
|
653
|
+
|
|
654
|
+
if (typeof aVal === 'string') {
|
|
655
|
+
aVal = aVal.toLowerCase();
|
|
656
|
+
bVal = bVal.toLowerCase();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
|
660
|
+
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
|
661
|
+
return 0;
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function updateSortIndicators() {
|
|
666
|
+
document.querySelectorAll('.sortable').forEach(th => {
|
|
667
|
+
th.classList.remove('sort-asc', 'sort-desc');
|
|
668
|
+
if (th.dataset.sort === sortColumn) {
|
|
669
|
+
th.classList.add(sortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function setupEventListeners() {
|
|
675
|
+
document.getElementById('search').addEventListener('input', renderTable);
|
|
676
|
+
document.getElementById('method-filter').addEventListener('change', renderTable);
|
|
677
|
+
document.getElementById('status-filter').addEventListener('change', renderTable);
|
|
678
|
+
document.getElementById('duration-filter').addEventListener('change', renderTable);
|
|
679
|
+
document.getElementById('time-filter').addEventListener('change', function() {
|
|
680
|
+
const customDiv = document.getElementById('custom-time-range');
|
|
681
|
+
if (this.value === 'custom') {
|
|
682
|
+
customDiv.style.display = 'flex';
|
|
683
|
+
} else {
|
|
684
|
+
customDiv.style.display = 'none';
|
|
685
|
+
}
|
|
686
|
+
renderTable();
|
|
687
|
+
});
|
|
688
|
+
document.getElementById('time-from').addEventListener('change', renderTable);
|
|
689
|
+
document.getElementById('time-to').addEventListener('change', renderTable);
|
|
690
|
+
document.getElementById('clear-filters').addEventListener('click', () => {
|
|
691
|
+
document.getElementById('search').value = '';
|
|
692
|
+
document.getElementById('method-filter').value = '';
|
|
693
|
+
document.getElementById('status-filter').value = '';
|
|
694
|
+
document.getElementById('duration-filter').value = '';
|
|
695
|
+
document.getElementById('time-filter').value = '';
|
|
696
|
+
document.getElementById('time-from').value = '';
|
|
697
|
+
document.getElementById('time-to').value = '';
|
|
698
|
+
document.getElementById('custom-time-range').style.display = 'none';
|
|
699
|
+
renderTable();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
document.querySelectorAll('.sortable').forEach(th => {
|
|
703
|
+
th.addEventListener('click', () => {
|
|
704
|
+
if (sortColumn === th.dataset.sort) {
|
|
705
|
+
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
|
706
|
+
} else {
|
|
707
|
+
sortColumn = th.dataset.sort;
|
|
708
|
+
sortDirection = 'desc';
|
|
709
|
+
}
|
|
710
|
+
renderTable();
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
document.querySelector('.modal-close').addEventListener('click', hideModal);
|
|
715
|
+
document.getElementById('detail-modal').addEventListener('click', e => {
|
|
716
|
+
if (e.target.id === 'detail-modal') hideModal();
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
let currentCassette = null;
|
|
721
|
+
|
|
722
|
+
function showDetail(c) {
|
|
723
|
+
if (!c) return;
|
|
724
|
+
currentCassette = c;
|
|
725
|
+
|
|
726
|
+
const modal = document.getElementById('detail-modal');
|
|
727
|
+
const body = document.getElementById('modal-body');
|
|
728
|
+
|
|
729
|
+
body.innerHTML = `
|
|
730
|
+
<div class="detail-section">
|
|
731
|
+
<h3>Request Overview</h3>
|
|
732
|
+
<div class="detail-grid">
|
|
733
|
+
<span class="detail-label">Method</span>
|
|
734
|
+
<span class="detail-value"><span class="method-badge method-${c.method}">${c.method}</span></span>
|
|
735
|
+
<span class="detail-label">Endpoint</span>
|
|
736
|
+
<span class="detail-value">${escapeHtml(c.endpoint)}</span>
|
|
737
|
+
<span class="detail-label">Status</span>
|
|
738
|
+
<span class="detail-value"><span class="status-badge ${getStatusClass(c.status)}">${c.status}</span></span>
|
|
739
|
+
<span class="detail-label">Duration</span>
|
|
740
|
+
<span class="detail-value">${c.duration_ms.toFixed(2)}ms</span>
|
|
741
|
+
<span class="detail-label">Recorded</span>
|
|
742
|
+
<span class="detail-value">${c.recorded_at}</span>
|
|
743
|
+
<span class="detail-label">File</span>
|
|
744
|
+
<span class="detail-value">${escapeHtml(c.filename)}</span>
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
|
|
748
|
+
${c.events.length > 0 ? `
|
|
749
|
+
<div class="detail-section">
|
|
750
|
+
<h3>Dependency Events (${c.events.length})</h3>
|
|
751
|
+
<div class="events-list">
|
|
752
|
+
${c.events.map(e => `
|
|
753
|
+
<div class="event-item">
|
|
754
|
+
<span class="method-badge method-${e.method || 'GET'}">${e.method || e.type}</span>
|
|
755
|
+
<span class="event-url" title="${escapeHtml(e.url || '')}">${escapeHtml(e.url || e.type)}</span>
|
|
756
|
+
<span class="status-badge ${getStatusClass(e.status || 200)}">${e.status || '-'}</span>
|
|
757
|
+
<span class="${getDurationClass(e.duration_ms)}">${e.duration_ms.toFixed(0)}ms</span>
|
|
758
|
+
</div>
|
|
759
|
+
`).join('')}
|
|
760
|
+
</div>
|
|
761
|
+
` : ''}
|
|
762
|
+
|
|
763
|
+
${c.error_info ? `
|
|
764
|
+
<div class="detail-section">
|
|
765
|
+
<h3 style="color:#ff6b6b;">⚠ Error Details</h3>
|
|
766
|
+
<div class="detail-grid">
|
|
767
|
+
<span class="detail-label">Type</span>
|
|
768
|
+
<span class="detail-value" style="color:#ff6b6b;">${escapeHtml(c.error_info.type || 'Unknown')}</span>
|
|
769
|
+
<span class="detail-label">Message</span>
|
|
770
|
+
<span class="detail-value">${escapeHtml(c.error_info.message || 'No message')}</span>
|
|
771
|
+
</div>
|
|
772
|
+
${c.error_info.traceback ? `
|
|
773
|
+
<h4 style="margin-top:16px;color:#888;">Stack Trace</h4>
|
|
774
|
+
<pre style="background:rgba(255,80,80,0.1);border:1px solid rgba(255,80,80,0.3);padding:16px;border-radius:8px;font-size:0.75rem;max-height:300px;overflow:auto;color:#ccc;white-space:pre-wrap;">${escapeHtml(c.error_info.traceback)}</pre>
|
|
775
|
+
` : ''}
|
|
776
|
+
</div>
|
|
777
|
+
` : ''}
|
|
778
|
+
|
|
779
|
+
<div class="detail-section">
|
|
780
|
+
<h3>Replay Command</h3>
|
|
781
|
+
<code id="replay-cmd" class="detail-value" style="display:block;background:rgba(0,0,0,0.3);padding:12px;border-radius:8px;font-size:0.8rem;"></code>
|
|
782
|
+
<button class="copy-btn" id="copy-btn">Copy Command</button>
|
|
783
|
+
</div>
|
|
784
|
+
|
|
785
|
+
<div class="detail-section">
|
|
786
|
+
<h3 style="cursor:pointer" onclick="toggleRawJson()">
|
|
787
|
+
Raw JSON <span id="json-toggle">▼</span>
|
|
788
|
+
</h3>
|
|
789
|
+
<pre id="raw-json" style="display:none;background:rgba(0,0,0,0.4);padding:16px;border-radius:8px;font-size:0.75rem;max-height:400px;overflow:auto;color:#aaa;"></pre>
|
|
790
|
+
</div>
|
|
791
|
+
`;
|
|
792
|
+
|
|
793
|
+
// Set replay command text (avoids escaping issues)
|
|
794
|
+
document.getElementById('replay-cmd').textContent =
|
|
795
|
+
'TIMETRACER_MODE=replay TIMETRACER_CASSETTE="' + c.path + '" uvicorn app:app';
|
|
796
|
+
|
|
797
|
+
// Set raw JSON with syntax highlighting
|
|
798
|
+
document.getElementById('raw-json').innerHTML = syntaxHighlight(JSON.stringify(c, null, 2));
|
|
799
|
+
|
|
800
|
+
// Add copy button handler
|
|
801
|
+
document.getElementById('copy-btn').onclick = copyReplayCommand;
|
|
802
|
+
|
|
803
|
+
modal.classList.add('show');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function toggleRawJson() {
|
|
807
|
+
const el = document.getElementById('raw-json');
|
|
808
|
+
const toggle = document.getElementById('json-toggle');
|
|
809
|
+
if (el.style.display === 'none') {
|
|
810
|
+
el.style.display = 'block';
|
|
811
|
+
toggle.textContent = '▲';
|
|
812
|
+
} else {
|
|
813
|
+
el.style.display = 'none';
|
|
814
|
+
toggle.textContent = '▼';
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function hideModal() {
|
|
819
|
+
document.getElementById('detail-modal').classList.remove('show');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function copyReplayCommand() {
|
|
823
|
+
if (!currentCassette) return;
|
|
824
|
+
const cmd = 'TIMETRACER_MODE=replay TIMETRACER_CASSETTE="' + currentCassette.path + '" uvicorn app:app';
|
|
825
|
+
navigator.clipboard.writeText(cmd).then(() => {
|
|
826
|
+
alert('Copied to clipboard!');
|
|
827
|
+
}).catch(() => {
|
|
828
|
+
prompt('Copy this command:', cmd);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function formatTime(isoString) {
|
|
833
|
+
if (!isoString) return '-';
|
|
834
|
+
return isoString.substring(11, 19);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function getStatusClass(status) {
|
|
838
|
+
if (status >= 400) return 'status-error';
|
|
839
|
+
if (status >= 300) return 'status-redirect';
|
|
840
|
+
return 'status-success';
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function getDurationClass(ms) {
|
|
844
|
+
if (ms > 1000) return 'duration-cell duration-slow';
|
|
845
|
+
if (ms > 300) return 'duration-cell duration-medium';
|
|
846
|
+
return 'duration-cell duration-fast';
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function escapeHtml(str) {
|
|
850
|
+
if (!str) return '';
|
|
851
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function syntaxHighlight(json) {
|
|
855
|
+
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
856
|
+
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\\s*:)?|\b(true|false|null)\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g, function (match) {
|
|
857
|
+
let cls = 'json-number';
|
|
858
|
+
if (/^"/.test(match)) {
|
|
859
|
+
if (/:$/.test(match)) {
|
|
860
|
+
cls = 'json-key';
|
|
861
|
+
} else {
|
|
862
|
+
cls = 'json-string';
|
|
863
|
+
}
|
|
864
|
+
} else if (/true|false/.test(match)) {
|
|
865
|
+
cls = 'json-boolean';
|
|
866
|
+
} else if (/null/.test(match)) {
|
|
867
|
+
cls = 'json-null';
|
|
868
|
+
}
|
|
869
|
+
return '<span class="' + cls + '">' + match + '</span>';
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
init();
|
|
874
|
+
"""
|