loaderup 0.1.1__tar.gz → 0.1.3__tar.gz

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.
Files changed (32) hide show
  1. {loaderup-0.1.1 → loaderup-0.1.3}/PKG-INFO +1 -1
  2. {loaderup-0.1.1 → loaderup-0.1.3}/agents/analyzer.py +46 -1
  3. {loaderup-0.1.1 → loaderup-0.1.3}/agents/generator.py +55 -0
  4. {loaderup-0.1.1 → loaderup-0.1.3}/loader/demo_registry_target.py +2 -2
  5. {loaderup-0.1.1 → loaderup-0.1.3}/loader/models.py +1 -0
  6. {loaderup-0.1.1 → loaderup-0.1.3}/loader/pipeline.py +4 -1
  7. {loaderup-0.1.1 → loaderup-0.1.3}/loader/web/assets/app.js +485 -27
  8. {loaderup-0.1.1 → loaderup-0.1.3}/loader/web/assets/styles.css +23 -0
  9. {loaderup-0.1.1 → loaderup-0.1.3}/loader/web/index.html +2 -2
  10. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup.egg-info/PKG-INFO +1 -1
  11. {loaderup-0.1.1 → loaderup-0.1.3}/pyproject.toml +1 -1
  12. {loaderup-0.1.1 → loaderup-0.1.3}/README.md +0 -0
  13. {loaderup-0.1.1 → loaderup-0.1.3}/agents/runner.py +0 -0
  14. {loaderup-0.1.1 → loaderup-0.1.3}/loader/__init__.py +0 -0
  15. {loaderup-0.1.1 → loaderup-0.1.3}/loader/history.py +0 -0
  16. {loaderup-0.1.1 → loaderup-0.1.3}/loader/main.py +0 -0
  17. {loaderup-0.1.1 → loaderup-0.1.3}/loader/settings.py +0 -0
  18. {loaderup-0.1.1 → loaderup-0.1.3}/loader/store.py +0 -0
  19. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/__init__.py +0 -0
  20. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/autodiscovery.py +0 -0
  21. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/cli.py +0 -0
  22. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/collector.py +0 -0
  23. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/decorators.py +0 -0
  24. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/importer.py +0 -0
  25. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/models.py +0 -0
  26. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup/registry.py +0 -0
  27. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup.egg-info/SOURCES.txt +0 -0
  28. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup.egg-info/dependency_links.txt +0 -0
  29. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup.egg-info/entry_points.txt +0 -0
  30. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup.egg-info/requires.txt +0 -0
  31. {loaderup-0.1.1 → loaderup-0.1.3}/loaderup.egg-info/top_level.txt +0 -0
  32. {loaderup-0.1.1 → loaderup-0.1.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loaderup
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
5
5
  Author: Mahdi Haroun
6
6
  License: MIT
@@ -75,4 +75,49 @@ def parse_k6_summary(summary_path: str) -> Tuple[SummaryMetrics, Dict[str, Any]]
75
75
  checks_pass_rate=_checks_pass_rate(metrics),
76
76
  )
77
77
 
