loaderup 0.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.
@@ -0,0 +1,773 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "https://esm.sh/react@18.3.1";
2
+ import { createRoot } from "https://esm.sh/react-dom@18.3.1/client";
3
+ import htm from "https://esm.sh/htm@3.1.1";
4
+
5
+ const html = htm.bind(React.createElement);
6
+
7
+ const NAV = ["Run Now", "Live Runs", "Registry", "History"];
8
+ const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
9
+ const UI_VERSION = "20260418d";
10
+
11
+ const METRIC_NAMES = {
12
+ total_requests: "Total Requests",
13
+ avg_http_req_duration_ms: "Avg Latency",
14
+ med_http_req_duration_ms: "Median",
15
+ p90_http_req_duration_ms: "P90",
16
+ p95_http_req_duration_ms: "P95",
17
+ max_http_req_duration_ms: "Max",
18
+ http_req_failed_rate: "Failure Rate",
19
+ checks_pass_rate: "Checks Pass Rate",
20
+ };
21
+
22
+ const defaultTarget = () => ({
23
+ name: "home",
24
+ method: "GET",
25
+ path: "/",
26
+ expected_status: 200,
27
+ weight: 1,
28
+ tagsRaw: "public",
29
+ headersRaw: "",
30
+ payloadRaw: "",
31
+ });
32
+
33
+ function metricValue(key, value) {
34
+ if (value === null || value === undefined) return "n/a";
35
+ if (typeof value === "number" && key.endsWith("_rate")) return `${(value * 100).toFixed(2)}%`;
36
+ if (typeof value === "number") return Number.isInteger(value) ? `${value}` : value.toFixed(2);
37
+ return String(value);
38
+ }
39
+
40
+ function barItems(metrics = {}) {
41
+ return [
42
+ ["Checks", (metrics.checks_pass_rate || 0) * 100],
43
+ ["Failures", (metrics.http_req_failed_rate || 0) * 100],
44
+ ["P95", (metrics.p95_http_req_duration_ms || 0) / 10],
45
+ ["Avg", (metrics.avg_http_req_duration_ms || 0) / 10],
46
+ ].map(([k, v]) => [k, Math.max(0, Math.min(100, Number(v) || 0))]);
47
+ }
48
+
49
+ function statusPill(status) {
50
+ const s = status || "pending";
51
+ return html`<span className=${`pill ${s}`}>${s}</span>`;
52
+ }
53
+
54
+ function methodUsesBody(method) {
55
+ return BODY_METHODS.has(String(method || "").toUpperCase());
56
+ }
57
+
58
+ class ErrorBoundary extends React.Component {
59
+ constructor(props) {
60
+ super(props);
61
+ this.state = { hasError: false, message: "" };
62
+ }
63
+
64
+ static getDerivedStateFromError(error) {
65
+ return { hasError: true, message: error?.message || "Unknown UI error" };
66
+ }
67
+
68
+ componentDidCatch(error) {
69
+ console.error("LoaderUp UI error:", error);
70
+ }
71
+
72
+ render() {
73
+ if (this.state.hasError) {
74
+ return html`
75
+ <div className="main">
76
+ <section className="card">
77
+ <h3>Dashboard error</h3>
78
+ <p className="muted">A rendering error occurred. Please refresh the page.</p>
79
+ <pre>${this.state.message}</pre>
80
+ </section>
81
+ </div>
82
+ `;
83
+ }
84
+ return this.props.children;
85
+ }
86
+ }
87
+
88
+ function Sidebar({ tab, setTab, health, stats }) {
89
+ return html`
90
+ <aside className="sidebar">
91
+ <div className="brand">
92
+ <h1>LoaderUp</h1>
93
+ <p>Control Center</p>
94
+ </div>
95
+
96
+ <nav className="nav">
97
+ ${NAV.map((item) => html`
98
+ <button key=${item} className=${tab === item ? "active" : ""} onClick=${() => setTab(item)}>
99
+ ${item}
100
+ </button>
101
+ `)}
102
+ </nav>
103
+
104
+ <div className="sidebar-meta">
105
+ <div className="meta-chip">API: ${health.status || "unknown"}</div>
106
+ <div className="meta-chip">k6: ${health.k6_installed ? "ready" : "missing"}</div>
107
+ <div className="meta-chip">Registry: ${stats.registry}</div>
108
+ <div className="meta-chip">History: ${stats.history}</div>
109
+ </div>
110
+ </aside>
111
+ `;
112
+ }
113
+
114
+ function TopBar({ tab, health, activeRuns, reloadAll }) {
115
+ return html`
116
+ <header className="topbar">
117
+ <div>
118
+ <h2>${tab}</h2>
119
+ <p>Loaderup workflow: configure, run, watch, and compare.</p>
120
+ </div>
121
+ <div className="topbar-right">
122
+ <span className="pill">UI v${UI_VERSION}</span>
123
+ ${statusPill(health.status === "ok" ? "ok" : "offline")}
124
+ <span className="pill">Active: ${activeRuns}</span>
125
+ <button className="ghost" onClick=${reloadAll}>Refresh Data</button>
126
+ </div>
127
+ </header>
128
+ `;
129
+ }
130
+
131
+ function RunNow({ registryTargets, onRunStart }) {
132
+ const [mode, setMode] = useState("registry");
133
+ const [baseUrl, setBaseUrl] = useState("http://127.0.0.1:8000");
134
+ const [vus, setVus] = useState(10);
135
+ const [duration, setDuration] = useState(30);
136
+ const [targets, setTargets] = useState([defaultTarget()]);
137
+ const [registryPayloads, setRegistryPayloads] = useState({});
138
+ const [registryHeaders, setRegistryHeaders] = useState({});
139
+ const [snapshotEditors, setSnapshotEditors] = useState({});
140
+ const [error, setError] = useState("");
141
+ const [busy, setBusy] = useState(false);
142
+
143
+ useEffect(() => {
144
+ const nextPayloads = {};
145
+ const nextHeaders = {};
146
+ for (const target of registryTargets || []) {
147
+ const key = `${target.method}:${target.path}:${target.name}`;
148
+ nextPayloads[key] = target.payload_example ? JSON.stringify(target.payload_example, null, 2) : "";
149
+ nextHeaders[key] = target.headers ? JSON.stringify(target.headers, null, 2) : "";
150
+ }
151
+ setRegistryPayloads(nextPayloads);
152
+ setRegistryHeaders(nextHeaders);
153
+ setSnapshotEditors({});
154
+ }, [registryTargets]);
155
+
156
+ function updateTarget(i, key, value) {
157
+ setTargets((prev) => prev.map((target, idx) => (idx === i ? { ...target, [key]: value } : target)));
158
+ }
159
+
160
+ function buildTargetsPayload() {
161
+ return targets.map((target) => {
162
+ const payload = target.payloadRaw.trim() ? JSON.parse(target.payloadRaw) : null;
163
+ if (payload !== null && (typeof payload !== "object" || Array.isArray(payload))) {
164
+ throw new Error(`Payload for '${target.name}' must be a JSON object`);
165
+ }
166
+ const headers = parseHeaders(target.headersRaw, target.name);
167
+ return {
168
+ name: target.name.trim(),
169
+ method: target.method,
170
+ path: target.path.trim(),
171
+ expected_status: Number(target.expected_status),
172
+ weight: Number(target.weight),
173
+ tags: target.tagsRaw.split(",").map((s) => s.trim()).filter(Boolean),
174
+ payload_example: methodUsesBody(target.method) ? payload : null,
175
+ headers,
176
+ };
177
+ });
178
+ }
179
+
180
+ function parseHeaders(raw, label) {
181
+ const text = (raw || "").trim();
182
+ if (!text) return {};
183
+
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(text);
187
+ } catch {
188
+ throw new Error(`Invalid headers JSON for '${label}'`);
189
+ }
190
+
191
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
192
+ throw new Error(`Headers for '${label}' must be a JSON object`);
193
+ }
194
+
195
+ return Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
196
+ }
197
+
198
+ function prettifyJson(raw, label) {
199
+ const text = (raw || "").trim();
200
+ if (!text) return "";
201
+ try {
202
+ return JSON.stringify(JSON.parse(text), null, 2);
203
+ } catch {
204
+ throw new Error(`Invalid JSON payload for '${label}'`);
205
+ }
206
+ }
207
+
208
+ function buildRegistryTargetsPayload() {
209
+ const safeTargets = Array.isArray(registryTargets) ? registryTargets.filter(Boolean) : [];
210
+ return safeTargets.map((target) => {
211
+ const key = `${target.method}:${target.path}:${target.name}`;
212
+ const raw = (registryPayloads[key] || "").trim();
213
+ const headers = parseHeaders(registryHeaders[key], target.name);
214
+
215
+ let payload = target.payload_example ?? null;
216
+ if (raw) {
217
+ let parsed;
218
+ try {
219
+ parsed = JSON.parse(raw);
220
+ } catch {
221
+ throw new Error(`Invalid JSON payload for '${target.name}'`);
222
+ }
223
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
224
+ throw new Error(`Payload for '${target.name}' must be a JSON object`);
225
+ }
226
+ payload = parsed;
227
+ }
228
+
229
+ if (String(target.method).toUpperCase() === "POST" && payload === null) {
230
+ throw new Error(`POST target '${target.name}' requires a JSON payload`);
231
+ }
232
+
233
+ return {
234
+ name: target.name,
235
+ method: target.method,
236
+ path: target.path,
237
+ expected_status: Number(target.expected_status),
238
+ weight: Number(target.weight || 1),
239
+ tags: Array.isArray(target.tags) ? target.tags : [],
240
+ payload_example: methodUsesBody(target.method) ? payload : null,
241
+ headers,
242
+ };
243
+ });
244
+ }
245
+
246
+ async function startRun(e) {
247
+ e.preventDefault();
248
+ setError("");
249
+ setBusy(true);
250
+ try {
251
+ const body = {
252
+ base_url: baseUrl.trim(),
253
+ vus: Number(vus),
254
+ duration_seconds: Number(duration),
255
+ };
256
+ let endpoint = "/run/targets";
257
+ if (mode === "manual") {
258
+ body.targets = buildTargetsPayload();
259
+ if (!body.targets.length) throw new Error("At least one target is required");
260
+ } else {
261
+ body.targets = buildRegistryTargetsPayload();
262
+ if (!body.targets.length) throw new Error("No registry targets available");
263
+ }
264
+ const response = await fetch(endpoint, {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify(body),
268
+ });
269
+ const data = await response.json();
270
+ if (!response.ok) throw new Error(data.detail || "Could not start run");
271
+ onRunStart(data);
272
+ } catch (err) {
273
+ setError(err.message || "Unknown error");
274
+ } finally {
275
+ setBusy(false);
276
+ }
277
+ }
278
+
279
+ return html`
280
+ <div className="grid-2">
281
+ <section className="card">
282
+ <div className="toolbar">
283
+ <h3>Execution Config</h3>
284
+ <span className="muted">Fast launch setup</span>
285
+ </div>
286
+ <form onSubmit=${startRun}>
287
+ <div className="grid-3">
288
+ <label>Mode
289
+ <select value=${mode} onChange=${(e) => setMode(e.target.value)}>
290
+ <option value="registry">Registry Targets</option>
291
+ <option value="manual">Manual Targets</option>
292
+ </select>
293
+ </label>
294
+ <label>Virtual Users
295
+ <input type="number" min="1" value=${vus} onInput=${(e) => setVus(e.target.value)} />
296
+ </label>
297
+ <label>Duration (sec)
298
+ <input type="number" min="1" value=${duration} onInput=${(e) => setDuration(e.target.value)} />
299
+ </label>
300
+ </div>
301
+ <label>Base URL
302
+ <input type="url" value=${baseUrl} onInput=${(e) => setBaseUrl(e.target.value)} required />
303
+ </label>
304
+
305
+ ${mode === "manual" && html`
306
+ <div className="toolbar" style=${{ marginTop: "0.65rem" }}>
307
+ <strong>Manual Targets</strong>
308
+ <button type="button" className="ghost" onClick=${() => setTargets((p) => [...p, defaultTarget()])}>Add Target</button>
309
+ </div>
310
+ <div className="targets">
311
+ ${targets.map((target, i) => html`
312
+ <div className="item" key=${i}>
313
+ <div className="grid-3">
314
+ <label>Name<input value=${target.name} onInput=${(e) => updateTarget(i, "name", e.target.value)} /></label>
315
+ <label>Method
316
+ <select value=${target.method} onChange=${(e) => updateTarget(i, "method", e.target.value)}>
317
+ <option value="GET">GET</option>
318
+ <option value="POST">POST</option>
319
+ <option value="PUT">PUT</option>
320
+ <option value="PATCH">PATCH</option>
321
+ <option value="DELETE">DELETE</option>
322
+ </select>
323
+ </label>
324
+ <label>Path<input value=${target.path} onInput=${(e) => updateTarget(i, "path", e.target.value)} /></label>
325
+ </div>
326
+ <div className="grid-3">
327
+ <label>Expected<input type="number" value=${target.expected_status} onInput=${(e) => updateTarget(i, "expected_status", e.target.value)} /></label>
328
+ <label>Weight<input type="number" step="0.1" value=${target.weight} onInput=${(e) => updateTarget(i, "weight", e.target.value)} /></label>
329
+ <label>Tags<input value=${target.tagsRaw} onInput=${(e) => updateTarget(i, "tagsRaw", e.target.value)} /></label>
330
+ </div>
331
+ <label>
332
+ <div className="inline-actions">
333
+ <span>Headers JSON</span>
334
+ <button
335
+ type="button"
336
+ className="ghost mini-btn"
337
+ onClick=${() => {
338
+ try {
339
+ updateTarget(i, "headersRaw", prettifyJson(target.headersRaw, target.name));
340
+ setError("");
341
+ } catch (err) {
342
+ setError(err.message || "Invalid JSON");
343
+ }
344
+ }}
345
+ >
346
+ Pretty
347
+ </button>
348
+ </div>
349
+ <textarea
350
+ value=${target.headersRaw}
351
+ onInput=${(e) => updateTarget(i, "headersRaw", e.target.value)}
352
+ placeholder='{"Authorization": "Bearer ..."}'
353
+ />
354
+ </label>
355
+ <label>
356
+ <div className="inline-actions">
357
+ <span>Payload JSON</span>
358
+ <button
359
+ type="button"
360
+ className="ghost mini-btn"
361
+ disabled=${!methodUsesBody(target.method)}
362
+ onClick=${() => {
363
+ try {
364
+ updateTarget(i, "payloadRaw", prettifyJson(target.payloadRaw, target.name));
365
+ setError("");
366
+ } catch (err) {
367
+ setError(err.message || "Invalid JSON");
368
+ }
369
+ }}
370
+ >
371
+ Pretty
372
+ </button>
373
+ </div>
374
+ <textarea
375
+ value=${target.payloadRaw}
376
+ disabled=${!methodUsesBody(target.method)}
377
+ onInput=${(e) => updateTarget(i, "payloadRaw", e.target.value)}
378
+ placeholder=${methodUsesBody(target.method) ? '{"key": "value"}' : "This method does not use a request body"}
379
+ />
380
+ </label>
381
+ <button type="button" className="danger" onClick=${() => setTargets((p) => p.filter((_, idx) => idx !== i))}>Remove</button>
382
+ </div>
383
+ `)}
384
+ </div>
385
+ `}
386
+ <div className="toolbar" style=${{ marginTop: "0.7rem" }}>
387
+ <span className="muted">Once started, follow progress in Live Runs.</span>
388
+ <button className="primary" disabled=${busy} type="submit">${busy ? "Starting..." : "Start Run"}</button>
389
+ </div>
390
+ </form>
391
+ ${error && html`<pre>${error}</pre>`}
392
+ </section>
393
+
394
+ <section className="card">
395
+ <h3>Registry Snapshot</h3>
396
+ <p className="muted">Loaded decorator targets available for immediate execution.</p>
397
+ <div className="list">
398
+ ${registryTargets.length
399
+ ? registryTargets.map((target) => html`
400
+ <div className="item" key=${`${target.method}:${target.path}:${target.name}`}>
401
+ ${(() => {
402
+ const key = `${target.method}:${target.path}:${target.name}`;
403
+ const canHaveBody = methodUsesBody(target.method);
404
+ const isOpen = !!snapshotEditors[key];
405
+ return html`
406
+ <div className="toolbar">
407
+ <strong>${target.name}</strong>
408
+ <span className="pill">${target.method}</span>
409
+ </div>
410
+ <div><code>${target.path}</code></div>
411
+ <div className="muted">expected=${target.expected_status}, tags=${(target.tags || []).join(", ") || "none"}</div>
412
+ <div className="toolbar" style=${{ marginTop: "0.55rem" }}>
413
+ <button
414
+ type="button"
415
+ className="ghost"
416
+ onClick=${() => setSnapshotEditors((prev) => ({ ...prev, [key]: !prev[key] }))}
417
+ >
418
+ ${isOpen ? "Hide API request editor" : "Modify API request body & headers"}
419
+ </button>
420
+ </div>
421
+ ${isOpen && html`
422
+ <div className="targets">
423
+ <label>
424
+ <div className="inline-actions">
425
+ <span>Request JSON</span>
426
+ <button
427
+ type="button"
428
+ className="ghost mini-btn"
429
+ disabled=${!canHaveBody}
430
+ onClick=${() => {
431
+ try {
432
+ setRegistryPayloads((prev) => ({
433
+ ...prev,
434
+ [key]: prettifyJson(prev[key] || "", target.name),
435
+ }));
436
+ setError("");
437
+ } catch (err) {
438
+ setError(err.message || "Invalid JSON");
439
+ }
440
+ }}
441
+ >
442
+ Pretty
443
+ </button>
444
+ </div>
445
+ <textarea
446
+ value=${registryPayloads[key] || ""}
447
+ disabled=${!canHaveBody}
448
+ onInput=${(e) => setRegistryPayloads((prev) => ({ ...prev, [key]: e.target.value }))}
449
+ placeholder=${canHaveBody ? '{"key": "value"}' : "This method does not use a request body"}
450
+ />
451
+ </label>
452
+ ${!canHaveBody && html`<div className="muted">Body is only used for POST/PUT/PATCH/DELETE.</div>`}
453
+ <label>
454
+ <div className="inline-actions">
455
+ <span>Headers JSON</span>
456
+ <button
457
+ type="button"
458
+ className="ghost mini-btn"
459
+ onClick=${() => {
460
+ try {
461
+ setRegistryHeaders((prev) => ({
462
+ ...prev,
463
+ [key]: prettifyJson(prev[key] || "", target.name),
464
+ }));
465
+ setError("");
466
+ } catch (err) {
467
+ setError(err.message || "Invalid JSON");
468
+ }
469
+ }}
470
+ >
471
+ Pretty
472
+ </button>
473
+ </div>
474
+ <textarea
475
+ value=${registryHeaders[key] || ""}
476
+ onInput=${(e) => setRegistryHeaders((prev) => ({ ...prev, [key]: e.target.value }))}
477
+ placeholder='{"Authorization": "Bearer ..."}'
478
+ />
479
+ </label>
480
+ </div>
481
+ `}
482
+ `;
483
+ })()}
484
+ </div>
485
+ `)
486
+ : html`<div className="item muted">No registered targets.</div>`}
487
+ </div>
488
+ </section>
489
+ </div>
490
+ `;
491
+ }
492
+
493
+ function LiveRuns({ activeJob, setActiveJob, activeRuns, logs, result, error, refreshActive }) {
494
+ const bars = useMemo(() => barItems(result?.metrics), [result]);
495
+
496
+ return html`
497
+ <div className="grid-2">
498
+ <section className="card">
499
+ <div className="toolbar">
500
+ <h3>Current and Recent Runs</h3>
501
+ <button className="ghost" onClick=${refreshActive}>Refresh</button>
502
+ </div>
503
+ <div className="list">
504
+ ${activeRuns.length
505
+ ? activeRuns.map((run) => html`
506
+ <div className="item" key=${run.job_id}>
507
+ <div className="toolbar">
508
+ <strong>${run.job_id}</strong>
509
+ ${statusPill(run.status)}
510
+ </div>
511
+ <div className="muted">targets=${run.result?.config?.targets?.length ?? 0}, vus=${run.result?.config?.vus ?? "n/a"}</div>
512
+ <button className="ghost" onClick=${() => setActiveJob(run)}>View</button>
513
+ </div>
514
+ `)
515
+ : html`<div className="item muted">No runs available.</div>`}
516
+ </div>
517
+
518
+ <h4 style=${{ marginTop: "0.9rem" }}>Live Progress</h4>
519
+ <div className="log">
520
+ ${logs.length ? logs.map((line, idx) => html`<div className="item" key=${idx}>${line}</div>`) : html`<div className="item muted">No live stream events yet.</div>`}
521
+ </div>
522
+ </section>
523
+
524
+ <section className="card">
525
+ <div className="toolbar">
526
+ <h3>Run Details</h3>
527
+ ${activeJob ? statusPill(activeJob.status) : null}
528
+ </div>
529
+ <p className="muted">${activeJob ? activeJob.job_id : "Select a run to inspect"}</p>
530
+
531
+ <div className="metrics">
532
+ ${Object.entries(METRIC_NAMES).map(([key, label]) => html`
533
+ <div className="item metric" key=${key}>
534
+ <div className="n">${label}</div>
535
+ <div className="v">${metricValue(key, result?.metrics?.[key])}</div>
536
+ </div>
537
+ `)}
538
+ </div>
539
+
540
+ <h4 style=${{ marginTop: "0.9rem" }}>Quick Trends</h4>
541
+ <div className="bars">
542
+ ${bars.map(([label, value]) => html`
543
+ <div className="vbar" key=${label}>
544
+ <div className="vbar-track">
545
+ <div className="vbar-fill" style=${{ height: `${value.toFixed(1)}%` }}></div>
546
+ </div>
547
+ <div className="vbar-meta">
548
+ <span>${label}</span>
549
+ <strong>${value.toFixed(1)}%</strong>
550
+ </div>
551
+ </div>
552
+ `)}
553
+ </div>
554
+
555
+ ${error && html`<pre>${error}</pre>`}
556
+ <h4 style=${{ marginTop: "0.9rem" }}>Raw Result</h4>
557
+ <pre>${JSON.stringify(result || { message: "No result yet" }, null, 2)}</pre>
558
+ </section>
559
+ </div>
560
+ `;
561
+ }
562
+
563
+ function RegistryPage({ targets, refresh }) {
564
+ const safeTargets = Array.isArray(targets) ? targets.filter(Boolean) : [];
565
+ return html`
566
+ <section className="card">
567
+ <div className="toolbar">
568
+ <h3>Registered Targets</h3>
569
+ <button className="ghost" onClick=${refresh}>Refresh Registry</button>
570
+ </div>
571
+ <p className="muted">These targets are discoverable by <code>/run/registry</code>.</p>
572
+ <div className="list">
573
+ ${safeTargets.length
574
+ ? safeTargets.map((target) => html`
575
+ <div className="item" key=${`${target.method}:${target.path}:${target.name}`}>
576
+ <div className="toolbar">
577
+ <strong>${target.name}</strong>
578
+ <span className="pill">${target.method}</span>
579
+ </div>
580
+ <div><code>${target.path}</code></div>
581
+ <div className="muted">status=${target.expected_status}, weight=${target.weight}, tags=${(target.tags || []).join(", ") || "none"}</div>
582
+ </div>
583
+ `)
584
+ : html`<div className="item muted">No targets found.</div>`}
585
+ </div>
586
+ </section>
587
+ `;
588
+ }
589
+
590
+ function History({ runs, refresh }) {
591
+ return html`
592
+ <section className="card">
593
+ <div className="toolbar">
594
+ <h3>Saved Run History</h3>
595
+ <button className="ghost" onClick=${refresh}>Refresh History</button>
596
+ </div>
597
+ <div className="list">
598
+ ${runs.length
599
+ ? runs.map((run) => html`
600
+ <div className="item" key=${run.job_id}>
601
+ <div className="toolbar">
602
+ <strong>${run.job_id}</strong>
603
+ ${statusPill(run.status)}
604
+ </div>
605
+ <div className="muted">vus=${run.result?.config?.vus ?? "n/a"}, duration=${run.result?.config?.duration_seconds ?? "n/a"}s, targets=${run.result?.config?.targets?.length ?? 0}</div>
606
+ <div className="muted">avg=${metricValue("avg_http_req_duration_ms", run.result?.metrics?.avg_http_req_duration_ms)} ms, failed=${metricValue("http_req_failed_rate", run.result?.metrics?.http_req_failed_rate)}</div>
607
+ </div>
608
+ `)
609
+ : html`<div className="item muted">No persisted runs found.</div>`}
610
+ </div>
611
+ </section>
612
+ `;
613
+ }
614
+
615
+ function App() {
616
+ const [tab, setTab] = useState("Run Now");
617
+ const [health, setHealth] = useState({ status: "checking", k6_installed: false });
618
+ const [registryTargets, setRegistryTargets] = useState([]);
619
+ const [historyRuns, setHistoryRuns] = useState([]);
620
+ const [activeRuns, setActiveRuns] = useState([]);
621
+
622
+ const [activeJob, setActiveJob] = useState(null);
623
+ const [logs, setLogs] = useState([]);
624
+ const [result, setResult] = useState(null);
625
+ const [streamError, setStreamError] = useState("");
626
+
627
+ const streamRef = useRef(null);
628
+
629
+ async function loadHealth() {
630
+ try {
631
+ const response = await fetch("/health");
632
+ setHealth(await response.json());
633
+ } catch {
634
+ setHealth({ status: "offline", k6_installed: false });
635
+ }
636
+ }
637
+
638
+ async function loadRegistry() {
639
+ try {
640
+ const response = await fetch("/registry/targets");
641
+ const data = await response.json();
642
+ const targets = Array.isArray(data.targets) ? data.targets.filter(Boolean) : [];
643
+ setRegistryTargets(targets);
644
+ } catch {
645
+ setRegistryTargets([]);
646
+ }
647
+ }
648
+
649
+ async function loadHistory() {
650
+ try {
651
+ const response = await fetch("/runs/history?limit=30");
652
+ const data = await response.json();
653
+ const runs = Array.isArray(data.runs) ? data.runs.filter(Boolean) : [];
654
+ setHistoryRuns(runs);
655
+ } catch {
656
+ setHistoryRuns([]);
657
+ }
658
+ }
659
+
660
+ async function loadActive() {
661
+ try {
662
+ const response = await fetch("/runs/active?limit=20");
663
+ const data = await response.json();
664
+ const runs = Array.isArray(data.runs) ? data.runs.filter(Boolean) : [];
665
+ setActiveRuns(runs);
666
+ if (!activeJob && runs.length) {
667
+ setActiveJob(runs[0]);
668
+ if (runs[0].result) setResult(runs[0].result);
669
+ }
670
+ } catch {
671
+ setActiveRuns([]);
672
+ }
673
+ }
674
+
675
+ async function reloadAll() {
676
+ await Promise.all([loadHealth(), loadRegistry(), loadHistory(), loadActive()]);
677
+ }
678
+
679
+ function closeStream() {
680
+ if (streamRef.current) {
681
+ streamRef.current.close();
682
+ streamRef.current = null;
683
+ }
684
+ }
685
+
686
+ function startStreaming(runMeta) {
687
+ closeStream();
688
+ setTab("Live Runs");
689
+ setActiveJob(runMeta);
690
+ setLogs([]);
691
+ setResult(null);
692
+ setStreamError("");
693
+
694
+ const source = new EventSource(runMeta.stream_url);
695
+ streamRef.current = source;
696
+
697
+ source.onmessage = (event) => {
698
+ const payload = JSON.parse(event.data);
699
+ if (payload.type === "progress") {
700
+ setLogs((prev) => [...prev.slice(-40), payload.message || "progress"]);
701
+ setActiveJob((prev) => (prev ? { ...prev, status: payload.status || prev.status } : prev));
702
+ } else if (payload.type === "error") {
703
+ setStreamError(payload.message || "Run failed");
704
+ setActiveJob((prev) => (prev ? { ...prev, status: "failed" } : prev));
705
+ loadHistory();
706
+ } else if (payload.type === "done") {
707
+ setResult(payload.result || null);
708
+ setActiveJob((prev) => (prev ? { ...prev, status: "done", result: payload.result } : prev));
709
+ setLogs((prev) => [...prev.slice(-40), "Run completed successfully"]);
710
+ loadHistory();
711
+ }
712
+ };
713
+
714
+ source.onerror = async () => {
715
+ try {
716
+ const response = await fetch(runMeta.result_url);
717
+ if (response.ok) {
718
+ const job = await response.json();
719
+ setActiveJob(job);
720
+ setResult(job.result || null);
721
+ if (job.error) setStreamError(job.error);
722
+ }
723
+ } catch {
724
+ setStreamError("Stream disconnected and result fetch failed");
725
+ }
726
+ };
727
+ }
728
+
729
+ useEffect(() => {
730
+ reloadAll();
731
+ return () => closeStream();
732
+ }, []);
733
+
734
+ useEffect(() => {
735
+ if (!activeJob) return;
736
+ setResult(activeJob.result || null);
737
+ }, [activeJob]);
738
+
739
+ const stats = useMemo(
740
+ () => ({
741
+ registry: registryTargets.length,
742
+ history: historyRuns.length,
743
+ active: activeRuns.length,
744
+ }),
745
+ [registryTargets, historyRuns, activeRuns]
746
+ );
747
+
748
+ return html`
749
+ <div className="app-shell">
750
+ <${Sidebar} tab=${tab} setTab=${setTab} health=${health} stats=${stats} />
751
+
752
+ <div className="main">
753
+ <${TopBar} tab=${tab} health=${health} activeRuns=${stats.active} reloadAll=${reloadAll} />
754
+
755
+ <div className="page">
756
+ <div className="kpi-row">
757
+ <div className="kpi-card"><div className="label">Registered Targets</div><div className="value">${stats.registry}</div></div>
758
+ <div className="kpi-card"><div className="label">Active Memory Runs</div><div className="value">${stats.active}</div></div>
759
+ <div className="kpi-card"><div className="label">Persisted Runs</div><div className="value">${stats.history}</div></div>
760
+ <div className="kpi-card"><div className="label">Runtime</div><div className="value">${health.k6_installed ? "k6 Ready" : "k6 Missing"}</div></div>
761
+ </div>
762
+
763
+ ${tab === "Run Now" && html`<${RunNow} registryTargets=${registryTargets} onRunStart=${startStreaming} />`}
764
+ ${tab === "Live Runs" && html`<${LiveRuns} activeJob=${activeJob} setActiveJob=${setActiveJob} activeRuns=${activeRuns} logs=${logs} result=${result} error=${streamError} refreshActive=${loadActive} />`}
765
+ ${tab === "Registry" && html`<${RegistryPage} targets=${registryTargets} refresh=${loadRegistry} />`}
766
+ ${tab === "History" && html`<${History} runs=${historyRuns} refresh=${loadHistory} />`}
767
+ </div>
768
+ </div>
769
+ </div>
770
+ `;
771
+ }
772
+
773
+ createRoot(document.getElementById("root")).render(html`<${ErrorBoundary}><${App} /></${ErrorBoundary}>`);