rosetta-sql 1.0.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.
@@ -0,0 +1,1457 @@
1
+ """HTML benchmark report generator for Rosetta.
2
+
3
+ Generates a self-contained HTML file with:
4
+ - Dashboard cards (total queries, QPS, duration)
5
+ - Per-DBMS latency stats table with tabs
6
+ - Grouped bar chart (ECharts) for cross-DBMS comparison
7
+ - Expandable raw latency data
8
+ """
9
+
10
+ import html
11
+ import json
12
+ import logging
13
+ import time
14
+
15
+ from ..models import BenchmarkResult, DBMSBenchResult, QueryLatencyStats
16
+
17
+ log = logging.getLogger("rosetta")
18
+
19
+
20
+ def _escape(text: str) -> str:
21
+ return html.escape(text, quote=True)
22
+
23
+
24
+ def _build_data(result: BenchmarkResult) -> dict:
25
+ """Convert BenchmarkResult into a JSON-serialisable dict."""
26
+ dbms_list = []
27
+ for dr in result.dbms_results:
28
+ queries = []
29
+ for qs in dr.query_stats:
30
+ queries.append({
31
+ "name": qs.query_name,
32
+ "sql": qs.sql_template,
33
+ "exec": qs.total_executions,
34
+ "errors": qs.total_errors,
35
+ "avg": round(qs.avg_ms, 3),
36
+ "p50": round(qs.p50_ms, 3),
37
+ "p95": round(qs.p95_ms, 3),
38
+ "p99": round(qs.p99_ms, 3),
39
+ "min": round(qs.min_ms, 3),
40
+ "max": round(qs.max_ms, 3),
41
+ "qps": round(qs.qps, 1),
42
+ "latencies_ms": [round(l, 3) for l in qs.latencies_ms] if qs.latencies_ms else [],
43
+ "has_flamegraph": bool(qs.flamegraph_svg),
44
+ "explain": qs.explain_plan or "",
45
+ "explain_tree": qs.explain_tree or "",
46
+ "error_logs": qs.error_logs[:50] if qs.error_logs else [],
47
+ })
48
+ dbms_list.append({
49
+ "name": dr.dbms_name,
50
+ "overall_qps": round(dr.overall_qps, 1),
51
+ "total_duration": round(dr.total_duration_s, 2),
52
+ "total_queries": dr.total_queries,
53
+ "total_errors": dr.total_errors,
54
+ "table_rows": dr.table_rows,
55
+ "table_rows_detail": dr.table_rows_detail or {},
56
+ "table_schema": dr.table_schema or {},
57
+ "queries": queries,
58
+ })
59
+ return {
60
+ "workload": result.workload_name,
61
+ "mode": result.mode.name,
62
+ "iterations": result.config.iterations,
63
+ "warmup": result.config.warmup,
64
+ "concurrency": result.config.concurrency,
65
+ "duration": result.config.duration,
66
+ "ramp_up": result.config.ramp_up,
67
+ "timestamp": result.timestamp or time.strftime("%Y-%m-%d %H:%M:%S"),
68
+ "run_id": result.run_id or "",
69
+ "table_rows": result.table_rows,
70
+ "table_rows_detail": result.table_rows_detail or {},
71
+ "table_schema": result.table_schema or {},
72
+ "dbms": dbms_list,
73
+ "has_profile": result.config.profile,
74
+ "setup_sql": list(result.setup_sql) if result.setup_sql else [],
75
+ "teardown_sql": list(result.teardown_sql) if result.teardown_sql else [],
76
+ "queries_sql": list(result.queries_sql) if result.queries_sql else [],
77
+ }
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # HTML template
82
+ # ---------------------------------------------------------------------------
83
+
84
+ _HTML_TEMPLATE = r"""<!DOCTYPE html>
85
+ <html lang="en">
86
+ <head>
87
+ <meta charset="UTF-8">
88
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
89
+ <title>Rosetta Benchmark — {{WORKLOAD}}</title>
90
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
91
+ <style>
92
+ :root {
93
+ --bg: #0d1117; --bg2: #161b22; --bg3: #21262d;
94
+ --fg: #c9d1d9; --fg2: #8b949e;
95
+ --green: #3fb950; --red: #f85149; --blue: #58a6ff;
96
+ --yellow: #d29922; --orange: #db8b0b; --purple: #a371f7;
97
+ --border: #30363d; --accent: #1f6feb;
98
+ }
99
+ * { margin: 0; padding: 0; box-sizing: border-box;
100
+ scrollbar-width: thin; scrollbar-color: var(--bg3) var(--bg); }
101
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
102
+ ::-webkit-scrollbar-track { background: var(--bg); border-radius: 6px; }
103
+ ::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 6px;
104
+ border: 2px solid var(--bg); }
105
+ ::-webkit-scrollbar-thumb:hover { background: var(--fg2); }
106
+ ::-webkit-scrollbar-corner { background: var(--bg); }
107
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
108
+ background: var(--bg); color: var(--fg); line-height: 1.5; padding: 20px; }
109
+ .container { max-width: 1400px; margin: 0 auto; }
110
+ h1 { color: var(--fg); margin-bottom: 4px; font-size: 24px; }
111
+ h2 { font-size: 18px; margin-bottom: 16px; color: var(--fg); }
112
+ .meta { color: var(--fg2); font-size: 14px; margin-bottom: 24px; }
113
+ .meta span { margin-right: 16px; }
114
+
115
+ /* Config panel */
116
+ .config-panel { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
117
+ padding: 20px 24px; margin-bottom: 24px; }
118
+ .config-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
119
+ gap: 12px 24px; }
120
+ .config-item { display: flex; flex-direction: column; gap: 2px; }
121
+ .config-item .cfg-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px;
122
+ color: var(--fg2); font-weight: 600; }
123
+ .config-item .cfg-value { font-size: 15px; font-weight: 600; color: var(--fg); }
124
+ .config-item .cfg-value.highlight { color: var(--blue); }
125
+ .config-item .cfg-value .badge-on { display: inline-block; padding: 1px 8px; border-radius: 4px;
126
+ font-size: 12px; font-weight: 600; background: #b91c1c; color: #fff; }
127
+ .config-item .cfg-value .badge-off { display: inline-block; padding: 1px 8px; border-radius: 4px;
128
+ font-size: 12px; font-weight: 600; background: var(--bg3); color: var(--fg2); }
129
+ .config-item .cfg-value .mode-serial { color: var(--green); }
130
+ .config-item .cfg-value .mode-concurrent { color: var(--orange); }
131
+ .table-rows-wrap { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; }
132
+ .table-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bg);
133
+ border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; font-size: 12px;
134
+ line-height: 1.4; transition: border-color 0.15s; }
135
+ .table-chip:hover { border-color: var(--fg2); }
136
+ .table-chip .tc-name { color: var(--blue); font-family: 'SF Mono', Consolas, monospace;
137
+ font-size: 11px; font-weight: 500; }
138
+ .table-chip .tc-count { color: var(--fg); font-weight: 600; font-size: 12px; }
139
+ .table-chip .tc-sep { color: var(--border); }
140
+
141
+ /* Run ID */
142
+ .run-id-wrap { display: inline-flex; align-items: center; gap: 6px; font-family: 'SF Mono', Consolas, monospace; font-size: 12px; color: var(--purple); max-width: 200px; }
143
+ .run-id-text { cursor: default; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
144
+ .run-id-copy { flex-shrink: 0; background: transparent; border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; font-size: 11px; cursor: pointer; color: var(--fg2); transition: all 0.15s; line-height: 1; }
145
+ .run-id-copy:hover { background: var(--bg3); border-color: var(--fg2); color: var(--fg); }
146
+
147
+ /* Cards */
148
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
149
+ gap: 12px; margin-bottom: 24px; }
150
+ .card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
151
+ padding: 16px; }
152
+ .card .label { font-size: 12px; color: var(--fg2); text-transform: uppercase;
153
+ letter-spacing: 0.5px; margin-bottom: 4px; }
154
+ .card .value { font-size: 24px; font-weight: 700; }
155
+ .card .sub { font-size: 12px; color: var(--fg2); margin-top: 2px; }
156
+
157
+ /* Section */
158
+ .section { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
159
+ padding: 20px; margin-bottom: 20px; }
160
+
161
+ /* Tabs */
162
+ .tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
163
+ .tab { padding: 6px 16px; border-radius: 6px; cursor: pointer; font-size: 14px;
164
+ border: 1px solid var(--border); background: var(--bg3); color: var(--fg2);
165
+ transition: all 0.15s; user-select: none; }
166
+ .tab:hover { color: var(--fg); border-color: var(--fg2); }
167
+ .tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
168
+
169
+ /* Table */
170
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
171
+ th { text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--border);
172
+ color: var(--fg2); font-weight: 600; position: sticky; top: 0; background: var(--bg2); }
173
+ th.num { text-align: right; }
174
+ td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
175
+ td.num { text-align: right; font-family: 'SF Mono', Consolas, monospace; font-size: 13px; }
176
+ tr:hover { background: var(--bg3); }
177
+ .qname { font-weight: 600; color: var(--blue); cursor: pointer; }
178
+ .qname:hover { text-decoration: underline; }
179
+ .sql-row td { padding: 4px 12px 12px; border-bottom: 1px solid var(--border); }
180
+ .sql-code { font-family: 'SF Mono', Consolas, monospace; font-size: 12px; color: var(--fg2);
181
+ background: var(--bg3); border-radius: 6px; padding: 8px 12px; white-space: pre-wrap;
182
+ word-break: break-all; line-height: 1.6; }
183
+ .dbms-tag { display:inline-block; padding:2px 8px; border-radius:4px; font-size:12px;
184
+ font-weight:600; color:#fff; white-space:nowrap; }
185
+ .query-group-first td { border-top: 2px solid var(--border); }
186
+ .query-group td { border-bottom-color: rgba(48,54,61,0.4); }
187
+
188
+ /* Query detail selector */
189
+ .q-dropdown-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
190
+ .q-dropdown-wrap label { font-size: 14px; font-weight: 600; color: var(--fg2); white-space: nowrap; }
191
+ .q-dropdown { appearance: none; background: var(--bg3); border: 1px solid var(--border);
192
+ border-radius: 6px; padding: 8px 36px 8px 14px; font-size: 14px; color: var(--fg);
193
+ cursor: pointer; min-width: 240px;
194
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238b949e' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
195
+ background-repeat: no-repeat; background-position: right 12px center;
196
+ transition: border-color 0.15s; }
197
+ .q-dropdown:hover { border-color: var(--fg2); }
198
+ .q-dropdown:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(31,111,235,0.2); }
199
+ .q-dropdown option { background: var(--bg2); color: var(--fg); }
200
+
201
+ /* Query detail panel */
202
+ .q-detail-panel { border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
203
+ background: var(--bg2); }
204
+ .q-empty { padding: 40px; text-align: center; color: var(--fg2); font-size: 14px; }
205
+ .q-sub { padding: 16px; }
206
+ .q-sub-title { font-size: 14px; font-weight: 600; color: var(--fg); margin-bottom: 10px;
207
+ display: flex; align-items: center; gap: 8px; }
208
+ .q-sub-title .q-icon { font-size: 16px; }
209
+ .q-separator { border: none; border-top: 1px solid var(--border); margin: 0; }
210
+
211
+ /* SQL code block */
212
+ .sql-code { font-family: 'SF Mono', Consolas, monospace; font-size: 12px; color: var(--fg2);
213
+ background: var(--bg); border-radius: 6px; padding: 8px 12px; white-space: pre-wrap;
214
+ word-break: break-all; line-height: 1.6; border: 1px solid var(--border); }
215
+
216
+ /* Schema (setup SQL) */
217
+ .schema-block { margin-bottom: 10px; }
218
+ .schema-toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
219
+ font-size: 12px; color: var(--fg2); padding: 4px 10px; border-radius: 6px;
220
+ border: 1px solid var(--border); background: var(--bg3); user-select: none;
221
+ transition: all 0.15s; }
222
+ .schema-toggle:hover { color: var(--fg); border-color: var(--fg2); }
223
+ .schema-toggle .arrow { transition: transform 0.2s; font-size: 10px; }
224
+ .schema-toggle.open .arrow { transform: rotate(90deg); }
225
+ .schema-sql { font-family: 'SF Mono', Consolas, monospace; font-size: 12px; color: var(--fg2);
226
+ background: var(--bg); border-radius: 6px; padding: 12px 16px; white-space: pre-wrap;
227
+ word-break: break-all; line-height: 1.8; border: 1px solid var(--border); margin-top: 8px; }
228
+ .schema-sql .kw { color: var(--purple); font-weight: 600; }
229
+ .schema-sql .type { color: var(--green); }
230
+ .schema-sql .name { color: var(--blue); }
231
+ .schema-sql .num { color: var(--orange); }
232
+ .schema-sql .paren { color: var(--fg2); }
233
+ .schema-sql .str { color: var(--yellow); }
234
+ .schema-count { font-size: 11px; color: var(--fg2); background: var(--bg);
235
+ padding: 1px 8px; border-radius: 10px; }
236
+
237
+ /* EXPLAIN plan */
238
+ .q-explain-wrap { margin-bottom: 8px; }
239
+ .q-explain-label { font-size: 12px; font-weight: 600; margin-bottom: 4px; display: flex;
240
+ align-items: center; gap: 6px; }
241
+ .q-explain { font-family: 'SF Mono', Consolas, monospace; font-size: 11px; color: var(--fg2);
242
+ background: var(--bg); border-radius: 6px; padding: 10px 12px; white-space: pre;
243
+ overflow-x: auto; line-height: 1.5; border: 1px solid var(--border); margin-bottom: 8px; }
244
+ .q-explain-tree { font-size: 12px; line-height: 1.7; }
245
+
246
+ /* Chart container */
247
+ .chart-box { width: 100%; height: 480px; }
248
+
249
+ /* Overall QPS bar */
250
+ .chart-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
251
+ .chart-label { min-width: 120px; font-size: 14px; font-weight: 600; text-align: right; }
252
+ .chart-bar-bg { flex: 1; height: 28px; background: var(--bg3); border-radius: 6px;
253
+ overflow: hidden; position: relative; }
254
+ .chart-bar { height: 100%; border-radius: 6px; display: flex; align-items: center;
255
+ padding: 0 12px; font-size: 13px; font-weight: 600; color: #fff;
256
+ transition: width 0.6s ease; min-width: fit-content; }
257
+ .chart-val { min-width: 80px; font-size: 13px; color: var(--fg2); }
258
+ .c0 { background: linear-gradient(90deg, #2563eb, #3b82f6); }
259
+ .c1 { background: linear-gradient(90deg, #059669, #10b981); }
260
+ .c2 { background: linear-gradient(90deg, #d97706, #f59e0b); }
261
+ .c3 { background: linear-gradient(90deg, #7c3aed, #8b5cf6); }
262
+ .c4 { background: linear-gradient(90deg, #dc2626, #ef4444); }
263
+
264
+ /* Responsive */
265
+ @media (max-width: 768px) {
266
+ .chart-box { height: 360px; }
267
+ .cards { grid-template-columns: repeat(2, 1fr); }
268
+ }
269
+
270
+ /* Flame Graph section */
271
+ .fg-section { margin-bottom: 20px; }
272
+ .fg-nav { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
273
+ .fg-nav-item { padding: 6px 16px; border-radius: 6px; cursor: pointer; font-size: 14px;
274
+ border: 1px solid var(--border); background: var(--bg3); color: var(--fg2);
275
+ transition: all 0.15s; user-select: none; }
276
+ .fg-nav-item:hover { color: var(--fg); border-color: var(--fg2); }
277
+ .fg-nav-item.active { background: #b91c1c; color: #fff; border-color: #b91c1c; }
278
+ .fg-container { width: 100%; overflow-x: auto; background: var(--bg); border-radius: 6px;
279
+ border: 1px solid var(--border); }
280
+ .fg-container svg { width: 100%; height: auto; display: block; }
281
+ .fg-container .fg-frame rect,
282
+ .fg-container .fg-frame text { transition: x 0.25s ease, width 0.25s ease; }
283
+ .fg-empty { padding: 40px; text-align: center; color: var(--fg2); font-size: 14px; }
284
+ .fg-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px;
285
+ font-weight: 600; background: #b91c1c; color: #fff; margin-left: 8px; vertical-align: middle; }
286
+ #fg-tooltip { position: fixed; display: none; background: #161b22; border: 1px solid #30363d;
287
+ border-radius: 6px; padding: 8px 12px; color: #e6edf3;
288
+ font: 12px/1.5 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
289
+ pointer-events: none; z-index: 9999; white-space: nowrap; max-width: 600px;
290
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
291
+ #fg-tooltip .tt-name { font-weight: 600; margin-bottom: 2px; white-space: normal; word-break: break-all; }
292
+ #fg-tooltip .tt-info { color: #8b949e; font-size: 11px; }
293
+
294
+ /* Error logs */
295
+ .err-section { margin-top: 8px; }
296
+ .err-toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
297
+ font-size: 12px; color: var(--red); padding: 4px 10px; border-radius: 6px;
298
+ border: 1px solid rgba(248,81,73,0.3); background: rgba(248,81,73,0.08);
299
+ user-select: none; transition: all 0.15s; font-weight: 600; }
300
+ .err-toggle:hover { border-color: var(--red); background: rgba(248,81,73,0.15); }
301
+ .err-toggle .arrow { transition: transform 0.2s; font-size: 10px; }
302
+ .err-toggle.open .arrow { transform: rotate(90deg); }
303
+ .err-list { margin-top: 8px; max-height: 400px; overflow-y: auto; }
304
+ .err-item { background: var(--bg); border: 1px solid rgba(248,81,73,0.2); border-radius: 6px;
305
+ padding: 10px 12px; margin-bottom: 6px; font-size: 12px; line-height: 1.5; }
306
+ .err-item .err-sql { font-family: 'SF Mono', Consolas, monospace; color: var(--fg2);
307
+ white-space: pre-wrap; word-break: break-all; margin-bottom: 4px; }
308
+ .err-item .err-msg { color: var(--red); font-weight: 600; }
309
+ .sb-log-tabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
310
+ .sb-log-tab {
311
+ padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px;
312
+ border: 1px solid var(--border); background: var(--bg3); color: var(--fg2);
313
+ transition: all 0.15s; user-select: none; font-weight: 600;
314
+ }
315
+ .sb-log-tab:hover { color: var(--fg); border-color: var(--fg2); }
316
+ .sb-log-tab.active { color: #fff; border-color: transparent; }
317
+ .sb-log-content { display: none; }
318
+ .sb-log-content.active { display: block; }
319
+ .sb-log-phase { margin-bottom: 12px; }
320
+ .sb-log-phase-label {
321
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
322
+ color: var(--fg2); font-weight: 600; margin-bottom: 6px;
323
+ }
324
+ .sb-log-pre {
325
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
326
+ padding: 12px 16px; font: 12px/1.6 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
327
+ overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 400px;
328
+ overflow-y: auto; color: var(--fg2);
329
+ }
330
+ .sb-log-pre .cmd { color: #58a6ff; }
331
+ .sb-log-pre .err { color: #f85149; }
332
+ .sb-log-pre .ok { color: #3fb950; }
333
+ .sb-log-pre .num { color: #d29922; font-weight: 600; }
334
+ .sb-log-pre .val { color: #3fb950; font-weight: 600; }
335
+ </style>
336
+ </head>
337
+ <body>
338
+ <div id="fg-tooltip"><div class="tt-name"></div><div class="tt-info"></div></div>
339
+ <div class="container">
340
+ <div style="display:flex;align-items:center;gap:14px;margin-bottom:16px">
341
+ <svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
342
+ <!-- Stone tablet shape -->
343
+ <rect x="4" y="2" width="36" height="40" rx="4" ry="12" fill="#161b22" stroke="#30363d" stroke-width="1.5"/>
344
+ <!-- Three bar columns representing benchmark comparison -->
345
+ <rect x="10" y="22" width="6" height="14" rx="1.5" fill="#3b82f6" opacity="0.9"/>
346
+ <rect x="19" y="14" width="6" height="22" rx="1.5" fill="#10b981" opacity="0.9"/>
347
+ <rect x="28" y="18" width="6" height="18" rx="1.5" fill="#f59e0b" opacity="0.9"/>
348
+ <!-- Top decorative lines (Rosetta stone inscriptions) -->
349
+ <line x1="10" y1="7" x2="34" y2="7" stroke="#30363d" stroke-width="1.2" stroke-linecap="round"/>
350
+ <line x1="10" y1="10" x2="28" y2="10" stroke="#30363d" stroke-width="1.2" stroke-linecap="round"/>
351
+ </svg>
352
+ <h1>Rosetta Benchmark Report</h1>
353
+ <a href="../index.html" style="color:var(--blue);font-size:14px;text-decoration:none;border:1px solid var(--border);border-radius:6px;padding:4px 12px">&#9664; History</a>
354
+ </div>
355
+ <div class="config-panel" id="config-panel"></div>
356
+
357
+ <!-- Dashboard cards -->
358
+ <div class="cards" id="cards"></div>
359
+
360
+ <!-- Main chart: grouped bar chart via ECharts -->
361
+ <div class="section">
362
+ <h2>Cross-DBMS Query Comparison</h2>
363
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
364
+ <span style="font-size:13px;color:var(--fg2)">Metric:</span>
365
+ <div class="tabs" id="chart-metric-tabs" style="margin-bottom:0"></div>
366
+ </div>
367
+ <div id="main-chart" class="chart-box"></div>
368
+ </div>
369
+
370
+ <!-- Overall QPS comparison -->
371
+ <div class="section">
372
+ <h2>Overall QPS</h2>
373
+ <div id="qps-chart"></div>
374
+ </div>
375
+
376
+ <!-- Workload Definition (Setup / Queries / Teardown) -->
377
+ <div class="section" id="schema-section" style="display:none">
378
+ <h2>&#128220; Workload Definition</h2>
379
+ <div id="schema-content"></div>
380
+ </div>
381
+
382
+ <!-- Per-Query detail dropdown -->
383
+ <div class="section">
384
+ <h2>Per-Query Latency Details</h2>
385
+ <div class="q-dropdown-wrap">
386
+ <label for="query-select">Query:</label>
387
+ <select id="query-select" class="q-dropdown">
388
+ <option value="">-- Select a query --</option>
389
+ </select>
390
+ </div>
391
+ <div id="query-detail-panel" class="q-detail-panel">
392
+ <div class="q-empty">Select a query above to view details</div>
393
+ </div>
394
+ </div>
395
+
396
+ <!-- Error Logs -->
397
+ <div class="section" id="error-logs-section" style="display:none">
398
+ <h2>&#9888;&#65039; Error Logs</h2>
399
+ <div id="error-logs-content"></div>
400
+ </div>
401
+ </div>
402
+
403
+ <script>
404
+ const DATA = {{DATA_JSON}};
405
+ const FLAME_GRAPH_DATA = {{FLAMEGRAPH_JSON}};
406
+ const COLORS = ['c0','c1','c2','c3','c4'];
407
+ const ECHARTS_COLORS = ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4','#ec4899'];
408
+
409
+ function esc(s) {
410
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
411
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
412
+ }
413
+ function fmtMs(v) {
414
+ if (v < 1) return v.toFixed(3);
415
+ if (v < 100) return v.toFixed(2);
416
+ return v.toFixed(1);
417
+ }
418
+ function copyToClipboard(text, btn) {
419
+ navigator.clipboard.writeText(text).then(function() {
420
+ var orig = btn.innerHTML;
421
+ btn.innerHTML = '✓';
422
+ btn.disabled = true;
423
+ setTimeout(function() { btn.innerHTML = orig; btn.disabled = false; }, 1500);
424
+ });
425
+ }
426
+
427
+ // -- Config Panel --
428
+ (function() {
429
+ var panel = document.getElementById('config-panel');
430
+ var modeClass = 'mode-serial';
431
+ if (DATA.mode === 'CONCURRENT') modeClass = 'mode-concurrent';
432
+ var items = '';
433
+
434
+ // Run ID (first item with copy button)
435
+ if (DATA.run_id) {
436
+ items += '<div class="config-item"><span class="cfg-label">Run ID</span>' +
437
+ '<span class="cfg-value run-id-wrap">' +
438
+ '<span class="run-id-text" title="' + esc(DATA.run_id) + '">' + esc(DATA.run_id) + '</span>' +
439
+ '<button class="run-id-copy" onclick="copyToClipboard(\'' + esc(DATA.run_id).replace(/'/g, "\\'") + '\',this)">📋</button>' +
440
+ '</span></div>';
441
+ }
442
+
443
+ // Workload
444
+ items += '<div class="config-item"><span class="cfg-label">Workload</span>' +
445
+ '<span class="cfg-value highlight">' + esc(DATA.workload) + '</span></div>';
446
+
447
+ // Mode
448
+ items += '<div class="config-item"><span class="cfg-label">Mode</span>' +
449
+ '<span class="cfg-value"><span class="' + modeClass + '">' + DATA.mode + '</span></span></div>';
450
+
451
+ if (DATA.mode === 'SERIAL') {
452
+ // Iterations
453
+ items += '<div class="config-item"><span class="cfg-label">Iterations</span>' +
454
+ '<span class="cfg-value">' + DATA.iterations + '</span></div>';
455
+ // Warmup
456
+ items += '<div class="config-item"><span class="cfg-label">Warmup</span>' +
457
+ '<span class="cfg-value">' + DATA.warmup + '</span></div>';
458
+ } else if (DATA.mode === 'CONCURRENT') {
459
+ // Concurrency
460
+ items += '<div class="config-item"><span class="cfg-label">Concurrency</span>' +
461
+ '<span class="cfg-value">' + DATA.concurrency + '</span></div>';
462
+ // Duration
463
+ if (DATA.duration > 0) {
464
+ items += '<div class="config-item"><span class="cfg-label">Duration</span>' +
465
+ '<span class="cfg-value">' + DATA.duration + 's</span></div>';
466
+ }
467
+ // Ramp-up
468
+ items += '<div class="config-item"><span class="cfg-label">Ramp-up</span>' +
469
+ '<span class="cfg-value">' + (DATA.ramp_up > 0 ? DATA.ramp_up + 's' : '0') + '</span></div>';
470
+ }
471
+
472
+ // Profiling
473
+ if (DATA.has_profile) {
474
+ items += '<div class="config-item"><span class="cfg-label">Profiling</span>' +
475
+ '<span class="cfg-value"><span class="badge-on">🔥 ON</span></span></div>';
476
+ } else {
477
+ items += '<div class="config-item"><span class="cfg-label">Profiling</span>' +
478
+ '<span class="cfg-value"><span class="badge-off">OFF</span></span></div>';
479
+ }
480
+
481
+ // Time (same row as above)
482
+ items += '<div class="config-item"><span class="cfg-label">Time</span>' +
483
+ '<span class="cfg-value" style="color:var(--fg2);font-weight:400;font-size:13px">' +
484
+ esc(DATA.timestamp) + '</span></div>';
485
+
486
+ // Rows — per-table detail as chips
487
+ var detail = DATA.table_rows_detail || {};
488
+ var tables = Object.keys(detail);
489
+ if (tables.length > 0) {
490
+ var rowsHtml = '<div class="config-item" style="grid-column:1/-1">' +
491
+ '<span class="cfg-label">Table Rows</span><div class="table-rows-wrap">';
492
+ tables.sort();
493
+ tables.forEach(function(t) {
494
+ rowsHtml += '<span class="table-chip">' +
495
+ '<span class="tc-name">' + esc(t) + '</span>' +
496
+ '<span class="tc-sep">·</span>' +
497
+ '<span class="tc-count">' + detail[t].toLocaleString() + '</span></span>';
498
+ });
499
+ rowsHtml += '</div></div>';
500
+ items += rowsHtml;
501
+ } else {
502
+ // Fallback: extract table names from setup SQL
503
+ var setupTables = [];
504
+ (DATA.setup_sql || []).forEach(function(sql) {
505
+ var m = sql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?/i);
506
+ if (m) setupTables.push(m[1]);
507
+ });
508
+ if (setupTables.length > 0) {
509
+ var rowsHtml = '<div class="config-item" style="grid-column:1/-1">' +
510
+ '<span class="cfg-label">Table Rows</span><div class="table-rows-wrap">';
511
+ setupTables.sort();
512
+ setupTables.forEach(function(t) {
513
+ rowsHtml += '<span class="table-chip">' +
514
+ '<span class="tc-name">' + esc(t) + '</span>' +
515
+ '<span class="tc-sep">·</span>' +
516
+ '<span class="tc-count">0</span></span>';
517
+ });
518
+ rowsHtml += '</div></div>';
519
+ items += rowsHtml;
520
+ }
521
+ }
522
+
523
+ panel.innerHTML = '<div class="config-grid">' + items + '</div>';
524
+ })();
525
+
526
+ // -- Dashboard cards --
527
+ (function() {
528
+ const el = document.getElementById('cards');
529
+ DATA.dbms.forEach((d, i) => {
530
+ el.innerHTML += '<div class="card">' +
531
+ '<div class="label">' + esc(d.name) + '</div>' +
532
+ '<div class="value" style="color:' + ECHARTS_COLORS[i % ECHARTS_COLORS.length] + '">' +
533
+ d.overall_qps + ' <span style="font-size:14px;font-weight:400">QPS</span></div>' +
534
+ '<div class="sub">' + d.total_queries + ' queries in ' + d.total_duration + 's' +
535
+ (d.total_errors > 0 ? ' · <span style="color:var(--red)">' + d.total_errors + ' errors</span>' : '') +
536
+ '</div></div>';
537
+ });
538
+ })();
539
+
540
+ // -- ECharts Grouped Bar Chart --
541
+ const METRICS = [
542
+ {key:'avg', label:'Avg Latency (ms)'},
543
+ {key:'p50', label:'P50 Latency (ms)'},
544
+ {key:'p95', label:'P95 Latency (ms)'},
545
+ {key:'p99', label:'P99 Latency (ms)'},
546
+ {key:'qps', label:'QPS (queries/sec)'},
547
+ ];
548
+ let currentMetric = 'avg';
549
+ let mainChart = null;
550
+
551
+ function getAllQueryNames() {
552
+ const names = [];
553
+ DATA.dbms.forEach(d => {
554
+ d.queries.forEach(q => {
555
+ if (names.indexOf(q.name) === -1) names.push(q.name);
556
+ });
557
+ });
558
+ return names;
559
+ }
560
+
561
+ function renderChartMetricTabs() {
562
+ const el = document.getElementById('chart-metric-tabs');
563
+ el.innerHTML = '';
564
+ METRICS.forEach(m => {
565
+ const tab = document.createElement('div');
566
+ tab.className = 'tab' + (m.key === currentMetric ? ' active' : '');
567
+ tab.textContent = m.label.split(' (')[0]; // short label
568
+ tab.onclick = () => { currentMetric = m.key; renderChartMetricTabs(); updateChart(); };
569
+ el.appendChild(tab);
570
+ });
571
+ }
572
+
573
+ function initChart() {
574
+ const dom = document.getElementById('main-chart');
575
+ mainChart = echarts.init(dom, null, {renderer: 'canvas'});
576
+ updateChart();
577
+
578
+ window.addEventListener('resize', () => {
579
+ mainChart && mainChart.resize();
580
+ });
581
+ }
582
+
583
+ function updateChart() {
584
+ if (!mainChart) return;
585
+ const queryNames = getAllQueryNames();
586
+ const mInfo = METRICS.find(m => m.key === currentMetric);
587
+
588
+ const series = DATA.dbms.map((d, i) => {
589
+ const dataMap = {};
590
+ d.queries.forEach(q => { dataMap[q.name] = q[currentMetric]; });
591
+ return {
592
+ name: d.name,
593
+ type: 'bar',
594
+ barGap: '10%',
595
+ barMaxWidth: 40,
596
+ emphasis: { focus: 'series' },
597
+ itemStyle: { borderRadius: [3, 3, 0, 0] },
598
+ data: queryNames.map(qn => dataMap[qn] || 0),
599
+ };
600
+ });
601
+
602
+ const option = {
603
+ color: ECHARTS_COLORS,
604
+ tooltip: {
605
+ trigger: 'axis',
606
+ axisPointer: { type: 'shadow' },
607
+ backgroundColor: '#161b22',
608
+ borderColor: '#30363d',
609
+ textStyle: { color: '#c9d1d9', fontSize: 13 },
610
+ formatter: function(params) {
611
+ let html = '<div style="font-weight:700;margin-bottom:6px">' + esc(params[0].axisValue) + '</div>';
612
+ params.forEach(p => {
613
+ const v = currentMetric === 'qps' ? p.value.toFixed(1) : fmtMs(p.value);
614
+ const unit = currentMetric === 'qps' ? ' qps' : ' ms';
615
+ html += '<div style="display:flex;align-items:center;gap:6px;margin:2px 0">' +
616
+ '<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:' +
617
+ p.color + '"></span>' +
618
+ '<span>' + esc(p.seriesName) + '</span>' +
619
+ '<span style="margin-left:auto;font-weight:600">' + v + unit + '</span></div>';
620
+ });
621
+ return html;
622
+ }
623
+ },
624
+ legend: {
625
+ data: DATA.dbms.map(d => d.name),
626
+ top: 0,
627
+ textStyle: { color: '#8b949e', fontSize: 13 },
628
+ itemWidth: 14, itemHeight: 10, itemGap: 20,
629
+ },
630
+ grid: {
631
+ left: 60, right: 30, top: 50, bottom: queryNames.length > 8 ? 100 : 60,
632
+ containLabel: false,
633
+ },
634
+ xAxis: {
635
+ type: 'category',
636
+ data: queryNames,
637
+ axisLabel: {
638
+ color: '#8b949e',
639
+ fontSize: 12,
640
+ rotate: queryNames.length > 6 ? 35 : 0,
641
+ interval: 0,
642
+ },
643
+ axisLine: { lineStyle: { color: '#30363d' } },
644
+ axisTick: { show: false },
645
+ },
646
+ yAxis: {
647
+ type: 'value',
648
+ name: mInfo.label,
649
+ nameTextStyle: { color: '#8b949e', fontSize: 12, padding: [0, 0, 0, 10] },
650
+ axisLabel: { color: '#8b949e', fontSize: 12 },
651
+ axisLine: { show: false },
652
+ splitLine: { lineStyle: { color: '#21262d', type: 'dashed' } },
653
+ },
654
+ dataZoom: queryNames.length > 12 ? [
655
+ { type: 'slider', bottom: 10, height: 20, borderColor: '#30363d',
656
+ fillerColor: 'rgba(31,111,235,0.2)', handleStyle: { color: '#58a6ff' },
657
+ textStyle: { color: '#8b949e' } },
658
+ { type: 'inside' }
659
+ ] : [],
660
+ series: series,
661
+ animationDuration: 600,
662
+ animationEasing: 'cubicOut',
663
+ };
664
+
665
+ mainChart.setOption(option, true);
666
+ }
667
+
668
+ // -- Overall QPS chart (CSS bar) --
669
+ function renderQpsChart() {
670
+ const el = document.getElementById('qps-chart');
671
+ if (DATA.dbms.length === 0) { el.innerHTML = ''; return; }
672
+ const maxQps = Math.max(...DATA.dbms.map(d => d.overall_qps), 0.001);
673
+ let html = '';
674
+ DATA.dbms.forEach((d, i) => {
675
+ const pct = (d.overall_qps / maxQps * 100).toFixed(1);
676
+ html += '<div class="chart-row">' +
677
+ '<div class="chart-label">' + esc(d.name) + '</div>' +
678
+ '<div class="chart-bar-bg"><div class="chart-bar ' + COLORS[i % COLORS.length] +
679
+ '" style="width:' + pct + '%">' + d.overall_qps + ' QPS</div></div>' +
680
+ '<div class="chart-val">' + d.total_duration + 's</div></div>';
681
+ });
682
+ el.innerHTML = html;
683
+ }
684
+
685
+ // -- Schema (Setup SQL) rendering --
686
+ function highlightSQL(sql) {
687
+ // Tokenise first to protect strings/numbers, then highlight keywords on the rest.
688
+ // This avoids the esc()-then-regex breakage with &#39; entities.
689
+ var tokens = [];
690
+ var re = /('(?:[^'\\]|\\.)*')|(\b\d+(?:\.\d+)?\b)/g;
691
+ var last = 0, m;
692
+ while ((m = re.exec(sql)) !== null) {
693
+ if (m.index > last) tokens.push({type:'code', text: sql.slice(last, m.index)});
694
+ if (m[1] !== undefined) tokens.push({type:'str', text: m[1]});
695
+ else tokens.push({type:'num', text: m[2]});
696
+ last = re.lastIndex;
697
+ }
698
+ if (last < sql.length) tokens.push({type:'code', text: sql.slice(last)});
699
+
700
+ var kwRe = /\b(CREATE|TABLE|IF|NOT|EXISTS|INSERT|INTO|SELECT|FROM|WHERE|VALUES|DROP|ALTER|ADD|SET|PRIMARY|KEY|AUTO_INCREMENT|DEFAULT|NULL|UNIQUE|INDEX|CONSTRAINT|FOREIGN|REFERENCES|ON|DELETE|UPDATE|CASCADE|CHECK|ENGINE|CHARSET|COLLATE|COMMENT|LIMIT|AS|AND|OR|IN|ROUND|RAND|CONCAT|SUM|CASE|WHEN|THEN|ELSE|END|VIRTUAL|STORED|GENERATED|ALWAYS|FLOOR|ELT|JSON_ARRAY|JSON_OBJECT|JSON_LENGTH|GLOBAL|ARRAY|CAST|MEMBER|OF|BETWEEN)\b/gi;
701
+ var typeRe = /\b(INT|INTEGER|BIGINT|SMALLINT|TINYINT|MEDIUMINT|FLOAT|DOUBLE|DECIMAL|NUMERIC|VARCHAR|CHAR|TEXT|BLOB|DATE|DATETIME|TIMESTAMP|TIME|YEAR|BOOLEAN|BOOL|ENUM|JSON)\b/gi;
702
+
703
+ var out = '';
704
+ tokens.forEach(function(t) {
705
+ if (t.type === 'str') {
706
+ out += '<span class="str">' + esc(t.text) + '</span>';
707
+ } else if (t.type === 'num') {
708
+ out += '<span class="num">' + esc(t.text) + '</span>';
709
+ } else {
710
+ var s = esc(t.text);
711
+ s = s.replace(kwRe, '<span class="kw">$1</span>');
712
+ s = s.replace(typeRe, '<span class="type">$1</span>');
713
+ out += s;
714
+ }
715
+ });
716
+ return out;
717
+ }
718
+
719
+ function formatCreateTable(sql) {
720
+ // Smart formatting: only break at top-level commas and the outer parens
721
+ // of the column definition list, not inside type parens like DECIMAL(10,2)
722
+ // or expressions like CASE WHEN ... END.
723
+ if (sql.indexOf('\n') !== -1) return sql; // already multi-line
724
+
725
+ // Find the first '(' that opens the column definition list
726
+ var header = '';
727
+ var body = sql;
728
+ var firstParen = sql.indexOf('(');
729
+ if (firstParen === -1) return sql;
730
+ header = sql.slice(0, firstParen);
731
+ body = sql.slice(firstParen); // starts with '('
732
+
733
+ // Walk the body tracking paren depth; break on commas at depth==1
734
+ var depth = 0;
735
+ var lines = [];
736
+ var cur = '';
737
+ for (var i = 0; i < body.length; i++) {
738
+ var ch = body[i];
739
+ if (ch === "'") {
740
+ // skip string literal
741
+ var j = i + 1;
742
+ while (j < body.length && body[j] !== "'") { if (body[j] === '\\') j++; j++; }
743
+ cur += body.slice(i, j + 1);
744
+ i = j;
745
+ continue;
746
+ }
747
+ if (ch === '(') { depth++; cur += ch; continue; }
748
+ if (ch === ')') {
749
+ depth--;
750
+ if (depth === 0) {
751
+ // This is the closing paren of the column list
752
+ if (cur.length > 0) lines.push(cur);
753
+ cur = body.slice(i); // rest including ')' and ENGINE etc.
754
+ break;
755
+ }
756
+ cur += ch;
757
+ continue;
758
+ }
759
+ if (ch === ',' && depth === 1) {
760
+ lines.push(cur);
761
+ cur = '';
762
+ continue;
763
+ }
764
+ cur += ch;
765
+ }
766
+
767
+ if (lines.length <= 1) return sql; // nothing to format
768
+
769
+ // Build formatted output
770
+ // First line: header + '(' + first column def
771
+ // Remove leading '(' from first item
772
+ var firstCol = lines[0].replace(/^\(\s*/, '');
773
+ var result = header + '(\n ' + firstCol.trim();
774
+ for (var k = 1; k < lines.length; k++) {
775
+ result += ',\n ' + lines[k].trim();
776
+ }
777
+ // Append closing part: ')' + ENGINE/etc suffix
778
+ var suffix = cur.replace(/^\)/, '');
779
+ result += '\n)' + suffix;
780
+ return result;
781
+ }
782
+
783
+ function formatOtherSQL(sql) {
784
+ // Format long INSERT/SET/other SQL for readability
785
+ if (sql.indexOf('\n') !== -1) return sql;
786
+ var s = sql;
787
+ // INSERT INTO ... SELECT: break at major clauses
788
+ if (/^\s*INSERT\s+INTO/i.test(s)) {
789
+ s = s.replace(/\)\s*SELECT\b/gi, ')\nSELECT');
790
+ s = s.replace(/\bSELECT\b/gi, function(m, off) { return off > 0 ? '\nSELECT' : m; });
791
+ s = s.replace(/\bFROM\b/gi, '\nFROM');
792
+ s = s.replace(/\bWHERE\b/gi, '\nWHERE');
793
+ s = s.replace(/\bLIMIT\b/gi, '\nLIMIT');
794
+ // Clean up double newlines
795
+ s = s.replace(/\n\n+/g, '\n');
796
+ s = s.replace(/^\n/, '');
797
+ }
798
+ return s;
799
+ }
800
+
801
+ function renderSchema() {
802
+ var setupSql = DATA.setup_sql || [];
803
+ var teardownSql = DATA.teardown_sql || [];
804
+ var queriesSql = DATA.queries_sql || [];
805
+
806
+ // Show section if any data exists
807
+ if (setupSql.length === 0 && teardownSql.length === 0 && queriesSql.length === 0) return;
808
+
809
+ var section = document.getElementById('schema-section');
810
+ section.style.display = '';
811
+ var container = document.getElementById('schema-content');
812
+
813
+ var html = '';
814
+
815
+ // --- Setup SQL ---
816
+ if (setupSql.length > 0) {
817
+ html += '<div class="schema-block">' +
818
+ '<div class="schema-toggle open" id="setup-toggle" onclick="toggleWorkloadSection(\'setup\')">' +
819
+ '<span class="arrow">&#9654;</span> Setup ' +
820
+ '<span class="schema-count">' + setupSql.length + ' statement(s)</span></div>' +
821
+ '<div id="setup-body">';
822
+ setupSql.forEach(function(sql) {
823
+ var formatted = /^\s*CREATE\s+TABLE/i.test(sql) ? formatCreateTable(sql) : formatOtherSQL(sql);
824
+ html += '<div class="schema-sql" style="margin-top:8px">' + highlightSQL(formatted) + '</div>';
825
+ });
826
+ html += '</div></div>';
827
+ }
828
+
829
+ // --- Queries ---
830
+ if (queriesSql.length > 0) {
831
+ html += '<div class="schema-block">' +
832
+ '<div class="schema-toggle" id="queries-toggle" onclick="toggleWorkloadSection(\'queries\')">' +
833
+ '<span class="arrow">&#9654;</span> Queries ' +
834
+ '<span class="schema-count">' + queriesSql.length + ' query(s)</span></div>' +
835
+ '<div id="queries-body" style="display:none">';
836
+ queriesSql.forEach(function(q) {
837
+ html += '<div style="margin-top:10px">' +
838
+ '<div style="font-size:12px;color:var(--blue);font-weight:600;margin-bottom:4px">' +
839
+ esc(q.name) +
840
+ '<span style="color:var(--fg2);font-weight:400;margin-left:8px">weight: ' + q.weight + '</span>' +
841
+ (q.description ? '<span style="color:var(--fg2);font-weight:400;margin-left:8px;font-style:italic">' + esc(q.description) + '</span>' : '') +
842
+ '</div>' +
843
+ '<div class="schema-sql">' + highlightSQL(q.sql) + '</div>';
844
+ if (q.cleanup_sql) {
845
+ html += '<div style="margin-top:4px;font-size:11px;color:var(--fg2)">cleanup:</div>' +
846
+ '<div class="schema-sql" style="border-color:var(--yellow);opacity:0.7">' + highlightSQL(q.cleanup_sql) + '</div>';
847
+ }
848
+ html += '</div>';
849
+ });
850
+ html += '</div></div>';
851
+ }
852
+
853
+ // --- Teardown SQL ---
854
+ if (teardownSql.length > 0) {
855
+ html += '<div class="schema-block">' +
856
+ '<div class="schema-toggle" id="teardown-toggle" onclick="toggleWorkloadSection(\'teardown\')">' +
857
+ '<span class="arrow">&#9654;</span> Teardown ' +
858
+ '<span class="schema-count">' + teardownSql.length + ' statement(s)</span></div>' +
859
+ '<div id="teardown-body" style="display:none">';
860
+ teardownSql.forEach(function(sql) {
861
+ html += '<div class="schema-sql" style="margin-top:8px">' + highlightSQL(sql) + '</div>';
862
+ });
863
+ html += '</div></div>';
864
+ }
865
+
866
+ container.innerHTML = html;
867
+ }
868
+
869
+ function toggleWorkloadSection(name) {
870
+ var toggle = document.getElementById(name + '-toggle');
871
+ var body = document.getElementById(name + '-body');
872
+ if (body.style.display === 'none') {
873
+ body.style.display = '';
874
+ toggle.classList.add('open');
875
+ } else {
876
+ body.style.display = 'none';
877
+ toggle.classList.remove('open');
878
+ }
879
+ }
880
+
881
+ function toggleErrLogs(id) {
882
+ var toggle = document.getElementById(id + '-toggle');
883
+ var body = document.getElementById(id + '-body');
884
+ if (body.style.display === 'none') {
885
+ body.style.display = '';
886
+ toggle.classList.add('open');
887
+ } else {
888
+ body.style.display = 'none';
889
+ toggle.classList.remove('open');
890
+ }
891
+ }
892
+
893
+ // -- Per-Query Dropdown + Detail Panel --
894
+ const DBMS_COLORS_BG = ['#1d4ed8','#047857','#b45309','#6d28d9','#b91c1c','#0e7490','#be185d'];
895
+ let currentQueryName = '';
896
+
897
+ function renderQuerySelector() {
898
+ const select = document.getElementById('query-select');
899
+ const queryNames = getAllQueryNames();
900
+
901
+ queryNames.forEach(function(qn) {
902
+ const opt = document.createElement('option');
903
+ opt.value = qn;
904
+ opt.textContent = qn;
905
+ select.appendChild(opt);
906
+ });
907
+
908
+ select.addEventListener('change', function() {
909
+ renderQueryDetail(this.value);
910
+ });
911
+
912
+ // Auto-select first query
913
+ if (queryNames.length > 0) {
914
+ select.value = queryNames[0];
915
+ renderQueryDetail(queryNames[0]);
916
+ }
917
+ }
918
+
919
+ function renderQueryDetail(qn) {
920
+ const panel = document.getElementById('query-detail-panel');
921
+ currentQueryName = qn;
922
+
923
+ if (!qn) {
924
+ panel.innerHTML = '<div class="q-empty">Select a query above to view details</div>';
925
+ return;
926
+ }
927
+
928
+ // Collect per-DBMS data for this query
929
+ var perDbms = [];
930
+ var sql = '';
931
+ DATA.dbms.forEach(function(d, di) {
932
+ var q = null;
933
+ d.queries.forEach(function(x) { if (x.name === qn) q = x; });
934
+ if (q) {
935
+ perDbms.push({ dbms: d.name, di: di, q: q });
936
+ if (!sql && q.sql) sql = q.sql;
937
+ }
938
+ });
939
+
940
+ // Build flame graph lookup
941
+ var fgByQuery = {};
942
+ FLAME_GRAPH_DATA.forEach(function(fg) {
943
+ fgByQuery[fg.query] = fg.svg || '';
944
+ });
945
+
946
+ var html = '';
947
+
948
+ // --- Description & SQL ---
949
+ // Look up description and cleanup_sql from queries_sql
950
+ var qMeta = null;
951
+ (DATA.queries_sql || []).forEach(function(qs) { if (qs.name === qn) qMeta = qs; });
952
+
953
+ if (qMeta && qMeta.description) {
954
+ html += '<div class="q-sub" style="padding-bottom:8px"><div style="font-size:13px;color:var(--fg2);font-style:italic">' +
955
+ esc(qMeta.description) + '</div></div><hr class="q-separator">';
956
+ }
957
+
958
+ if (sql) {
959
+ html += '<div class="q-sub"><div class="q-sub-title"><span class="q-icon">\uD83D\uDCDD</span> SQL</div>' +
960
+ '<div class="sql-code">' + esc(sql) + '</div>';
961
+ if (qMeta && qMeta.cleanup_sql) {
962
+ html += '<div style="margin-top:8px;font-size:12px;color:var(--yellow);font-weight:600">Cleanup SQL</div>' +
963
+ '<div class="sql-code" style="margin-top:4px;border-color:var(--yellow);opacity:0.8">' + esc(qMeta.cleanup_sql) + '</div>';
964
+ }
965
+ html += '</div><hr class="q-separator">';
966
+ }
967
+
968
+ // --- Latency Table ---
969
+ html += '<div class="q-sub"><div class="q-sub-title"><span class="q-icon">\u23F1\uFE0F</span> Latency</div>' +
970
+ '<div style="overflow-x:auto"><table>' +
971
+ '<thead><tr><th>DBMS</th>' +
972
+ '<th class="num">Exec</th><th class="num">Errors</th>' +
973
+ '<th class="num">Avg (ms)</th><th class="num">P50 (ms)</th>' +
974
+ '<th class="num">P95 (ms)</th><th class="num">P99 (ms)</th>' +
975
+ '<th class="num">Min (ms)</th><th class="num">Max (ms)</th>' +
976
+ '<th class="num">QPS</th></tr></thead><tbody>';
977
+ perDbms.forEach(function(pd) {
978
+ var q = pd.q;
979
+ var colorBg = DBMS_COLORS_BG[pd.di % DBMS_COLORS_BG.length];
980
+ html += '<tr>' +
981
+ '<td><span class="dbms-tag" style="background:' + colorBg + '">' + esc(pd.dbms) + '</span></td>' +
982
+ '<td class="num">' + q.exec + '</td>' +
983
+ '<td class="num"' + (q.errors > 0 ? ' style="color:var(--red)"' : '') + '>' + q.errors + '</td>' +
984
+ '<td class="num">' + fmtMs(q.avg) + '</td>' +
985
+ '<td class="num">' + fmtMs(q.p50) + '</td>' +
986
+ '<td class="num">' + fmtMs(q.p95) + '</td>' +
987
+ '<td class="num">' + fmtMs(q.p99) + '</td>' +
988
+ '<td class="num">' + fmtMs(q.min) + '</td>' +
989
+ '<td class="num">' + fmtMs(q.max) + '</td>' +
990
+ '<td class="num" style="font-weight:600">' + q.qps.toFixed(1) + '</td></tr>';
991
+ });
992
+ html += '</tbody></table></div></div>';
993
+
994
+ // --- EXPLAIN Plans ---
995
+ var hasAnyExplain = false;
996
+ perDbms.forEach(function(pd) { if (pd.q.explain) hasAnyExplain = true; });
997
+ perDbms.forEach(function(pd) { if (pd.q.explain_tree) hasAnyExplain = true; });
998
+ if (hasAnyExplain) {
999
+ html += '<hr class="q-separator">';
1000
+ html += '<div class="q-sub"><div class="q-sub-title"><span class="q-icon">\uD83D\uDCCA</span> Execution Plan</div>';
1001
+ perDbms.forEach(function(pd) {
1002
+ if (pd.q.explain) {
1003
+ var colorBg = DBMS_COLORS_BG[pd.di % DBMS_COLORS_BG.length];
1004
+ html += '<div class="q-explain-wrap">' +
1005
+ '<div class="q-explain-label"><span class="dbms-tag" style="background:' +
1006
+ colorBg + ';font-size:11px;padding:1px 6px">' + esc(pd.dbms) + '</span></div>' +
1007
+ '<div class="q-explain">' + esc(pd.q.explain) + '</div>';
1008
+ if (pd.q.explain_tree) {
1009
+ html += '<div class="q-explain-label" style="margin-top:8px">Tree:</div>' +
1010
+ '<div class="q-explain q-explain-tree">' + esc(pd.q.explain_tree) + '</div>';
1011
+ }
1012
+ html += '</div>';
1013
+ }
1014
+ });
1015
+ html += '</div>';
1016
+ }
1017
+
1018
+ // --- Flame Graph (tdsql only) ---
1019
+ var fgSvg = fgByQuery[qn] || '';
1020
+ if (fgSvg) {
1021
+ html += '<hr class="q-separator">';
1022
+ html += '<div class="q-sub"><div class="q-sub-title"><span class="q-icon">\uD83D\uDD25</span> CPU Flame Graph ' +
1023
+ '<span class="fg-badge">tdsql \u00B7 perf</span></div>' +
1024
+ '<div class="fg-container" id="fg-detail-' + esc(qn) + '"></div></div>';
1025
+ }
1026
+
1027
+ panel.innerHTML = html;
1028
+
1029
+ // Bind flame graph interactivity after DOM update
1030
+ if (fgSvg) {
1031
+ var fgContainer = document.getElementById('fg-detail-' + esc(qn));
1032
+ if (fgContainer) {
1033
+ fgContainer.innerHTML = fgSvg;
1034
+ fgBindInteractivity(fgContainer);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ function createSep() {
1040
+ var hr = document.createElement('hr');
1041
+ hr.className = 'q-separator';
1042
+ return hr;
1043
+ }
1044
+
1045
+ /**
1046
+ * Bind hover + click-to-zoom interactivity to a flame graph SVG container.
1047
+ * This replaces the SVG-embedded <script> which does not execute via innerHTML.
1048
+ */
1049
+ function fgBindInteractivity(container) {
1050
+ const svg = container.querySelector('svg');
1051
+ if (!svg) return;
1052
+
1053
+ const details = svg.querySelector('.fg-details');
1054
+ const frames = svg.querySelectorAll('.fg-frame');
1055
+ const chartWidth = parseFloat(svg.getAttribute('data-chart-width') || '1180');
1056
+ const xPad = parseFloat(svg.getAttribute('data-x-pad') || '10');
1057
+ const fontSize = 12;
1058
+
1059
+ // -- Tooltip element (shared across all flame graph containers) --
1060
+ var tooltip = document.getElementById('fg-tooltip');
1061
+ var ttName = tooltip ? tooltip.querySelector('.tt-name') : null;
1062
+ var ttInfo = tooltip ? tooltip.querySelector('.tt-info') : null;
1063
+
1064
+ // -- Hover → show tooltip near mouse --
1065
+ frames.forEach(function(g) {
1066
+ g.addEventListener('mouseenter', function() {
1067
+ var name = g.getAttribute('data-name') || '';
1068
+ var samples = g.getAttribute('data-samples') || '';
1069
+ var pct = g.getAttribute('data-pct') || '';
1070
+ if (ttName) ttName.textContent = name;
1071
+ if (ttInfo) ttInfo.textContent = samples + ' samples (' + pct + '%)';
1072
+ if (tooltip) tooltip.style.display = 'block';
1073
+ // highlight
1074
+ var rect = g.querySelector('rect');
1075
+ if (rect) rect.setAttribute('opacity', '0.8');
1076
+ });
1077
+ g.addEventListener('mousemove', function(e) {
1078
+ if (!tooltip) return;
1079
+ // Position tooltip near cursor, offset slightly so it doesn't cover the frame
1080
+ var tx = e.clientX + 12;
1081
+ var ty = e.clientY - 8;
1082
+ // Prevent tooltip from going off the right edge
1083
+ var tw = tooltip.offsetWidth;
1084
+ if (tx + tw > window.innerWidth - 8) {
1085
+ tx = e.clientX - tw - 12;
1086
+ }
1087
+ // Prevent tooltip from going off the bottom edge
1088
+ var th = tooltip.offsetHeight;
1089
+ if (ty + th > window.innerHeight - 8) {
1090
+ ty = e.clientY - th - 8;
1091
+ }
1092
+ tooltip.style.left = tx + 'px';
1093
+ tooltip.style.top = ty + 'px';
1094
+ });
1095
+ g.addEventListener('mouseleave', function() {
1096
+ if (tooltip) tooltip.style.display = 'none';
1097
+ var rect = g.querySelector('rect');
1098
+ if (rect) rect.setAttribute('opacity', '1');
1099
+ });
1100
+ });
1101
+
1102
+ // -- Click to zoom --
1103
+ // We store original positions in data-x, data-y, data-w, data-h.
1104
+ // On click, we scale all frames so the clicked frame fills the full width,
1105
+ // and hide frames that are not in the clicked subtree.
1106
+ var zoomed = false; // track zoom state
1107
+
1108
+ frames.forEach(function(g) {
1109
+ g.style.cursor = 'pointer';
1110
+ g.addEventListener('click', function(e) {
1111
+ e.stopPropagation();
1112
+ var clickX = parseFloat(g.getAttribute('data-x'));
1113
+ var clickW = parseFloat(g.getAttribute('data-w'));
1114
+ var clickY = parseFloat(g.getAttribute('data-y'));
1115
+
1116
+ if (zoomed && clickW > chartWidth * 0.98) {
1117
+ // Clicking a full-width frame while zoomed -> reset
1118
+ fgResetZoom(svg, frames, chartWidth, xPad, fontSize);
1119
+ zoomed = false;
1120
+ return;
1121
+ }
1122
+
1123
+ // Scale factor: how much to expand clicked frame to fill chart width
1124
+ var scale = chartWidth / clickW;
1125
+ var offsetX = clickX - xPad;
1126
+
1127
+ zoomed = true;
1128
+
1129
+ frames.forEach(function(f) {
1130
+ var fx = parseFloat(f.getAttribute('data-x'));
1131
+ var fw = parseFloat(f.getAttribute('data-w'));
1132
+ var fy = parseFloat(f.getAttribute('data-y'));
1133
+ var fh = parseFloat(f.getAttribute('data-h'));
1134
+
1135
+ // Determine visibility:
1136
+ // 1. Frames at the same or deeper level that overlap with clicked range
1137
+ // 2. Ancestor frames (at shallower level) that contain the clicked frame
1138
+ var fRight = fx + fw;
1139
+ var clickRight = clickX + clickW;
1140
+ var overlaps = fx < clickRight && fRight > clickX;
1141
+ var isAncestor = fy > clickY && fx <= clickX && fRight >= clickRight;
1142
+ var isDescendant = fy <= clickY && overlaps;
1143
+
1144
+ if (!overlaps && !isAncestor) {
1145
+ // Not in subtree – hide
1146
+ f.style.display = 'none';
1147
+ return;
1148
+ }
1149
+
1150
+ f.style.display = '';
1151
+ var rect = f.querySelector('rect');
1152
+ var text = f.querySelector('text');
1153
+ var clipRect = f.querySelector('clipPath rect');
1154
+
1155
+ // Compute new position: scale and shift
1156
+ var newX = xPad + (fx - clickX) * scale;
1157
+ var newW = fw * scale;
1158
+
1159
+ // Clamp to chart boundaries
1160
+ if (newX < xPad) {
1161
+ newW -= (xPad - newX);
1162
+ newX = xPad;
1163
+ }
1164
+ if (newX + newW > xPad + chartWidth) {
1165
+ newW = xPad + chartWidth - newX;
1166
+ }
1167
+ if (newW < 0.1) {
1168
+ f.style.display = 'none';
1169
+ return;
1170
+ }
1171
+
1172
+ if (rect) {
1173
+ rect.setAttribute('x', newX.toFixed(1));
1174
+ rect.setAttribute('width', newW.toFixed(1));
1175
+ }
1176
+ // Keep the clipPath rectangle in sync so text is clipped correctly
1177
+ if (clipRect) {
1178
+ clipRect.setAttribute('x', newX.toFixed(1));
1179
+ clipRect.setAttribute('width', newW.toFixed(1));
1180
+ }
1181
+ if (text) {
1182
+ text.setAttribute('x', (newX + 3).toFixed(1));
1183
+ // Recompute label truncation with ellipsis
1184
+ var name = f.getAttribute('data-name') || '';
1185
+ var charW = fontSize * 0.60;
1186
+ var availW = newW - 6;
1187
+ var maxChars = availW > 0 ? Math.floor(availW / charW) : 0;
1188
+ var label;
1189
+ if (maxChars >= name.length) {
1190
+ label = name;
1191
+ } else if (maxChars > 3) {
1192
+ label = name.substring(0, maxChars - 1) + '\u2026';
1193
+ } else if (maxChars > 0) {
1194
+ label = name.substring(0, maxChars);
1195
+ } else {
1196
+ label = '';
1197
+ }
1198
+ text.textContent = label;
1199
+ text.style.display = newW > 12 ? '' : 'none';
1200
+ }
1201
+ });
1202
+
1203
+ if (details) details.textContent = 'Zoomed: ' + g.getAttribute('data-name') +
1204
+ ' — click full-width frame or background to reset';
1205
+ });
1206
+ });
1207
+
1208
+ // Click on SVG background to reset zoom
1209
+ svg.addEventListener('click', function(e) {
1210
+ if (e.target === svg || e.target.tagName === 'rect' && !e.target.closest('.fg-frame')) {
1211
+ if (zoomed) {
1212
+ fgResetZoom(svg, frames, chartWidth, xPad, fontSize);
1213
+ zoomed = false;
1214
+ }
1215
+ }
1216
+ });
1217
+ }
1218
+
1219
+ function fgResetZoom(svg, frames, chartWidth, xPad, fontSize) {
1220
+ var details = svg.querySelector('.fg-details');
1221
+ frames.forEach(function(f) {
1222
+ f.style.display = '';
1223
+ var ox = parseFloat(f.getAttribute('data-x'));
1224
+ var ow = parseFloat(f.getAttribute('data-w'));
1225
+ var rect = f.querySelector('rect');
1226
+ var text = f.querySelector('text');
1227
+ var clipRect = f.querySelector('clipPath rect');
1228
+ if (rect) {
1229
+ rect.setAttribute('x', ox.toFixed(1));
1230
+ rect.setAttribute('width', ow.toFixed(1));
1231
+ }
1232
+ if (clipRect) {
1233
+ clipRect.setAttribute('x', ox.toFixed(1));
1234
+ clipRect.setAttribute('width', ow.toFixed(1));
1235
+ }
1236
+ if (text) {
1237
+ var name = f.getAttribute('data-name') || '';
1238
+ var charW = fontSize * 0.60;
1239
+ var availW = ow - 6;
1240
+ var maxChars = availW > 0 ? Math.floor(availW / charW) : 0;
1241
+ var label;
1242
+ if (maxChars >= name.length) {
1243
+ label = name;
1244
+ } else if (maxChars > 3) {
1245
+ label = name.substring(0, maxChars - 1) + '\u2026';
1246
+ } else if (maxChars > 0) {
1247
+ label = name.substring(0, maxChars);
1248
+ } else {
1249
+ label = '';
1250
+ }
1251
+ text.textContent = label;
1252
+ text.style.display = ow > 12 ? '' : 'none';
1253
+ text.setAttribute('x', (ox + 3).toFixed(1));
1254
+ }
1255
+ });
1256
+ if (details) details.textContent = ' ';
1257
+ }
1258
+
1259
+ // -- Init --
1260
+ renderChartMetricTabs();
1261
+ initChart();
1262
+ renderQpsChart();
1263
+ renderSchema();
1264
+ renderQuerySelector();
1265
+ renderErrorLogs();
1266
+
1267
+ // -- Error Logs Renderer (standalone section) --
1268
+ function renderErrorLogs() {
1269
+ var section = document.getElementById('error-logs-section');
1270
+ var content = document.getElementById('error-logs-content');
1271
+
1272
+ // Check if any DBMS has any error logs across all queries
1273
+ var hasAny = false;
1274
+ DATA.dbms.forEach(function(d) {
1275
+ d.queries.forEach(function(q) {
1276
+ if (q.error_logs && q.error_logs.length > 0) hasAny = true;
1277
+ });
1278
+ });
1279
+ if (!hasAny) return;
1280
+
1281
+ section.style.display = '';
1282
+ var html = '';
1283
+
1284
+ // Build tabs for each DBMS that has errors
1285
+ html += '<div class="tabs" id="err-tabs" style="margin-bottom:16px">';
1286
+ var firstActive = true;
1287
+ DATA.dbms.forEach(function(d, di) {
1288
+ var totalErrs = 0;
1289
+ d.queries.forEach(function(q) { totalErrs += q.errors; });
1290
+ if (totalErrs === 0) return;
1291
+ var colorBg = DBMS_COLORS_BG[di % DBMS_COLORS_BG.length];
1292
+ html += '<div class="tab' + (firstActive ? ' active' : '') + '" ' +
1293
+ 'style="' + (firstActive ? 'background:' + colorBg + ';border-color:' + colorBg + ';color:#fff' : '') + '" ' +
1294
+ 'data-color="' + colorBg + '" ' +
1295
+ 'onclick="switchErrTab(this,\'err-panel-' + di + '\')">' +
1296
+ esc(d.name) + ' <span style="font-size:12px;opacity:0.8">(' + totalErrs + ')</span></div>';
1297
+ firstActive = false;
1298
+ });
1299
+ html += '</div>';
1300
+
1301
+ // Build content panels for each DBMS
1302
+ firstActive = true;
1303
+ DATA.dbms.forEach(function(d, di) {
1304
+ var totalErrs = 0;
1305
+ d.queries.forEach(function(q) { totalErrs += q.errors; });
1306
+ if (totalErrs === 0) return;
1307
+
1308
+ html += '<div id="err-panel-' + di + '" style="' + (firstActive ? '' : 'display:none') + '">';
1309
+ firstActive = false;
1310
+
1311
+ d.queries.forEach(function(q) {
1312
+ var logs = q.error_logs || [];
1313
+ if (q.errors === 0) return;
1314
+
1315
+ var errId = 'errlog-' + di + '-' + q.name.replace(/[^a-zA-Z0-9_]/g, '_');
1316
+ html += '<div class="err-section" style="margin-bottom:12px">' +
1317
+ '<div class="err-toggle open" id="' + errId + '-toggle" onclick="toggleErrLogs(\'' + errId + '\')">' +
1318
+ '<span class="arrow">&#9654;</span> ' +
1319
+ '<span style="color:var(--blue);font-weight:600">' + esc(q.name) + '</span>' +
1320
+ ' &mdash; ' + q.errors + ' error(s)' +
1321
+ (logs.length > 0 && logs.length < q.errors ? ' (showing first ' + logs.length + ')' : '') +
1322
+ '</div>' +
1323
+ '<div class="err-list" id="' + errId + '-body">';
1324
+
1325
+ if (logs.length === 0) {
1326
+ html += '<div class="err-item"><div class="err-msg">No detailed error info captured</div></div>';
1327
+ } else {
1328
+ // Deduplicate: group by error message
1329
+ var errGroups = {};
1330
+ logs.forEach(function(el) {
1331
+ var key = el.error;
1332
+ if (!errGroups[key]) errGroups[key] = {error: el.error, sqls: [], count: 0};
1333
+ errGroups[key].count++;
1334
+ if (errGroups[key].sqls.length < 3) errGroups[key].sqls.push(el.sql);
1335
+ });
1336
+ Object.keys(errGroups).forEach(function(key) {
1337
+ var g = errGroups[key];
1338
+ html += '<div class="err-item">' +
1339
+ '<div class="err-msg">' + esc(g.error) +
1340
+ (g.count > 1 ? ' <span style="font-weight:400;color:var(--fg2)">(' + g.count + ' occurrences)</span>' : '') +
1341
+ '</div>';
1342
+ g.sqls.forEach(function(sql, si) {
1343
+ html += '<div class="err-sql" style="margin-top:4px">' +
1344
+ '<span style="color:var(--fg2);font-size:11px">SQL' + (g.sqls.length > 1 ? ' #' + (si+1) : '') + ':</span> ' +
1345
+ esc(sql) + '</div>';
1346
+ });
1347
+ html += '</div>';
1348
+ });
1349
+ }
1350
+ html += '</div></div>';
1351
+ });
1352
+
1353
+ html += '</div>';
1354
+ });
1355
+
1356
+ content.innerHTML = html;
1357
+ }
1358
+
1359
+ function switchErrTab(el, panelId) {
1360
+ // Deactivate all tabs
1361
+ var tabs = el.parentElement.querySelectorAll('.tab');
1362
+ tabs.forEach(function(t) {
1363
+ t.classList.remove('active');
1364
+ t.style.background = '';
1365
+ t.style.borderColor = '';
1366
+ t.style.color = '';
1367
+ });
1368
+ // Hide all panels
1369
+ var container = el.parentElement.parentElement;
1370
+ container.querySelectorAll('[id^="err-panel-"]').forEach(function(p) {
1371
+ p.style.display = 'none';
1372
+ });
1373
+ // Activate clicked tab
1374
+ el.classList.add('active');
1375
+ var colorBg = el.getAttribute('data-color');
1376
+ el.style.background = colorBg;
1377
+ el.style.borderColor = colorBg;
1378
+ el.style.color = '#fff';
1379
+ // Show panel
1380
+ document.getElementById(panelId).style.display = '';
1381
+ }
1382
+ </script>
1383
+ </body>
1384
+ </html>"""
1385
+
1386
+
1387
+ def write_bench_html_report(path: str, result: BenchmarkResult):
1388
+ """Generate a self-contained HTML benchmark report.
1389
+
1390
+ Uses ECharts (CDN) for an interactive grouped bar chart that puts
1391
+ all queries from all DBMS targets side-by-side in one view, plus
1392
+ a CSS-based Overall QPS bar, and a detail
1393
+ table with DBMS tabs.
1394
+
1395
+ If profiling was enabled, embeds per-query SVG flame graphs.
1396
+ """
1397
+ data = _build_data(result)
1398
+
1399
+ # Build flame graph data array: [{dbms, query, svg}, ...]
1400
+ # Only include tdsql flame graphs — other DBMS profiling is skipped.
1401
+ fg_data = []
1402
+ seen_svgs = set() # deduplicate concurrent mode shared SVGs
1403
+ for dr in result.dbms_results:
1404
+ if dr.dbms_name.lower() != "tdsql":
1405
+ continue
1406
+ for qs in dr.query_stats:
1407
+ if qs.flamegraph_svg:
1408
+ # In concurrent mode, all queries share the same SVG;
1409
+ # deduplicate by (dbms, svg_hash)
1410
+ svg_hash = hash(qs.flamegraph_svg)
1411
+ dedup_key = (dr.dbms_name, svg_hash)
1412
+ if dedup_key in seen_svgs:
1413
+ continue
1414
+ seen_svgs.add(dedup_key)
1415
+ fg_data.append({
1416
+ "dbms": dr.dbms_name,
1417
+ "query": qs.query_name,
1418
+ "svg": qs.flamegraph_svg,
1419
+ })
1420
+
1421
+ page = _HTML_TEMPLATE
1422
+ page = page.replace("{{WORKLOAD}}", _escape(result.workload_name))
1423
+
1424
+ def _safe_json(obj):
1425
+ s = json.dumps(obj, ensure_ascii=False)
1426
+ s = s.replace("<", "\\u003c")
1427
+ return s
1428
+
1429
+ page = page.replace("{{DATA_JSON}}", _safe_json(data))
1430
+
1431
+ # Flame graph data: SVG content is NOT JSON-encoded (it's raw HTML).
1432
+ # We build a JS array of objects with the SVG as a string.
1433
+ fg_js_items = []
1434
+ for fg in fg_data:
1435
+ # Escape SVG for safe embedding in JS template literal.
1436
+ # CRITICAL: We must escape </script so the browser's HTML parser
1437
+ # does not prematurely close the <script> block when it encounters
1438
+ # </script> tags embedded inside SVG CDATA sections.
1439
+ svg_escaped = (fg["svg"]
1440
+ .replace("\\", "\\\\")
1441
+ .replace("`", "\\`")
1442
+ .replace("${", "\\${")
1443
+ .replace("</script", "<\\/script")
1444
+ .replace("</Script", "<\\/Script")
1445
+ .replace("</SCRIPT", "<\\/SCRIPT"))
1446
+ fg_js_items.append(
1447
+ '{dbms:' + json.dumps(fg["dbms"]) +
1448
+ ',query:' + json.dumps(fg["query"]) +
1449
+ ',svg:`' + svg_escaped + '`}'
1450
+ )
1451
+ fg_js = "[" + ",".join(fg_js_items) + "]" if fg_js_items else "[]"
1452
+ page = page.replace("{{FLAMEGRAPH_JSON}}", fg_js)
1453
+
1454
+ with open(path, "w", encoding="utf-8") as f:
1455
+ f.write(page)
1456
+
1457
+ log.info("Benchmark HTML report written: %s", path)