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,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dashboard data generator.
|
|
3
|
+
|
|
4
|
+
Scans cassette directories and builds data for the dashboard view.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CassetteSummary:
|
|
17
|
+
"""Summary of a single cassette for dashboard display."""
|
|
18
|
+
|
|
19
|
+
path: str
|
|
20
|
+
filename: str
|
|
21
|
+
method: str
|
|
22
|
+
endpoint: str
|
|
23
|
+
status: int
|
|
24
|
+
duration_ms: float
|
|
25
|
+
recorded_at: str
|
|
26
|
+
event_count: int
|
|
27
|
+
is_error: bool
|
|
28
|
+
service: str = ""
|
|
29
|
+
env: str = ""
|
|
30
|
+
|
|
31
|
+
# For expandable details
|
|
32
|
+
request_headers: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
response_headers: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
35
|
+
error_info: dict[str, Any] | None = None # Stack trace, error type, message
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
"""Convert to dictionary for JSON serialization."""
|
|
39
|
+
return {
|
|
40
|
+
"path": self.path,
|
|
41
|
+
"filename": self.filename,
|
|
42
|
+
"method": self.method,
|
|
43
|
+
"endpoint": self.endpoint,
|
|
44
|
+
"status": self.status,
|
|
45
|
+
"duration_ms": self.duration_ms,
|
|
46
|
+
"recorded_at": self.recorded_at,
|
|
47
|
+
"event_count": self.event_count,
|
|
48
|
+
"is_error": self.is_error,
|
|
49
|
+
"service": self.service,
|
|
50
|
+
"env": self.env,
|
|
51
|
+
"request_headers": self.request_headers,
|
|
52
|
+
"response_headers": self.response_headers,
|
|
53
|
+
"events": self.events,
|
|
54
|
+
"error_info": self.error_info,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class DashboardData:
|
|
60
|
+
"""Complete dashboard data for rendering."""
|
|
61
|
+
|
|
62
|
+
title: str
|
|
63
|
+
cassette_dir: str
|
|
64
|
+
generated_at: str
|
|
65
|
+
cassettes: list[CassetteSummary] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
# Stats
|
|
68
|
+
total_count: int = 0
|
|
69
|
+
error_count: int = 0
|
|
70
|
+
success_count: int = 0
|
|
71
|
+
|
|
72
|
+
# Unique values for filters
|
|
73
|
+
methods: list[str] = field(default_factory=list)
|
|
74
|
+
endpoints: list[str] = field(default_factory=list)
|
|
75
|
+
statuses: list[int] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
"""Convert to dictionary for JSON/template use."""
|
|
79
|
+
return {
|
|
80
|
+
"title": self.title,
|
|
81
|
+
"cassette_dir": self.cassette_dir,
|
|
82
|
+
"generated_at": self.generated_at,
|
|
83
|
+
"cassettes": [c.to_dict() for c in self.cassettes],
|
|
84
|
+
"stats": {
|
|
85
|
+
"total": self.total_count,
|
|
86
|
+
"errors": self.error_count,
|
|
87
|
+
"success": self.success_count,
|
|
88
|
+
},
|
|
89
|
+
"filters": {
|
|
90
|
+
"methods": self.methods,
|
|
91
|
+
"endpoints": self.endpoints,
|
|
92
|
+
"statuses": self.statuses,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def generate_dashboard(cassette_dir: str, limit: int = 500) -> DashboardData:
|
|
98
|
+
"""
|
|
99
|
+
Generate dashboard data from a cassette directory.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
cassette_dir: Path to the cassettes directory.
|
|
103
|
+
limit: Maximum number of cassettes to include.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
DashboardData ready for rendering.
|
|
107
|
+
"""
|
|
108
|
+
from datetime import datetime
|
|
109
|
+
|
|
110
|
+
dir_path = Path(cassette_dir).resolve()
|
|
111
|
+
|
|
112
|
+
dashboard = DashboardData(
|
|
113
|
+
title="Timetracer Dashboard",
|
|
114
|
+
cassette_dir=str(dir_path),
|
|
115
|
+
generated_at=datetime.now().isoformat(),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if not dir_path.exists():
|
|
119
|
+
return dashboard
|
|
120
|
+
|
|
121
|
+
# Find all cassette files
|
|
122
|
+
cassette_files: list[tuple[Path, float]] = []
|
|
123
|
+
for json_file in dir_path.rglob("*.json"):
|
|
124
|
+
# Skip index files
|
|
125
|
+
if json_file.name == "index.json":
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
mtime = json_file.stat().st_mtime
|
|
129
|
+
cassette_files.append((json_file, mtime))
|
|
130
|
+
except OSError:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Sort by modification time (newest first)
|
|
134
|
+
cassette_files.sort(key=lambda x: x[1], reverse=True)
|
|
135
|
+
|
|
136
|
+
# Limit results
|
|
137
|
+
cassette_files = cassette_files[:limit]
|
|
138
|
+
|
|
139
|
+
# Track unique values for filters
|
|
140
|
+
methods_set: set[str] = set()
|
|
141
|
+
endpoints_set: set[str] = set()
|
|
142
|
+
statuses_set: set[int] = set()
|
|
143
|
+
|
|
144
|
+
# Process each cassette
|
|
145
|
+
for file_path, _ in cassette_files:
|
|
146
|
+
try:
|
|
147
|
+
summary = _load_cassette_summary(file_path, dir_path)
|
|
148
|
+
if summary:
|
|
149
|
+
dashboard.cassettes.append(summary)
|
|
150
|
+
|
|
151
|
+
# Track filters
|
|
152
|
+
methods_set.add(summary.method)
|
|
153
|
+
endpoints_set.add(summary.endpoint)
|
|
154
|
+
statuses_set.add(summary.status)
|
|
155
|
+
|
|
156
|
+
# Track stats
|
|
157
|
+
if summary.is_error:
|
|
158
|
+
dashboard.error_count += 1
|
|
159
|
+
else:
|
|
160
|
+
dashboard.success_count += 1
|
|
161
|
+
except Exception:
|
|
162
|
+
# Skip malformed cassettes
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
dashboard.total_count = len(dashboard.cassettes)
|
|
166
|
+
dashboard.methods = sorted(methods_set)
|
|
167
|
+
dashboard.endpoints = sorted(endpoints_set)
|
|
168
|
+
dashboard.statuses = sorted(statuses_set)
|
|
169
|
+
|
|
170
|
+
return dashboard
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _load_cassette_summary(file_path: Path, base_dir: Path) -> CassetteSummary | None:
|
|
174
|
+
"""Load a cassette file and extract summary data."""
|
|
175
|
+
try:
|
|
176
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
177
|
+
data = json.load(f)
|
|
178
|
+
except (json.JSONDecodeError, OSError):
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
# Extract request info
|
|
182
|
+
request = data.get("request", {})
|
|
183
|
+
response = data.get("response", {})
|
|
184
|
+
session = data.get("session", {})
|
|
185
|
+
events = data.get("events", [])
|
|
186
|
+
|
|
187
|
+
method = request.get("method", "UNKNOWN")
|
|
188
|
+
path = request.get("route_template") or request.get("path", "/unknown")
|
|
189
|
+
status = response.get("status", 0)
|
|
190
|
+
duration_ms = response.get("duration_ms", 0)
|
|
191
|
+
recorded_at = session.get("recorded_at", "")
|
|
192
|
+
|
|
193
|
+
# Extract headers (redacted versions are fine)
|
|
194
|
+
req_headers = request.get("headers", {})
|
|
195
|
+
res_headers = response.get("headers", {})
|
|
196
|
+
|
|
197
|
+
# Build event summaries
|
|
198
|
+
event_summaries = []
|
|
199
|
+
for event in events:
|
|
200
|
+
sig = event.get("signature", {})
|
|
201
|
+
result = event.get("result", {})
|
|
202
|
+
event_summaries.append({
|
|
203
|
+
"type": event.get("event_type", "unknown"),
|
|
204
|
+
"method": sig.get("method", ""),
|
|
205
|
+
"url": sig.get("url", ""),
|
|
206
|
+
"status": result.get("status"),
|
|
207
|
+
"duration_ms": event.get("duration_ms", 0),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
# Extract error info if present
|
|
211
|
+
error_info = data.get("error_info")
|
|
212
|
+
|
|
213
|
+
return CassetteSummary(
|
|
214
|
+
path=str(file_path),
|
|
215
|
+
filename=file_path.name,
|
|
216
|
+
method=method,
|
|
217
|
+
endpoint=path,
|
|
218
|
+
status=status,
|
|
219
|
+
duration_ms=duration_ms,
|
|
220
|
+
recorded_at=recorded_at,
|
|
221
|
+
event_count=len(events),
|
|
222
|
+
is_error=status >= 400,
|
|
223
|
+
service=session.get("service", ""),
|
|
224
|
+
env=session.get("env", ""),
|
|
225
|
+
request_headers=req_headers if isinstance(req_headers, dict) else {},
|
|
226
|
+
response_headers=res_headers if isinstance(res_headers, dict) else {},
|
|
227
|
+
events=event_summaries,
|
|
228
|
+
error_info=error_info,
|
|
229
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live dashboard server with replay capability.
|
|
3
|
+
|
|
4
|
+
Provides a web interface with actual replay functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
from timetracer.dashboard.generator import generate_dashboard
|
|
16
|
+
from timetracer.dashboard.template import render_dashboard_html
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DashboardHandler(SimpleHTTPRequestHandler):
|
|
20
|
+
"""HTTP handler for the dashboard server."""
|
|
21
|
+
|
|
22
|
+
cassette_dir: str = "./cassettes"
|
|
23
|
+
app_command: str = "uvicorn app:app"
|
|
24
|
+
|
|
25
|
+
def do_GET(self) -> None:
|
|
26
|
+
"""Handle GET requests."""
|
|
27
|
+
parsed = urlparse(self.path)
|
|
28
|
+
|
|
29
|
+
if parsed.path == "/" or parsed.path == "/dashboard":
|
|
30
|
+
self._serve_dashboard()
|
|
31
|
+
elif parsed.path == "/api/cassettes":
|
|
32
|
+
self._serve_cassettes_api()
|
|
33
|
+
elif parsed.path == "/api/cassette":
|
|
34
|
+
self._serve_cassette_detail(parsed.query)
|
|
35
|
+
else:
|
|
36
|
+
self.send_error(404, "Not Found")
|
|
37
|
+
|
|
38
|
+
def do_POST(self) -> None:
|
|
39
|
+
"""Handle POST requests."""
|
|
40
|
+
parsed = urlparse(self.path)
|
|
41
|
+
|
|
42
|
+
if parsed.path == "/api/replay":
|
|
43
|
+
self._handle_replay()
|
|
44
|
+
else:
|
|
45
|
+
self.send_error(404, "Not Found")
|
|
46
|
+
|
|
47
|
+
def _serve_dashboard(self) -> None:
|
|
48
|
+
"""Serve the dashboard HTML with live features."""
|
|
49
|
+
dashboard_data = generate_dashboard(self.cassette_dir, limit=500)
|
|
50
|
+
html = render_live_dashboard_html(dashboard_data)
|
|
51
|
+
|
|
52
|
+
self.send_response(200)
|
|
53
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
54
|
+
self.end_headers()
|
|
55
|
+
self.wfile.write(html.encode("utf-8"))
|
|
56
|
+
|
|
57
|
+
def _serve_cassettes_api(self) -> None:
|
|
58
|
+
"""Serve cassettes as JSON API."""
|
|
59
|
+
dashboard_data = generate_dashboard(self.cassette_dir, limit=500)
|
|
60
|
+
|
|
61
|
+
self.send_response(200)
|
|
62
|
+
self.send_header("Content-type", "application/json")
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.wfile.write(json.dumps(dashboard_data.to_dict()).encode("utf-8"))
|
|
65
|
+
|
|
66
|
+
def _serve_cassette_detail(self, query: str) -> None:
|
|
67
|
+
"""Serve full cassette JSON."""
|
|
68
|
+
params = parse_qs(query)
|
|
69
|
+
path = params.get("path", [None])[0]
|
|
70
|
+
|
|
71
|
+
if not path or not Path(path).exists():
|
|
72
|
+
self.send_error(404, "Cassette not found")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
77
|
+
cassette = json.load(f)
|
|
78
|
+
|
|
79
|
+
self.send_response(200)
|
|
80
|
+
self.send_header("Content-type", "application/json")
|
|
81
|
+
self.end_headers()
|
|
82
|
+
self.wfile.write(json.dumps(cassette, indent=2).encode("utf-8"))
|
|
83
|
+
except Exception as e:
|
|
84
|
+
self.send_error(500, str(e))
|
|
85
|
+
|
|
86
|
+
def _handle_replay(self) -> None:
|
|
87
|
+
"""Handle replay request."""
|
|
88
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
89
|
+
body = self.rfile.read(content_length)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(body)
|
|
93
|
+
cassette_path = data.get("cassette_path")
|
|
94
|
+
|
|
95
|
+
if not cassette_path or not Path(cassette_path).exists():
|
|
96
|
+
self._send_json({"error": "Cassette not found"}, 404)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Load the cassette
|
|
100
|
+
with open(cassette_path, "r", encoding="utf-8") as f:
|
|
101
|
+
cassette = json.load(f)
|
|
102
|
+
|
|
103
|
+
request = cassette.get("request", {})
|
|
104
|
+
response = cassette.get("response", {})
|
|
105
|
+
events = cassette.get("events", [])
|
|
106
|
+
|
|
107
|
+
# Return the replay data (simulated replay without starting server)
|
|
108
|
+
replay_result = {
|
|
109
|
+
"success": True,
|
|
110
|
+
"cassette_path": cassette_path,
|
|
111
|
+
"request": {
|
|
112
|
+
"method": request.get("method"),
|
|
113
|
+
"path": request.get("path"),
|
|
114
|
+
"headers": request.get("headers", {}),
|
|
115
|
+
},
|
|
116
|
+
"response": {
|
|
117
|
+
"status": response.get("status"),
|
|
118
|
+
"duration_ms": response.get("duration_ms"),
|
|
119
|
+
"body": response.get("body"),
|
|
120
|
+
},
|
|
121
|
+
"events": [
|
|
122
|
+
{
|
|
123
|
+
"type": e.get("event_type"),
|
|
124
|
+
"url": e.get("signature", {}).get("url"),
|
|
125
|
+
"status": e.get("result", {}).get("status"),
|
|
126
|
+
"duration_ms": e.get("duration_ms"),
|
|
127
|
+
}
|
|
128
|
+
for e in events
|
|
129
|
+
],
|
|
130
|
+
"message": "Replay data loaded. This shows what would happen if you replayed this cassette.",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
self._send_json(replay_result, 200)
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
self._send_json({"error": str(e)}, 500)
|
|
137
|
+
|
|
138
|
+
def _send_json(self, data: dict[str, Any], status: int = 200) -> None:
|
|
139
|
+
"""Send JSON response."""
|
|
140
|
+
self.send_response(status)
|
|
141
|
+
self.send_header("Content-type", "application/json")
|
|
142
|
+
self.end_headers()
|
|
143
|
+
self.wfile.write(json.dumps(data).encode("utf-8"))
|
|
144
|
+
|
|
145
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
146
|
+
"""Log requests to console."""
|
|
147
|
+
print(f"[{self.command}] {self.path} - {args[1] if len(args) > 1 else ''}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_live_dashboard_html(data: Any) -> str:
|
|
151
|
+
"""Render dashboard with live replay capability."""
|
|
152
|
+
# Get the base dashboard HTML
|
|
153
|
+
base_html = render_dashboard_html(data)
|
|
154
|
+
|
|
155
|
+
# Inject live replay script
|
|
156
|
+
live_script = """
|
|
157
|
+
<script>
|
|
158
|
+
// Override replay button to use live API
|
|
159
|
+
function liveReplay(cassettePath) {
|
|
160
|
+
const modal = document.getElementById('detail-modal');
|
|
161
|
+
const body = document.getElementById('modal-body');
|
|
162
|
+
|
|
163
|
+
body.innerHTML = '<div style="text-align:center;padding:40px;"><h3>Loading replay...</h3></div>';
|
|
164
|
+
modal.classList.add('show');
|
|
165
|
+
|
|
166
|
+
fetch('/api/replay', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {'Content-Type': 'application/json'},
|
|
169
|
+
body: JSON.stringify({cassette_path: cassettePath})
|
|
170
|
+
})
|
|
171
|
+
.then(r => r.json())
|
|
172
|
+
.then(result => {
|
|
173
|
+
if (result.error) {
|
|
174
|
+
body.innerHTML = '<div style="color:#ff6b6b;padding:20px;">Error: ' + result.error + '</div>';
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
body.innerHTML = `
|
|
179
|
+
<div class="detail-section">
|
|
180
|
+
<h3 style="color:#00ff88;">Replay Result</h3>
|
|
181
|
+
<div class="detail-grid">
|
|
182
|
+
<span class="detail-label">Request</span>
|
|
183
|
+
<span class="detail-value">${result.request.method} ${result.request.path}</span>
|
|
184
|
+
<span class="detail-label">Status</span>
|
|
185
|
+
<span class="detail-value"><span class="status-badge ${result.response.status >= 400 ? 'status-error' : 'status-success'}">${result.response.status}</span></span>
|
|
186
|
+
<span class="detail-label">Duration</span>
|
|
187
|
+
<span class="detail-value">${result.response.duration_ms.toFixed(2)}ms</span>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
${result.events.length > 0 ? `
|
|
192
|
+
<div class="detail-section">
|
|
193
|
+
<h3>Mocked Dependencies (${result.events.length})</h3>
|
|
194
|
+
<div class="events-list">
|
|
195
|
+
${result.events.map(e => `
|
|
196
|
+
<div class="event-item">
|
|
197
|
+
<span>${e.type}</span>
|
|
198
|
+
<span class="event-url">${e.url || '-'}</span>
|
|
199
|
+
<span class="status-badge ${(e.status || 200) >= 400 ? 'status-error' : 'status-success'}">${e.status || '-'}</span>
|
|
200
|
+
<span>${e.duration_ms.toFixed(0)}ms</span>
|
|
201
|
+
</div>
|
|
202
|
+
`).join('')}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
` : ''}
|
|
206
|
+
|
|
207
|
+
${result.response.body ? `
|
|
208
|
+
<div class="detail-section">
|
|
209
|
+
<h3>Response Body</h3>
|
|
210
|
+
<pre style="background:rgba(0,0,0,0.4);padding:16px;border-radius:8px;max-height:300px;overflow:auto;font-size:0.8rem;color:#98c379;">${typeof result.response.body === 'string' ? result.response.body : JSON.stringify(result.response.body, null, 2)}</pre>
|
|
211
|
+
</div>
|
|
212
|
+
` : ''}
|
|
213
|
+
|
|
214
|
+
<div class="detail-section">
|
|
215
|
+
<p style="color:#888;font-size:0.85rem;">${result.message}</p>
|
|
216
|
+
</div>
|
|
217
|
+
`;
|
|
218
|
+
})
|
|
219
|
+
.catch(err => {
|
|
220
|
+
body.innerHTML = '<div style="color:#ff6b6b;padding:20px;">Network error: ' + err.message + '</div>';
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
</script>
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
# Insert before </body>
|
|
227
|
+
return base_html.replace("</body>", live_script + "</body>")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def start_server(cassette_dir: str, port: int = 8765) -> None:
|
|
231
|
+
"""Start the dashboard server."""
|
|
232
|
+
DashboardHandler.cassette_dir = cassette_dir
|
|
233
|
+
|
|
234
|
+
server = HTTPServer(("", port), DashboardHandler)
|
|
235
|
+
|
|
236
|
+
print(f"Dashboard server running at http://localhost:{port}")
|
|
237
|
+
print(f"Serving cassettes from: {cassette_dir}")
|
|
238
|
+
print("Press Ctrl+C to stop")
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
server.serve_forever()
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
print("\nShutting down...")
|
|
244
|
+
server.shutdown()
|