78
- return parsed, data
78
+ return parsed, data
79
+
80
+
81
+ def _decode_diag_json(raw_json: str) -> dict[str, Any] | None:
82
+ candidates = [
83
+ raw_json,
84
+ raw_json.replace('\\"', '"'),
85
+ raw_json.encode("utf-8", errors="ignore").decode("unicode_escape", errors="ignore"),
86
+ ]
87
+
88
+ for candidate in candidates:
89
+ try:
90
+ data = json.loads(candidate)
91
+ except json.JSONDecodeError:
92
+ continue
93
+ if isinstance(data, dict):
94
+ return data
95
+ return None
96
+
97
+
98
+ def parse_k6_diagnostics(stdout_text: str, stderr_text: str = "") -> list[dict[str, Any]]:
99
+ diagnostics: list[dict[str, Any]] = []
100
+ merged = "\n".join([stdout_text or "", stderr_text or ""])
101
+ if not merged.strip():
102
+ return diagnostics
103
+
104
+ marker = "LOADERUP_DIAG "
105
+
106
+ for line in merged.splitlines():
107
+ if marker not in line:
108
+ continue
109
+
110
+ payload_part = line.split(marker, 1)[1]
111
+ start = payload_part.find("{")
112
+ end = payload_part.rfind("}")
113
+ if start == -1 or end == -1 or end <= start:
114
+ continue
115
+
116
+ raw_json = payload_part[start : end + 1]
117
+ data = _decode_diag_json(raw_json)
118
+ if not data:
119
+ continue
120
+
121
+ diagnostics.append(data)
122
+
123
+ return diagnostics
@@ -33,6 +33,7 @@ def build_k6_script(
33
33
 
34
34
  script = f"""import http from 'k6/http';
35
35
  import {{ check, sleep }} from 'k6';
36
+ import {{ Counter }} from 'k6/metrics';
36
37
 
37
38
  export const options = {{
38
39
  vus: {vus},
@@ -41,6 +42,24 @@ export const options = {{
41
42
 
42
43
  const BASE_URL = {_js_string(base_url.rstrip("/"))};
43
44
  const TARGETS = {targets_json};
45
+ const STATUS_CODE_COUNT = new Counter('status_code_count');
46
+ const STATUS_429_SERVER_COUNT = new Counter('status_429_server_count');
47
+ const MAX_DIAG_PER_RUN = 6;
48
+ let diagCount = 0;
49
+
50
+ function normalizeHeaders(headers) {{
51
+ const normalized = {{}};
52
+ for (const [key, value] of Object.entries(headers || {{}})) {{
53
+ if (Array.isArray(value)) {{
54
+ normalized[key] = value.join(', ');
55
+ }} else if (value === null || value === undefined) {{
56
+ normalized[key] = '';
57
+ }} else {{
58
+ normalized[key] = String(value);
59
+ }}
60
+ }}
61
+ return normalized;
62
+ }}
44
63
 
45
64
  function weightedPick(targets) {{
46
65
  const expanded = [];
@@ -93,8 +112,44 @@ export default function () {{
93
112
  check(res, {{
94
113
  [`status is expected`]: (r) => r.status === target.expected_status,
95
114
  [`status is < 500`]: (r) => r.status < 500,
115
+ [`status code ${{res.status}}`]: () => true,
96
116
  }});
97
117
 
118
+ STATUS_CODE_COUNT.add(1, {{
119
+ status_code: String(res.status),
120
+ target_name: target.name,
121
+ method,
122
+ path: target.path,
123
+ }});
124
+
125
+ if (res.status === 429) {{
126
+ const headers = normalizeHeaders(res.headers);
127
+ const serverSignature = headers.Server || headers.server || 'unknown';
128
+ STATUS_429_SERVER_COUNT.add(1, {{
129
+ server_signature: String(serverSignature || 'unknown'),
130
+ target_name: target.name,
131
+ method,
132
+ path: target.path,
133
+ }});
134
+ check(res, {{
135
+ [`429 server ${{String(serverSignature || 'unknown')}}`]: () => true,
136
+ }});
137
+ }}
138
+
139
+ if (res.status === 429 && diagCount < MAX_DIAG_PER_RUN) {{
140
+ diagCount += 1;
141
+ const diagnostic = {{
142
+ status: res.status,
143
+ target_name: target.name,
144
+ method,
145
+ path: target.path,
146
+ url,
147
+ headers: normalizeHeaders(res.headers),
148
+ body_preview: String(res.body || '').slice(0, 2000),
149
+ }};
150
+ console.log(`LOADERUP_DIAG ${{JSON.stringify(diagnostic)}}`);
151
+ }}
152
+
98
153
  sleep(1);
99
154
  }}
100
155
  """
@@ -1,8 +1,7 @@
1
1
  # demo_registry_targets.py
2
2
 
3
3
  from loaderup import load_target
4
-
5
-
4
+ """
6
5
  @load_target(
7
6
  name="home",
8
7
  method="GET",
@@ -23,3 +22,4 @@ def home():
23
22
  def gg():
24
23
  pass
25
24
 
25
+ """
@@ -83,6 +83,7 @@ class JobResult(BaseModel):
83
83
  metrics: Optional[SummaryMetrics] = None
84
84
  target_metrics: List[TargetMetric] = Field(default_factory=list)
85
85
  raw_summary: Optional[Dict[str, Any]] = None
86
+ status_diagnostics: List[Dict[str, Any]] = Field(default_factory=list)
86
87
 
87
88
 
88
89
  class Job(BaseModel):
@@ -3,7 +3,7 @@ from loader.history import persist_job
3
3
  from loader.store import get_job, update_job, push_event, close_queue
4
4
  from agents.generator import build_k6_script, save_k6_script
5
5
  from agents.runner import run_k6
6
- from agents.analyzer import parse_k6_summary
6
+ from agents.analyzer import parse_k6_summary, parse_k6_diagnostics
7
7
 
8
8
 
9
9
  async def set_status(job, status: JobStatus, message: str):
@@ -66,6 +66,9 @@ async def run_targets_pipeline(job_id: str, config: RunConfig):
66
66
  job.result.k6_stdout = run_output["stdout"]
67
67
  if hasattr(job.result, "k6_stderr"):
68
68
  job.result.k6_stderr = run_output["stderr"]
69
+ job.result.status_diagnostics = parse_k6_diagnostics(
70
+ run_output["stdout"], run_output.get("stderr", "")
71
+ )
69
72
 
70
73
  update_job(job)
71
74
 
@@ -6,7 +6,7 @@ const html = htm.bind(React.createElement);
6
6
 
7
7
  const NAV = ["Run Now", "Live Runs", "Registry", "History"];
8
8
  const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
9
- const UI_VERSION = "20260418d";
9
+ const UI_VERSION = "20260418o";
10
10
 
11
11
  const METRIC_NAMES = {
12
12
  total_requests: "Total Requests",
@@ -46,6 +46,167 @@ function barItems(metrics = {}) {
46
46
  ].map(([k, v]) => [k, Math.max(0, Math.min(100, Number(v) || 0))]);
47
47
  }
48
48
 
49
+ function statusCodeBreakdown(rawSummary) {
50
+ const metrics = rawSummary?.metrics;
51
+ const rootChecks = rawSummary?.root_group?.checks;
52
+
53
+ const parseStatus = (text) => {
54
+ const match = String(text || "").match(/(?:status|status_code):(\d{3})/);
55
+ return match ? match[1] : null;
56
+ };
57
+
58
+ const parseStatusFromCheck = (text) => {
59
+ const match = String(text || "").match(/^status code\s+(\d{3})$/i);
60
+ return match ? match[1] : null;
61
+ };
62
+
63
+ const checkCounts = {};
64
+ if (rootChecks && typeof rootChecks === "object") {
65
+ for (const [checkName, checkMeta] of Object.entries(rootChecks)) {
66
+ const code = parseStatusFromCheck(checkName);
67
+ if (!code || !checkMeta || typeof checkMeta !== "object") continue;
68
+ const passes = Number(checkMeta.passes);
69
+ if (!Number.isFinite(passes) || passes <= 0) continue;
70
+ checkCounts[code] = (checkCounts[code] || 0) + passes;
71
+ }
72
+ }
73
+
74
+ if (Object.keys(checkCounts).length) {
75
+ const total = Object.values(checkCounts).reduce((sum, count) => sum + count, 0);
76
+ if (total > 0) {
77
+ return Object.entries(checkCounts)
78
+ .sort(([a], [b]) => Number(a) - Number(b))
79
+ .map(([code, count]) => ({
80
+ code,
81
+ count,
82
+ percent: (count / total) * 100,
83
+ }));
84
+ }
85
+ }
86
+
87
+ if (!metrics || typeof metrics !== "object") return [];
88
+
89
+ const subCounts = {};
90
+ for (const metric of Object.values(metrics)) {
91
+ if (!metric || typeof metric !== "object") continue;
92
+ const submetrics = metric.submetrics;
93
+ if (!submetrics || typeof submetrics !== "object") continue;
94
+
95
+ for (const [subName, subMetric] of Object.entries(submetrics)) {
96
+ const code = parseStatus(subName);
97
+ if (!code || !subMetric || typeof subMetric !== "object") continue;
98
+ const count = Number(subMetric.count);
99
+ if (!Number.isFinite(count) || count <= 0) continue;
100
+ subCounts[code] = (subCounts[code] || 0) + count;
101
+ }
102
+ }
103
+
104
+ const fallbackCounts = {};
105
+ if (!Object.keys(subCounts).length) {
106
+ for (const [metricName, metric] of Object.entries(metrics)) {
107
+ const code = parseStatus(metricName);
108
+ if (!code || !metric || typeof metric !== "object") continue;
109
+ const count = Number(metric.count);
110
+ if (!Number.isFinite(count) || count <= 0) continue;
111
+ fallbackCounts[code] = (fallbackCounts[code] || 0) + count;
112
+ }
113
+ }
114
+
115
+ const source = Object.keys(subCounts).length ? subCounts : fallbackCounts;
116
+ const total = Object.values(source).reduce((sum, count) => sum + count, 0);
117
+ if (!total) return [];
118
+
119
+ return Object.entries(source)
120
+ .sort(([a], [b]) => Number(a) - Number(b))
121
+ .map(([code, count]) => ({
122
+ code,
123
+ count,
124
+ percent: (count / total) * 100,
125
+ }));
126
+ }
127
+
128
+ function summarize429Diagnostics(diagnostics = []) {
129
+ const samples = Array.isArray(diagnostics) ? diagnostics.filter((d) => Number(d?.status) === 429) : [];
130
+ if (!samples.length) return null;
131
+
132
+ const serverCounts = {};
133
+ for (const sample of samples) {
134
+ const headers = sample?.headers && typeof sample.headers === "object" ? sample.headers : {};
135
+ const serverHeader = headers.Server || headers.server || headers.SERVER || "unknown";
136
+ const server = String(serverHeader || "unknown").trim() || "unknown";
137
+ serverCounts[server] = (serverCounts[server] || 0) + 1;
138
+ }
139
+
140
+ const servers = Object.entries(serverCounts)
141
+ .sort((a, b) => b[1] - a[1])
142
+ .map(([server, count]) => ({
143
+ server,
144
+ count,
145
+ percent: (count / samples.length) * 100,
146
+ }));
147
+
148
+ return {
149
+ total: samples.length,
150
+ servers,
151
+ sample: samples[0],
152
+ };
153
+ }
154
+
155
+ function status429ServerBreakdown(rawSummary) {
156
+ const checks = rawSummary?.root_group?.checks;
157
+ if (checks && typeof checks === "object") {
158
+ const fromChecks = {};
159
+ for (const [checkName, checkMeta] of Object.entries(checks)) {
160
+ const match = String(checkName || "").match(/^429 server\s+(.+)$/i);
161
+ if (!match) continue;
162
+ const server = match[1].trim() || "unknown";
163
+ const passes = Number(checkMeta?.passes);
164
+ if (!Number.isFinite(passes) || passes <= 0) continue;
165
+ fromChecks[server] = (fromChecks[server] || 0) + passes;
166
+ }
167
+
168
+ const totalFromChecks = Object.values(fromChecks).reduce((sum, count) => sum + count, 0);
169
+ if (totalFromChecks > 0) {
170
+ return Object.entries(fromChecks)
171
+ .sort((a, b) => b[1] - a[1])
172
+ .map(([server, count]) => ({
173
+ server,
174
+ count,
175
+ percent: (count / totalFromChecks) * 100,
176
+ }));
177
+ }
178
+ }
179
+
180
+ const metric = rawSummary?.metrics?.status_429_server_count;
181
+ const submetrics = metric?.submetrics;
182
+ if (!submetrics || typeof submetrics !== "object") return [];
183
+
184
+ const parseServer = (name) => {
185
+ const match = String(name || "").match(/server_signature:([^,}]+)/);
186
+ return match ? match[1].trim() : null;
187
+ };
188
+
189
+ const servers = {};
190
+ for (const [subName, subMetric] of Object.entries(submetrics)) {
191
+ const server = parseServer(subName);
192
+ if (!server || !subMetric || typeof subMetric !== "object") continue;
193
+ const count = Number(subMetric.count);
194
+ if (!Number.isFinite(count) || count <= 0) continue;
195
+ servers[server] = (servers[server] || 0) + count;
196
+ }
197
+
198
+ const total = Object.values(servers).reduce((sum, count) => sum + count, 0);
199
+ if (!total) return [];
200
+
201
+ return Object.entries(servers)
202
+ .sort((a, b) => b[1] - a[1])
203
+ .map(([server, count]) => ({
204
+ server,
205
+ count,
206
+ percent: (count / total) * 100,
207
+ }));
208
+ }
209
+
49
210
  function statusPill(status) {
50
211
  const s = status || "pending";
51
212
  return html`<span className=${`pill ${s}`}>${s}</span>`;
@@ -136,6 +297,7 @@ function RunNow({ registryTargets, onRunStart }) {
136
297
  const [targets, setTargets] = useState([defaultTarget()]);
137
298
  const [registryPayloads, setRegistryPayloads] = useState({});
138
299
  const [registryHeaders, setRegistryHeaders] = useState({});
300
+ const [registrySelection, setRegistrySelection] = useState({});
139
301
  const [snapshotEditors, setSnapshotEditors] = useState({});
140
302
  const [error, setError] = useState("");
141
303
  const [busy, setBusy] = useState(false);
@@ -150,6 +312,14 @@ function RunNow({ registryTargets, onRunStart }) {
150
312
  }
151
313
  setRegistryPayloads(nextPayloads);
152
314
  setRegistryHeaders(nextHeaders);
315
+ setRegistrySelection((prev) => {
316
+ const nextSelection = {};
317
+ for (const target of registryTargets || []) {
318
+ const key = `${target.method}:${target.path}:${target.name}`;
319
+ nextSelection[key] = Object.prototype.hasOwnProperty.call(prev, key) ? !!prev[key] : true;
320
+ }
321
+ return nextSelection;
322
+ });
153
323
  setSnapshotEditors({});
154
324
  }, [registryTargets]);
