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.
- agents/analyzer.py +78 -0
- agents/generator.py +110 -0
- agents/runner.py +40 -0
- loader/__init__.py +0 -0
- loader/demo_registry_target.py +25 -0
- loader/history.py +36 -0
- loader/main.py +150 -0
- loader/models.py +93 -0
- loader/pipeline.py +111 -0
- loader/settings.py +4 -0
- loader/store.py +43 -0
- loader/web/assets/app.js +773 -0
- loader/web/assets/styles.css +570 -0
- loader/web/index.html +16 -0
- loaderup/__init__.py +10 -0
- loaderup/autodiscovery.py +65 -0
- loaderup/cli.py +219 -0
- loaderup/collector.py +11 -0
- loaderup/decorators.py +34 -0
- loaderup/importer.py +120 -0
- loaderup/models.py +36 -0
- loaderup/registry.py +31 -0
- loaderup-0.1.0.dist-info/METADATA +78 -0
- loaderup-0.1.0.dist-info/RECORD +27 -0
- loaderup-0.1.0.dist-info/WHEEL +5 -0
- loaderup-0.1.0.dist-info/entry_points.txt +2 -0
- loaderup-0.1.0.dist-info/top_level.txt +3 -0
loader/web/assets/app.js
ADDED
|
@@ -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}>`);
|