155
325
 
@@ -207,7 +377,16 @@ function RunNow({ registryTargets, onRunStart }) {
207
377
 
208
378
  function buildRegistryTargetsPayload() {
209
379
  const safeTargets = Array.isArray(registryTargets) ? registryTargets.filter(Boolean) : [];
210
- return safeTargets.map((target) => {
380
+ const selectedTargets = safeTargets.filter((target) => {
381
+ const key = `${target.method}:${target.path}:${target.name}`;
382
+ return !!registrySelection[key];
383
+ });
384
+
385
+ if (!selectedTargets.length) {
386
+ throw new Error("Select at least one registry target");
387
+ }
388
+
389
+ return selectedTargets.map((target) => {
211
390
  const key = `${target.method}:${target.path}:${target.name}`;
212
391
  const raw = (registryPayloads[key] || "").trim();
213
392
  const headers = parseHeaders(registryHeaders[key], target.name);
@@ -392,8 +571,40 @@ function RunNow({ registryTargets, onRunStart }) {
392
571
  </section>
393
572
 
394
573
  <section className="card">
395
- <h3>Registry Snapshot</h3>
396
- <p className="muted">Loaded decorator targets available for immediate execution.</p>
574
+ <div className="toolbar">
575
+ <h3>Registry Snapshot</h3>
576
+ <div className="topbar-right">
577
+ <button
578
+ type="button"
579
+ className="ghost"
580
+ onClick=${() => {
581
+ const next = {};
582
+ for (const target of registryTargets || []) {
583
+ const key = `${target.method}:${target.path}:${target.name}`;
584
+ next[key] = true;
585
+ }
586
+ setRegistrySelection(next);
587
+ }}
588
+ >
589
+ Select All
590
+ </button>
591
+ <button
592
+ type="button"
593
+ className="ghost"
594
+ onClick=${() => {
595
+ const next = {};
596
+ for (const target of registryTargets || []) {
597
+ const key = `${target.method}:${target.path}:${target.name}`;
598
+ next[key] = false;
599
+ }
600
+ setRegistrySelection(next);
601
+ }}
602
+ >
603
+ Clear
604
+ </button>
605
+ </div>
606
+ </div>
607
+ <p className="muted">Loaded decorator targets available for immediate execution. Selected: ${Object.values(registrySelection).filter(Boolean).length}</p>
397
608
  <div className="list">
398
609
  ${registryTargets.length
399
610
  ? registryTargets.map((target) => html`
@@ -404,11 +615,20 @@ function RunNow({ registryTargets, onRunStart }) {
404
615
  const isOpen = !!snapshotEditors[key];
405
616
  return html`
406
617
  <div className="toolbar">
407
- <strong>${target.name}</strong>
618
+ <label className="inline-actions" style=${{ margin: 0 }}>
619
+ <span>
620
+ <input
621
+ type="checkbox"
622
+ checked=${!!registrySelection[key]}
623
+ onChange=${(e) => setRegistrySelection((prev) => ({ ...prev, [key]: e.target.checked }))}
624
+ />
625
+ <strong style=${{ marginLeft: "0.45rem" }}>${target.name}</strong>
626
+ </span>
627
+ </label>
408
628
  <span className="pill">${target.method}</span>
409
629
  </div>
410
630
  <div><code>${target.path}</code></div>
411
- <div className="muted">expected=${target.expected_status}, tags=${(target.tags || []).join(", ") || "none"}</div>
631
+ <div className="muted">tags=${(target.tags || []).join(", ") || "none"}</div>
412
632
  <div className="toolbar" style=${{ marginTop: "0.55rem" }}>
413
633
  <button
414
634
  type="button"
@@ -492,6 +712,10 @@ function RunNow({ registryTargets, onRunStart }) {
492
712
 
493
713
  function LiveRuns({ activeJob, setActiveJob, activeRuns, logs, result, error, refreshActive }) {
494
714
  const bars = useMemo(() => barItems(result?.metrics), [result]);
715
+ const statusBreakdown = useMemo(() => statusCodeBreakdown(result?.raw_summary), [result]);
716
+ const status429Servers = useMemo(() => status429ServerBreakdown(result?.raw_summary), [result]);
717
+ const total429 = useMemo(() => statusBreakdown.find((item) => item.code === "429")?.count || 0, [statusBreakdown]);
718
+ const diagnostics429 = useMemo(() => summarize429Diagnostics(result?.status_diagnostics), [result]);
495
719
 
496
720
  return html`
497
721
  <div className="grid-2">
@@ -537,6 +761,72 @@ function LiveRuns({ activeJob, setActiveJob, activeRuns, logs, result, error, re
537
761
  `)}
538
762
  </div>
539
763
 
764
+ <h4 style=${{ marginTop: "0.9rem" }}>Status Codes</h4>
765
+ <div className="status-grid">
766
+ ${statusBreakdown.length
767
+ ? statusBreakdown.map((item) => html`
768
+ <div className="item status-item" key=${item.code}>
769
+ <div className="toolbar">
770
+ <strong>${item.code}</strong>
771
+ <span>${item.percent.toFixed(1)}%</span>
772
+ </div>
773
+ <div className="status-track"><div className="status-fill" style=${{ width: `${item.percent.toFixed(1)}%` }}></div></div>
774
+ <div className="muted">${item.count} requests</div>
775
+ </div>
776
+ `)
777
+ : html`<div className="item muted">No status code distribution available yet.</div>`}
778
+ </div>
779
+
780
+ <h4 style=${{ marginTop: "0.9rem" }}>429 Diagnostics</h4>
781
+ <div className="list">
782
+ ${status429Servers.length
783
+ ? html`
784
+ <div className="item">
785
+ <div className="toolbar">
786
+ <strong>429 responses captured</strong>
787
+ <span className="pill failed">${total429 || status429Servers.reduce((sum, item) => sum + item.count, 0)}</span>
788
+ </div>
789
+ <div className="muted">Server Signatures</div>
790
+ <div className="list">
791
+ ${status429Servers.map((item) => html`
792
+ <div className="item" key=${item.server}>
793
+ <div className="toolbar">
794
+ <strong>${item.server}</strong>
795
+ <span>${item.percent.toFixed(1)}%</span>
796
+ </div>
797
+ <div className="status-track"><div className="status-fill" style=${{ width: `${item.percent.toFixed(1)}%` }}></div></div>
798
+ <div className="muted">${item.count} responses</div>
799
+ </div>
800
+ `)}
801
+ </div>
802
+ </div>
803
+ `
804
+ : diagnostics429
805
+ ? html`
806
+ <div className="item">
807
+ <div className="toolbar">
808
+ <strong>429 responses captured</strong>
809
+ <span className="pill failed">${total429 || diagnostics429.total}</span>
810
+ </div>
811
+ <div className="muted">Sampled diagnostics: ${diagnostics429.total}${total429 ? ` of ${total429}` : ""}</div>
812
+ <div className="muted" style=${{ marginTop: "0.35rem" }}>Server Signatures</div>
813
+ <div className="list">
814
+ ${diagnostics429.servers.map((item) => html`
815
+ <div className="item" key=${item.server}>
816
+ <div className="toolbar">
817
+ <strong>${item.server}</strong>
818
+ <span>${item.percent.toFixed(1)}%</span>
819
+ </div>
820
+ <div className="status-track"><div className="status-fill" style=${{ width: `${item.percent.toFixed(1)}%` }}></div></div>
821
+ <div className="muted">${item.count} responses</div>
822
+ </div>
823
+ `)}
824
+ </div>
825
+ </div>
826
+ `
827
+ : html`<div className="item muted">No 429 diagnostics captured in this run.</div>`}
828
+ </div>
829
+
540
830
  <h4 style=${{ marginTop: "0.9rem" }}>Quick Trends</h4>
541
831
  <div className="bars">
542
832
  ${bars.map(([label, value]) => html`
@@ -578,7 +868,7 @@ function RegistryPage({ targets, refresh }) {
578
868
  <span className="pill">${target.method}</span>
579
869
  </div>
580
870
  <div><code>${target.path}</code></div>
581
- <div className="muted">status=${target.expected_status}, weight=${target.weight}, tags=${(target.tags || []).join(", ") || "none"}</div>
871
+ <div className="muted">weight=${target.weight}, tags=${(target.tags || []).join(", ") || "none"}</div>
582
872
  </div>
583
873
  `)
584
874
  : html`<div className="item muted">No targets found.</div>`}
@@ -587,28 +877,144 @@ function RegistryPage({ targets, refresh }) {
587
877
  `;
588
878
  }
589
879
 
590
- function History({ runs, refresh }) {
880
+ function History({ runs, refresh, selectedRun, setSelectedRun, onRunAgain, rerunBusy, rerunError }) {
881
+ const bars = useMemo(() => barItems(selectedRun?.result?.metrics), [selectedRun]);
882
+ const statusBreakdown = useMemo(() => statusCodeBreakdown(selectedRun?.result?.raw_summary), [selectedRun]);
883
+ const status429Servers = useMemo(() => status429ServerBreakdown(selectedRun?.result?.raw_summary), [selectedRun]);
884
+ const total429 = useMemo(() => statusBreakdown.find((item) => item.code === "429")?.count || 0, [statusBreakdown]);
885
+ const diagnostics429 = useMemo(() => summarize429Diagnostics(selectedRun?.result?.status_diagnostics), [selectedRun]);
886
+
591
887
  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)}
888
+ <div className="grid-2">
889
+ <section className="card">
890
+ <div className="toolbar">
891
+ <h3>Saved Run History</h3>
892
+ <button className="ghost" onClick=${refresh}>Refresh History</button>
893
+ </div>
894
+ <div className="list">
895
+ ${runs.length
896
+ ? runs.map((run) => html`
897
+ <div className="item" key=${run.job_id}>
898
+ <div className="toolbar">
899
+ <strong>${run.job_id}</strong>
900
+ ${statusPill(run.status)}
901
+ </div>
902
+ <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>
903
+ <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>
904
+ <button className="ghost" onClick=${() => setSelectedRun(run)}>View Details</button>
905
+ </div>
906
+ `)
907
+ : html`<div className="item muted">No persisted runs found.</div>`}
908
+ </div>
909
+ </section>
910
+
911
+ <section className="card">
912
+ <div className="toolbar">
913
+ <h3>Run Details</h3>
914
+ <div className="topbar-right">
915
+ ${selectedRun ? statusPill(selectedRun.status) : null}
916
+ <button className="primary" disabled=${!selectedRun || rerunBusy} onClick=${() => selectedRun && onRunAgain(selectedRun)}>
917
+ ${rerunBusy ? "Starting..." : "Run Test Again"}
918
+ </button>
919
+ </div>
920
+ </div>
921
+ <p className="muted">${selectedRun ? selectedRun.job_id : "Select a history run to inspect"}</p>
922
+ ${rerunError && html`<pre>${rerunError}</pre>`}
923
+
924
+ <div className="metrics">
925
+ ${Object.entries(METRIC_NAMES).map(([key, label]) => html`
926
+ <div className="item metric" key=${key}>
927
+ <div className="n">${label}</div>
928
+ <div className="v">${metricValue(key, selectedRun?.result?.metrics?.[key])}</div>
929
+ </div>
930
+ `)}
931
+ </div>
932
+
933
+ <h4 style=${{ marginTop: "0.9rem" }}>Status Codes</h4>
934
+ <div className="status-grid">
935
+ ${statusBreakdown.length
936
+ ? statusBreakdown.map((item) => html`
937
+ <div className="item status-item" key=${item.code}>
938
+ <div className="toolbar">
939
+ <strong>${item.code}</strong>
940
+ <span>${item.percent.toFixed(1)}%</span>
941
+ </div>
942
+ <div className="status-track"><div className="status-fill" style=${{ width: `${item.percent.toFixed(1)}%` }}></div></div>
943
+ <div className="muted">${item.count} requests</div>
944
+ </div>
945
+ `)
946
+ : html`<div className="item muted">No status code distribution available for this run.</div>`}
947
+ </div>
948
+
949
+ <h4 style=${{ marginTop: "0.9rem" }}>429 Diagnostics</h4>
950
+ <div className="list">
951
+ ${status429Servers.length
952
+ ? html`
953
+ <div className="item">
954
+ <div className="toolbar">
955
+ <strong>429 responses captured</strong>
956
+ <span className="pill failed">${total429 || status429Servers.reduce((sum, item) => sum + item.count, 0)}</span>
957
+ </div>
958
+ <div className="muted">Server Signatures</div>
959
+ <div className="list">
960
+ ${status429Servers.map((item) => html`
961
+ <div className="item" key=${item.server}>
962
+ <div className="toolbar">
963
+ <strong>${item.server}</strong>
964
+ <span>${item.percent.toFixed(1)}%</span>
965
+ </div>
966
+ <div className="status-track"><div className="status-fill" style=${{ width: `${item.percent.toFixed(1)}%` }}></div></div>
967
+ <div className="muted">${item.count} responses</div>
968
+ </div>
969
+ `)}
970
+ </div>
971
+ </div>
972
+ `
973
+ : diagnostics429
974
+ ? html`
975
+ <div className="item">
976
+ <div className="toolbar">
977
+ <strong>429 responses captured</strong>
978
+ <span className="pill failed">${total429 || diagnostics429.total}</span>
979
+ </div>
980
+ <div className="muted">Sampled diagnostics: ${diagnostics429.total}${total429 ? ` of ${total429}` : ""}</div>
981
+ <div className="muted" style=${{ marginTop: "0.35rem" }}>Server Signatures</div>
982
+ <div className="list">
983
+ ${diagnostics429.servers.map((item) => html`
984
+ <div className="item" key=${item.server}>
985
+ <div className="toolbar">
986
+ <strong>${item.server}</strong>
987
+ <span>${item.percent.toFixed(1)}%</span>
988
+ </div>
989
+ <div className="status-track"><div className="status-fill" style=${{ width: `${item.percent.toFixed(1)}%` }}></div></div>
990
+ <div className="muted">${item.count} responses</div>
991
+ </div>
992
+ `)}
993
+ </div>
604
994
  </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>
995
+ `
996
+ : html`<div className="item muted">No 429 diagnostics captured for this run.</div>`}
997
+ </div>
998
+
999
+ <h4 style=${{ marginTop: "0.9rem" }}>Quick Trends</h4>
1000
+ <div className="bars">
1001
+ ${bars.map(([label, value]) => html`
1002
+ <div className="vbar" key=${label}>
1003
+ <div className="vbar-track">
1004
+ <div className="vbar-fill" style=${{ height: `${value.toFixed(1)}%` }}></div>
607
1005
  </div>
608
- `)
609
- : html`<div className="item muted">No persisted runs found.</div>`}
610
- </div>
611
- </section>
1006
+ <div className="vbar-meta">
1007
+ <span>${label}</span>
1008
+ <strong>${value.toFixed(1)}%</strong>
1009
+ </div>
1010
+ </div>
1011
+ `)}
1012
+ </div>
1013
+
1014
+ <h4 style=${{ marginTop: "0.9rem" }}>Raw Result</h4>
1015
+ <pre>${JSON.stringify(selectedRun?.result || { message: "No run selected" }, null, 2)}</pre>
1016
+ </section>
1017
+ </div>
612
1018
  `;
613
1019
  }
614
1020
 
@@ -620,9 +1026,12 @@ function App() {
620
1026
  const [activeRuns, setActiveRuns] = useState([]);
621
1027
 
622
1028
  const [activeJob, setActiveJob] = useState(null);
1029
+ const [historyActiveRun, setHistoryActiveRun] = useState(null);
623
1030
  const [logs, setLogs] = useState([]);
624
1031
  const [result, setResult] = useState(null);
625
1032
  const [streamError, setStreamError] = useState("");
1033
+ const [historyRerunBusy, setHistoryRerunBusy] = useState(false);
1034
+ const [historyRerunError, setHistoryRerunError] = useState("");
626
1035
 
627
1036
  const streamRef = useRef(null);
628
1037
 
@@ -726,6 +1135,36 @@ function App() {
726
1135
  };
727
1136
  }
728
1137
 
1138
+ async function rerunHistoryRun(run) {
1139
+ if (!run?.result?.config) {
1140
+ setHistoryRerunError("Selected history run has no runnable config");
1141
+ return;
1142
+ }
1143
+
1144
+ setHistoryRerunError("");
1145
+ setHistoryRerunBusy(true);
1146
+ try {
1147
+ const cfg = run.result.config;
1148
+ const response = await fetch("/run/targets", {
1149
+ method: "POST",
1150
+ headers: { "Content-Type": "application/json" },
1151
+ body: JSON.stringify({
1152
+ base_url: cfg.base_url,
1153
+ vus: Number(cfg.vus),
1154
+ duration_seconds: Number(cfg.duration_seconds),
1155
+ targets: Array.isArray(cfg.targets) ? cfg.targets : [],
1156
+ }),
1157
+ });
1158
+ const data = await response.json();
1159
+ if (!response.ok) throw new Error(data.detail || "Could not start rerun");
1160
+ startStreaming(data);
1161
+ } catch (err) {
1162
+ setHistoryRerunError(err.message || "Could not start rerun");
1163
+ } finally {
1164
+ setHistoryRerunBusy(false);
1165
+ }
1166
+ }
1167
+
729
1168
  useEffect(() => {
730
1169
  reloadAll();
731
1170
  return () => closeStream();
@@ -736,6 +1175,25 @@ function App() {
736
1175
  setResult(activeJob.result || null);
737
1176
  }, [activeJob]);
738
1177
 
1178
+ useEffect(() => {
1179
+ if (!historyRuns.length) {
1180
+ setHistoryActiveRun(null);
1181
+ return;
1182
+ }
1183
+
1184
+ if (!historyActiveRun) {
1185
+ setHistoryActiveRun(historyRuns[0]);
1186
+ return;
1187
+ }
1188
+
1189
+ const updated = historyRuns.find((run) => run.job_id === historyActiveRun.job_id);
1190
+ if (updated) {
1191
+ setHistoryActiveRun(updated);
1192
+ } else {
1193
+ setHistoryActiveRun(historyRuns[0]);
1194
+ }
1195
+ }, [historyRuns, historyActiveRun]);
1196
+
739
1197
  const stats = useMemo(
740
1198
  () => ({
741
1199
  registry: registryTargets.length,
@@ -763,7 +1221,7 @@ function App() {
763
1221
  ${tab === "Run Now" && html`<${RunNow} registryTargets=${registryTargets} onRunStart=${startStreaming} />`}
764
1222
  ${tab === "Live Runs" && html`<${LiveRuns} activeJob=${activeJob} setActiveJob=${setActiveJob} activeRuns=${activeRuns} logs=${logs} result=${result} error=${streamError} refreshActive=${loadActive} />`}
765
1223
  ${tab === "Registry" && html`<${RegistryPage} targets=${registryTargets} refresh=${loadRegistry} />`}
766
- ${tab === "History" && html`<${History} runs=${historyRuns} refresh=${loadHistory} />`}
1224
+ ${tab === "History" && html`<${History} runs=${historyRuns} refresh=${loadHistory} selectedRun=${historyActiveRun} setSelectedRun=${setHistoryActiveRun} onRunAgain=${rerunHistoryRun} rerunBusy=${historyRerunBusy} rerunError=${historyRerunError} />`}
767
1225
  </div>
768
1226
  </div>
769
1227
  </div>
@@ -423,6 +423,29 @@ textarea {
423
423
  align-items: end;
424
424
  }
425
425
 
426
+ .status-grid {
427
+ display: grid;
428
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
429
+ gap: 0.56rem;
430
+ }
431
+
432
+ .status-item .toolbar {
433
+ margin-bottom: 0.45rem;
434
+ }
435
+
436
+ .status-track {
437
+ height: 10px;
438
+ border-radius: 999px;
439
+ border: 1px solid var(--line);
440
+ background: #1b2a3b;
441
+ overflow: hidden;
442
+ }
443
+
444
+ .status-fill {
445
+ height: 100%;
446
+ background: linear-gradient(90deg, var(--brand), var(--brand-2));
447
+ }
448
+
426
449
  .vbar {
427
450
  display: grid;
428
451
  gap: 0.42rem;
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <link rel="stylesheet" href="/assets/styles.css?v=20260418e">
10
+ <link rel="stylesheet" href="/assets/styles.css?v=20260418o">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
14
- <script type="module" src="/assets/app.js?v=20260418e"></script>
14
+ <script type="module" src="/assets/app.js?v=20260418o"></script>
15
15
  </body>
16
16
  </html>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loaderup
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
5
5
  Author: Mahdi Haroun
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "loaderup"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes