loaderup 0.1.3__tar.gz → 0.1.4__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 (33) hide show
  1. {loaderup-0.1.3 → loaderup-0.1.4}/PKG-INFO +71 -6
  2. loaderup-0.1.4/README.md +130 -0
  3. {loaderup-0.1.3 → loaderup-0.1.4}/agents/analyzer.py +79 -0
  4. {loaderup-0.1.3 → loaderup-0.1.4}/agents/runner.py +5 -1
  5. {loaderup-0.1.3 → loaderup-0.1.4}/loader/demo_registry_target.py +2 -3
  6. {loaderup-0.1.3 → loaderup-0.1.4}/loader/main.py +1 -1
  7. {loaderup-0.1.3 → loaderup-0.1.4}/loader/models.py +8 -0
  8. {loaderup-0.1.3 → loaderup-0.1.4}/loader/pipeline.py +8 -1
  9. {loaderup-0.1.3 → loaderup-0.1.4}/loader/web/assets/app.js +323 -3
  10. {loaderup-0.1.3 → loaderup-0.1.4}/loader/web/assets/styles.css +206 -0
  11. {loaderup-0.1.3 → loaderup-0.1.4}/loader/web/index.html +2 -2
  12. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/cli.py +1 -1
  13. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/PKG-INFO +71 -6
  14. {loaderup-0.1.3 → loaderup-0.1.4}/pyproject.toml +1 -1
  15. loaderup-0.1.3/README.md +0 -65
  16. {loaderup-0.1.3 → loaderup-0.1.4}/agents/generator.py +0 -0
  17. {loaderup-0.1.3 → loaderup-0.1.4}/loader/__init__.py +0 -0
  18. {loaderup-0.1.3 → loaderup-0.1.4}/loader/history.py +0 -0
  19. {loaderup-0.1.3 → loaderup-0.1.4}/loader/settings.py +0 -0
  20. {loaderup-0.1.3 → loaderup-0.1.4}/loader/store.py +0 -0
  21. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/__init__.py +0 -0
  22. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/autodiscovery.py +0 -0
  23. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/collector.py +0 -0
  24. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/decorators.py +0 -0
  25. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/importer.py +0 -0
  26. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/models.py +0 -0
  27. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/registry.py +0 -0
  28. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/SOURCES.txt +0 -0
  29. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/dependency_links.txt +0 -0
  30. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/entry_points.txt +0 -0
  31. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/requires.txt +0 -0
  32. {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/top_level.txt +0 -0
  33. {loaderup-0.1.3 → loaderup-0.1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loaderup
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -33,7 +33,7 @@ Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-bas
33
33
 
34
34
  - Python `>=3.12`
35
35
  - `uv` (recommended package manager)
36
- - `k6` (optional but recommended for real load runs)
36
+ - `k6`
37
37
 
38
38
  ## Install
39
39
 
@@ -47,7 +47,7 @@ uv pip install -e .
47
47
  python -m loader.main
48
48
  ```
49
49
 
50
- Server starts on `http://127.0.0.1:8000` by default.
50
+ Server starts on `http://127.0.0.1:5050` by default.
51
51
 
52
52
  ## Run via CLI
53
53
 
@@ -55,7 +55,7 @@ Server starts on `http://127.0.0.1:8000` by default.
55
55
  python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
56
56
  ```
57
57
 
58
- This command opens the dashboard automatically at `http://127.0.0.1:8000/`.
58
+ This command opens the dashboard automatically at `http://127.0.0.1:5050/`.
59
59
  Use `--no-open-browser` if you want to disable auto-open.
60
60
 
61
61
  You can also use fallback-friendly forms like:
@@ -64,10 +64,75 @@ You can also use fallback-friendly forms like:
64
64
  python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
65
65
  ```
66
66
 
67
+ ## Using the decorator with FastAPI
68
+
69
+ Create your FastAPI app as usual:
70
+
71
+ ```python
72
+ # main.py
73
+ from fastapi import FastAPI
74
+
75
+ app = FastAPI()
76
+
77
+
78
+ @app.get("/")
79
+ def home():
80
+ return {"message": "ok"}
81
+
82
+
83
+ @app.post("/users")
84
+ def create_user(payload: dict):
85
+ return {"id": 1, **payload}
86
+ ```
87
+
88
+ Then create a module with the load targets you want LoaderUp to test. The decorated functions do not need to call your route handlers; importing this module registers the target metadata.
89
+
90
+ ```python
91
+ # targets.py
92
+ from loaderup import load_target
93
+
94
+
95
+ @load_target(
96
+ name="home page",
97
+ method="GET",
98
+ path="/",
99
+ expected_status=200,
100
+ tags=["public"],
101
+ )
102
+ def home_page():
103
+ pass
104
+
105
+
106
+ @load_target(
107
+ name="create user",
108
+ method="POST",
109
+ path="/users",
110
+ payload_example={"name": "Ada"},
111
+ expected_status=200,
112
+ tags=["users"],
113
+ )
114
+ def create_user():
115
+ pass
116
+ ```
117
+
118
+ Start LoaderUp and tell it to import the target module:
119
+
120
+ ```bash
121
+ python -m loaderup.cli up --app main:app --targets targets --reload
122
+ ```
123
+
124
+ Run all decorator-registered targets against your FastAPI app:
125
+
126
+ ```bash
127
+ curl -X POST http://127.0.0.1:5050/run/registry \
128
+ -H 'Content-Type: application/json' \
129
+ -d '{"base_url":"http://127.0.0.1:5050","vus":10,"duration_seconds":30}'
130
+ ```
131
+
67
132
  ## Health check
68
133
 
69
134
  ```bash
70
- curl http://127.0.0.1:8000/health
135
+ curl http://127.0.0.1:5050/health
71
136
  ```
72
137
 
73
138
  ## Dashboard
@@ -75,7 +140,7 @@ curl http://127.0.0.1:8000/health
75
140
  Open:
76
141
 
77
142
  ```bash
78
- http://127.0.0.1:8000/
143
+ http://127.0.0.1:5050/
79
144
  ```
80
145
 
81
146
  Dashboard includes:
@@ -0,0 +1,130 @@
1
+ # LoaderUp
2
+
3
+ Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
4
+
5
+ ## Requirements
6
+
7
+ - Python `>=3.12`
8
+ - `uv` (recommended package manager)
9
+ - `k6`
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uv pip install -e .
15
+ ```
16
+
17
+ ## Run API directly
18
+
19
+ ```bash
20
+ python -m loader.main
21
+ ```
22
+
23
+ Server starts on `http://127.0.0.1:5050` by default.
24
+
25
+ ## Run via CLI
26
+
27
+ ```bash
28
+ python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
29
+ ```
30
+
31
+ This command opens the dashboard automatically at `http://127.0.0.1:5050/`.
32
+ Use `--no-open-browser` if you want to disable auto-open.
33
+
34
+ You can also use fallback-friendly forms like:
35
+
36
+ ```bash
37
+ python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
38
+ ```
39
+
40
+ ## Using the decorator with FastAPI
41
+
42
+ Create your FastAPI app as usual:
43
+
44
+ ```python
45
+ # main.py
46
+ from fastapi import FastAPI
47
+
48
+ app = FastAPI()
49
+
50
+
51
+ @app.get("/")
52
+ def home():
53
+ return {"message": "ok"}
54
+
55
+
56
+ @app.post("/users")
57
+ def create_user(payload: dict):
58
+ return {"id": 1, **payload}
59
+ ```
60
+
61
+ Then create a module with the load targets you want LoaderUp to test. The decorated functions do not need to call your route handlers; importing this module registers the target metadata.
62
+
63
+ ```python
64
+ # targets.py
65
+ from loaderup import load_target
66
+
67
+
68
+ @load_target(
69
+ name="home page",
70
+ method="GET",
71
+ path="/",
72
+ expected_status=200,
73
+ tags=["public"],
74
+ )
75
+ def home_page():
76
+ pass
77
+
78
+
79
+ @load_target(
80
+ name="create user",
81
+ method="POST",
82
+ path="/users",
83
+ payload_example={"name": "Ada"},
84
+ expected_status=200,
85
+ tags=["users"],
86
+ )
87
+ def create_user():
88
+ pass
89
+ ```
90
+
91
+ Start LoaderUp and tell it to import the target module:
92
+
93
+ ```bash
94
+ python -m loaderup.cli up --app main:app --targets targets --reload
95
+ ```
96
+
97
+ Run all decorator-registered targets against your FastAPI app:
98
+
99
+ ```bash
100
+ curl -X POST http://127.0.0.1:5050/run/registry \
101
+ -H 'Content-Type: application/json' \
102
+ -d '{"base_url":"http://127.0.0.1:5050","vus":10,"duration_seconds":30}'
103
+ ```
104
+
105
+ ## Health check
106
+
107
+ ```bash
108
+ curl http://127.0.0.1:5050/health
109
+ ```
110
+
111
+ ## Dashboard
112
+
113
+ Open:
114
+
115
+ ```bash
116
+ http://127.0.0.1:5050/
117
+ ```
118
+
119
+ Dashboard includes:
120
+
121
+ - live status + progress stream
122
+ - metrics cards + quick chart bars
123
+ - saved run history (persisted in `artifacts/history/runs.jsonl`)
124
+ - React-based multi-tab control center (`Run Now`, `Live`, `Registered Targets`, `History`)
125
+
126
+ ## Notes
127
+
128
+ - Registry targets are loaded when the module is imported.
129
+ - Use `/run/targets` to submit explicit targets.
130
+ - Use `/run/registry` to run all decorator-registered targets.
@@ -1,4 +1,5 @@
1
1
  import json
2
+ from datetime import datetime
2
3
  from typing import Any, Dict, Tuple
3
4
  from loader.models import SummaryMetrics
4
5
 
@@ -121,3 +122,81 @@ def parse_k6_diagnostics(stdout_text: str, stderr_text: str = "") -> list[dict[s
121
122
  diagnostics.append(data)
122
123
 
123
124
  return diagnostics
125
+
126
+
127
+ def _to_dt(value: str) -> datetime | None:
128
+ text = str(value or "").strip()
129
+ if not text:
130
+ return None
131
+ if text.endswith("Z"):
132
+ text = text[:-1] + "+00:00"
133
+ try:
134
+ return datetime.fromisoformat(text)
135
+ except ValueError:
136
+ return None
137
+
138
+
139
+ def _p95(values: list[float]) -> float | None:
140
+ if not values:
141
+ return None
142
+ ordered = sorted(values)
143
+ idx = max(0, min(len(ordered) - 1, int(0.95 * (len(ordered) - 1))))
144
+ return float(ordered[idx])
145
+
146
+
147
+ def parse_k6_latency_timeseries(stream_path: str) -> list[dict[str, Any]]:
148
+ buckets: dict[int, list[float]] = {}
149
+ start_time: datetime | None = None
150
+
151
+ with open(stream_path, "r", encoding="utf-8") as handle:
152
+ for line in handle:
153
+ line = line.strip()
154
+ if not line:
155
+ continue
156
+
157
+ try:
158
+ entry = json.loads(line)
159
+ except json.JSONDecodeError:
160
+ continue
161
+
162
+ if entry.get("type") != "Point" or entry.get("metric") != "http_req_duration":
163
+ continue
164
+
165
+ data = entry.get("data")
166
+ if not isinstance(data, dict):
167
+ continue
168
+
169
+ value = data.get("value")
170
+ if not isinstance(value, (int, float)):
171
+ continue
172
+
173
+ t = _to_dt(data.get("time", ""))
174
+ if not t:
175
+ continue
176
+
177
+ if start_time is None:
178
+ start_time = t
179
+
180
+ second = int((t - start_time).total_seconds()) + 1
181
+ if second < 1:
182
+ second = 1
183
+
184
+ buckets.setdefault(second, []).append(float(value))
185
+
186
+ if not buckets:
187
+ return []
188
+
189
+ points: list[dict[str, Any]] = []
190
+ for second in sorted(buckets):
191
+ values = buckets[second]
192
+ avg = sum(values) / len(values)
193
+ points.append(
194
+ {
195
+ "second": second,
196
+ "avg_ms": float(avg),
197
+ "p95_ms": _p95(values),
198
+ "count": len(values),
199
+ }
200
+ )
201
+
202
+ return points
@@ -7,12 +7,15 @@ async def run_k6(script_path: str, job_id: str) -> dict:
7
7
  out_dir.mkdir(parents=True, exist_ok=True)
8
8
 
9
9
  summary_path = out_dir / "summary.json"
10
+ metrics_stream_path = out_dir / "metrics.jsonl"
10
11
 
11
12
  process = await asyncio.create_subprocess_exec(
12
13
  "k6",
13
14
  "run",
14
15
  "--summary-export",
15
16
  str(summary_path),
17
+ "--out",
18
+ f"json={metrics_stream_path}",
16
19
  script_path,
17
20
  stdout=asyncio.subprocess.PIPE,
18
21
  stderr=asyncio.subprocess.PIPE,
@@ -35,6 +38,7 @@ async def run_k6(script_path: str, job_id: str) -> dict:
35
38
 
36
39
  return {
37
40
  "summary_path": str(summary_path),
41
+ "metrics_stream_path": str(metrics_stream_path),
38
42
  "stdout": stdout_text,
39
43
  "stderr": stderr_text,
40
- }
44
+ }
@@ -1,7 +1,7 @@
1
1
  # demo_registry_targets.py
2
2
 
3
3
  from loaderup import load_target
4
- """
4
+ '''
5
5
  @load_target(
6
6
  name="home",
7
7
  method="GET",
@@ -11,7 +11,7 @@ from loaderup import load_target
11
11
  )
12
12
  def home():
13
13
  pass
14
-
14
+ '''
15
15
  @load_target(
16
16
  name="home",
17
17
  method="POST",
@@ -22,4 +22,3 @@ def home():
22
22
  def gg():
23
23
  pass
24
24
 
25
- """
@@ -147,4 +147,4 @@ async def get_run_history(limit: int = 20):
147
147
  if __name__ == "__main__":
148
148
  import uvicorn
149
149
 
150
- uvicorn.run(app, host="0.0.0.0", port=8000)
150
+ uvicorn.run(app, host="0.0.0.0", port=5050)
@@ -74,6 +74,13 @@ class TargetMetric(BaseModel):
74
74
  note: Optional[str] = None
75
75
 
76
76
 
77
+ class LatencySeriesPoint(BaseModel):
78
+ second: int
79
+ avg_ms: Optional[float] = None
80
+ p95_ms: Optional[float] = None
81
+ count: int = 0
82
+
83
+
77
84
  class JobResult(BaseModel):
78
85
  config: Optional[RunConfig] = None
79
86
  k6_script_path: Optional[str] = None
@@ -84,6 +91,7 @@ class JobResult(BaseModel):
84
91
  target_metrics: List[TargetMetric] = Field(default_factory=list)
85
92
  raw_summary: Optional[Dict[str, Any]] = None
86
93
  status_diagnostics: List[Dict[str, Any]] = Field(default_factory=list)
94
+ latency_timeseries: List[LatencySeriesPoint] = Field(default_factory=list)
87
95
 
88
96
 
89
97
  class Job(BaseModel):
@@ -3,7 +3,11 @@ 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, parse_k6_diagnostics
6
+ from agents.analyzer import (
7
+ parse_k6_summary,
8
+ parse_k6_diagnostics,
9
+ parse_k6_latency_timeseries,
10
+ )
7
11
 
8
12
 
9
13
  async def set_status(job, status: JobStatus, message: str):
@@ -85,6 +89,9 @@ async def run_targets_pipeline(job_id: str, config: RunConfig):
85
89
  metrics, raw_summary = parse_k6_summary(run_output["summary_path"])
86
90
  job.result.metrics = metrics
87
91
  job.result.raw_summary = raw_summary
92
+ stream_path = run_output.get("metrics_stream_path")
93
+ if stream_path:
94
+ job.result.latency_timeseries = parse_k6_latency_timeseries(stream_path)
88
95
  update_job(job)
89
96
 
90
97
  await push_event(
@@ -4,9 +4,9 @@ import htm from "https://esm.sh/htm@3.1.1";
4
4
 
5
5
  const html = htm.bind(React.createElement);
6
6
 
7
- const NAV = ["Run Now", "Live Runs", "Registry", "History"];
7
+ const NAV = ["Run Now", "Live Runs", "Registry", "History", "History Plot"];
8
8
  const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
9
- const UI_VERSION = "20260418o";
9
+ const UI_VERSION = "20260423a";
10
10
 
11
11
  const METRIC_NAMES = {
12
12
  total_requests: "Total Requests",
@@ -19,6 +19,11 @@ const METRIC_NAMES = {
19
19
  checks_pass_rate: "Checks Pass Rate",
20
20
  };
21
21
 
22
+ const LATENCY_SERIES = [
23
+ { key: "avg_http_req_duration_ms", title: "Average", lineClass: "avg" },
24
+ { key: "p95_http_req_duration_ms", title: "P95", lineClass: "p95" },
25
+ ];
26
+
22
27
  const defaultTarget = () => ({
23
28
  name: "home",
24
29
  method: "GET",
@@ -37,6 +42,43 @@ function metricValue(key, value) {
37
42
  return String(value);
38
43
  }
39
44
 
45
+ function formatMs(value) {
46
+ const n = Number(value);
47
+ if (!Number.isFinite(n)) return "n/a";
48
+ return `${n.toFixed(1)} ms`;
49
+ }
50
+
51
+ function buildSelectedRunLatencyPoints(run, latencyMetricKey = "avg_http_req_duration_ms") {
52
+ const field = latencyMetricKey === "p95_http_req_duration_ms" ? "p95_ms" : "avg_ms";
53
+ const raw = Array.isArray(run?.result?.latency_timeseries) ? run.result.latency_timeseries : [];
54
+
55
+ const fromSeries = raw
56
+ .map((point) => {
57
+ const second = Number(point?.second);
58
+ const latency = Number(point?.[field]);
59
+ if (!Number.isFinite(second) || !Number.isFinite(latency)) return null;
60
+ return {
61
+ job_id: run?.job_id,
62
+ duration: second,
63
+ latency,
64
+ vus: Number(run?.result?.config?.vus),
65
+ status: run?.status || "pending",
66
+ };
67
+ })
68
+ .filter(Boolean)
69
+ .sort((a, b) => a.duration - b.duration);
70
+
71
+ if (fromSeries.length) return fromSeries;
72
+ return [];
73
+ }
74
+
75
+ function buildLinePath(points, xFor, yFor) {
76
+ if (!points.length) return "";
77
+ return points
78
+ .map((point, index) => `${index === 0 ? "M" : "L"}${xFor(point.duration).toFixed(2)},${yFor(point.latency).toFixed(2)}`)
79
+ .join(" ");
80
+ }
81
+
40
82
  function barItems(metrics = {}) {
41
83
  return [
42
84
  ["Checks", (metrics.checks_pass_rate || 0) * 100],
@@ -291,7 +333,7 @@ function TopBar({ tab, health, activeRuns, reloadAll }) {
291
333
 
292
334
  function RunNow({ registryTargets, onRunStart }) {
293
335
  const [mode, setMode] = useState("registry");
294
- const [baseUrl, setBaseUrl] = useState("http://127.0.0.1:8000");
336
+ const [baseUrl, setBaseUrl] = useState("http://127.0.0.1:5050");
295
337
  const [vus, setVus] = useState(10);
296
338
  const [duration, setDuration] = useState(30);
297
339
  const [targets, setTargets] = useState([defaultTarget()]);
@@ -1018,6 +1060,283 @@ function History({ runs, refresh, selectedRun, setSelectedRun, onRunAgain, rerun
1018
1060
  `;
1019
1061
  }
1020
1062
 
1063
+ function HistoryPlot({ runs, refresh, selectedRun, setSelectedRun }) {
1064
+ const [hoveredPoint, setHoveredPoint] = useState(null);
1065
+ const [visibleSeries, setVisibleSeries] = useState({ avg: true, p95: true });
1066
+ const plotWrapRef = useRef(null);
1067
+ const selectedJobId = selectedRun?.job_id || null;
1068
+
1069
+ const avgPoints = useMemo(() => buildSelectedRunLatencyPoints(selectedRun, "avg_http_req_duration_ms"), [selectedRun]);
1070
+ const p95Points = useMemo(() => buildSelectedRunLatencyPoints(selectedRun, "p95_http_req_duration_ms"), [selectedRun]);
1071
+ const allPoints = useMemo(
1072
+ () => [
1073
+ ...avgPoints.map((point) => ({ ...point, series: "avg_http_req_duration_ms", lineClass: "avg" })),
1074
+ ...p95Points.map((point) => ({ ...point, series: "p95_http_req_duration_ms", lineClass: "p95" })),
1075
+ ],
1076
+ [avgPoints, p95Points]
1077
+ );
1078
+
1079
+ const pointsByJob = useMemo(
1080
+ () =>
1081
+ allPoints.reduce((acc, point) => {
1082
+ if (!acc[point.job_id]) acc[point.job_id] = {};
1083
+ acc[point.job_id][point.series] = point;
1084
+ return acc;
1085
+ }, {}),
1086
+ [allPoints]
1087
+ );
1088
+
1089
+ const selectedPointSet = useMemo(() => pointsByJob[selectedRun?.job_id] || null, [pointsByJob, selectedRun]);
1090
+ const selectedPoint = useMemo(
1091
+ () => selectedPointSet?.avg_http_req_duration_ms || selectedPointSet?.p95_http_req_duration_ms || null,
1092
+ [selectedPointSet]
1093
+ );
1094
+
1095
+ const visibleAllPoints = useMemo(
1096
+ () => allPoints.filter((point) => !!visibleSeries[point.lineClass] && point.job_id === selectedJobId),
1097
+ [allPoints, visibleSeries, selectedJobId]
1098
+ );
1099
+
1100
+ const visibleAvgPoints = useMemo(
1101
+ () => (visibleSeries.avg ? avgPoints.filter((point) => point.job_id === selectedJobId) : []),
1102
+ [avgPoints, visibleSeries.avg, selectedJobId]
1103
+ );
1104
+ const visibleP95Points = useMemo(
1105
+ () => (visibleSeries.p95 ? p95Points.filter((point) => point.job_id === selectedJobId) : []),
1106
+ [p95Points, visibleSeries.p95, selectedJobId]
1107
+ );
1108
+
1109
+ function toggleSeries(lineClass) {
1110
+ setVisibleSeries((prev) => ({ ...prev, [lineClass]: !prev[lineClass] }));
1111
+ setHoveredPoint(null);
1112
+ }
1113
+
1114
+ useEffect(() => {
1115
+ setHoveredPoint(null);
1116
+ }, [selectedRun?.job_id]);
1117
+
1118
+ function updateHover(point, event) {
1119
+ if (!plotWrapRef.current) return;
1120
+ const rect = plotWrapRef.current.getBoundingClientRect();
1121
+ const x = Math.max(12, Math.min(rect.width - 12, event.clientX - rect.left + 12));
1122
+ const y = Math.max(8, Math.min(rect.height - 8, event.clientY - rect.top - 10));
1123
+ setHoveredPoint({ point, x, y });
1124
+ }
1125
+
1126
+ const width = 860;
1127
+ const height = 420;
1128
+ const margin = { top: 26, right: 26, bottom: 50, left: 68 };
1129
+ const innerWidth = width - margin.left - margin.right;
1130
+ const innerHeight = height - margin.top - margin.bottom;
1131
+
1132
+ const xValues = visibleAllPoints.map((point) => point.duration);
1133
+ const yValues = visibleAllPoints.map((point) => point.latency);
1134
+
1135
+ const xMinRaw = xValues.length ? Math.min(...xValues) : 0;
1136
+ const xMaxRaw = xValues.length ? Math.max(...xValues) : 1;
1137
+ const yMinRaw = yValues.length ? Math.min(...yValues) : 0;
1138
+ const yMaxRaw = yValues.length ? Math.max(...yValues) : 1;
1139
+
1140
+ const xPad = Math.max((xMaxRaw - xMinRaw) * 0.12, 1);
1141
+ const yPad = Math.max((yMaxRaw - yMinRaw) * 0.16, 4);
1142
+
1143
+ const xMin = xMinRaw - xPad;
1144
+ const xMax = xMaxRaw + xPad;
1145
+ const yMin = Math.max(0, yMinRaw - yPad);
1146
+ const yMax = yMaxRaw + yPad;
1147
+
1148
+ const xFor = (v) => margin.left + ((v - xMin) / Math.max(0.0001, xMax - xMin)) * innerWidth;
1149
+ const yFor = (v) => margin.top + (1 - (v - yMin) / Math.max(0.0001, yMax - yMin)) * innerHeight;
1150
+
1151
+ const yTicks = 5;
1152
+ const xTicks = 5;
1153
+
1154
+ const yGrid = Array.from({ length: yTicks + 1 }, (_, i) => {
1155
+ const ratio = i / yTicks;
1156
+ const value = yMin + (yMax - yMin) * (1 - ratio);
1157
+ const y = margin.top + innerHeight * ratio;
1158
+ return { value, y };
1159
+ });
1160
+
1161
+ const xGrid = Array.from({ length: xTicks + 1 }, (_, i) => {
1162
+ const ratio = i / xTicks;
1163
+ const value = xMin + (xMax - xMin) * ratio;
1164
+ const x = margin.left + innerWidth * ratio;
1165
+ return { value, x };
1166
+ });
1167
+
1168
+ const avgLinePath = buildLinePath(visibleAvgPoints, xFor, yFor);
1169
+ const p95LinePath = buildLinePath(visibleP95Points, xFor, yFor);
1170
+ const runLookup = useMemo(
1171
+ () =>
1172
+ runs.reduce((acc, run) => {
1173
+ acc[run.job_id] = run;
1174
+ return acc;
1175
+ }, {}),
1176
+ [runs]
1177
+ );
1178
+
1179
+ return html`
1180
+ <div className="history-plot-layout">
1181
+ <section className="card history-plot-main">
1182
+ <div className="toolbar">
1183
+ <h3>Duration vs Latency</h3>
1184
+ <span className="pill">${visibleAllPoints.length}/${allPoints.length} points</span>
1185
+ </div>
1186
+ <p className="muted">Selected run only. X axis: elapsed second in this run. Y axis: latency (ms).</p>
1187
+ <div className="plot-legend" aria-label="Latency series legend">
1188
+ ${LATENCY_SERIES.map((item) => html`
1189
+ <button
1190
+ key=${item.key}
1191
+ className=${`legend-chip ${visibleSeries[item.lineClass] ? "on" : "off"}`}
1192
+ onClick=${() => toggleSeries(item.lineClass)}
1193
+ aria-pressed=${visibleSeries[item.lineClass] ? "true" : "false"}
1194
+ title=${visibleSeries[item.lineClass] ? `Hide ${item.title}` : `Show ${item.title}`}
1195
+ >
1196
+ <span className=${`legend-dot ${item.lineClass}`}></span>
1197
+ ${item.title}
1198
+ </button>
1199
+ `)}
1200
+ </div>
1201
+
1202
+ ${visibleAllPoints.length
1203
+ ? html`
1204
+ <div className="history-plot-wrap" ref=${plotWrapRef}>
1205
+ <svg className="history-plot" viewBox=${`0 0 ${width} ${height}`} role="img" aria-label="Run duration vs latency chart">
1206
+ <defs>
1207
+ <filter id="point-glow" x="-80%" y="-80%" width="260%" height="260%">
1208
+ <feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#18a9c2" floodOpacity="0.62" />
1209
+ </filter>
1210
+ </defs>
1211
+
1212
+ ${yGrid.map((tick, idx) => html`
1213
+ <g key=${`y-${idx}`}>
1214
+ <line x1=${margin.left} x2=${width - margin.right} y1=${tick.y} y2=${tick.y} className="plot-grid-line" />
1215
+ <text x=${margin.left - 12} y=${tick.y + 4} textAnchor="end" className="plot-axis-label">${tick.value.toFixed(0)}</text>
1216
+ </g>
1217
+ `)}
1218
+
1219
+ ${xGrid.map((tick, idx) => html`
1220
+ <g key=${`x-${idx}`}>
1221
+ <line x1=${tick.x} x2=${tick.x} y1=${margin.top} y2=${height - margin.bottom} className="plot-grid-line vertical" />
1222
+ <text x=${tick.x} y=${height - margin.bottom + 22} textAnchor="middle" className="plot-axis-label">${tick.value.toFixed(0)}s</text>
1223
+ </g>
1224
+ `)}
1225
+
1226
+ <line x1=${margin.left} x2=${width - margin.right} y1=${height - margin.bottom} y2=${height - margin.bottom} className="plot-axis" />
1227
+ <line x1=${margin.left} x2=${margin.left} y1=${margin.top} y2=${height - margin.bottom} className="plot-axis" />
1228
+
1229
+ ${avgLinePath && html`<path d=${avgLinePath} fill="none" className="plot-line avg" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />`}
1230
+ ${p95LinePath && html`<path d=${p95LinePath} fill="none" className="plot-line p95" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />`}
1231
+
1232
+ ${visibleAllPoints.map((point, idx) => {
1233
+ const selected = point.job_id === selectedRun?.job_id;
1234
+ const run = runLookup[point.job_id];
1235
+ return html`
1236
+ <g
1237
+ key=${`${point.job_id}-${point.series}-${point.duration}-${idx}`}
1238
+ className=${`plot-point-group ${selected ? "selected" : ""}`}
1239
+ onClick=${() => run && setSelectedRun(run)}
1240
+ onMouseEnter=${(event) => updateHover(point, event)}
1241
+ onMouseMove=${(event) => updateHover(point, event)}
1242
+ onMouseLeave=${() => setHoveredPoint(null)}
1243
+ >
1244
+ <circle
1245
+ cx=${xFor(point.duration)}
1246
+ cy=${yFor(point.latency)}
1247
+ r=${selected ? 8 : 5.2}
1248
+ className=${`plot-point ${point.lineClass}`}
1249
+ filter=${selected ? "url(#point-glow)" : undefined}
1250
+ />
1251
+ </g>
1252
+ `;
1253
+ })}
1254
+
1255
+ <text x=${width / 2} y=${height - 10} textAnchor="middle" className="plot-title-label">Elapsed Time (s)</text>
1256
+ <text
1257
+ x="18"
1258
+ y=${height / 2}
1259
+ transform=${`rotate(-90 18 ${height / 2})`}
1260
+ textAnchor="middle"
1261
+ className="plot-title-label"
1262
+ >
1263
+ Latency (ms)
1264
+ </text>
1265
+ </svg>
1266
+ ${hoveredPoint && html`
1267
+ <div className="plot-tooltip" style=${{ left: `${hoveredPoint.x}px`, top: `${hoveredPoint.y}px` }}>
1268
+ <div><strong>${hoveredPoint.point.job_id}</strong></div>
1269
+ <div>Duration: ${hoveredPoint.point.duration}s</div>
1270
+ ${visibleSeries.avg && html`<div>Average: ${formatMs(runLookup[hoveredPoint.point.job_id]?.result?.metrics?.avg_http_req_duration_ms)}</div>`}
1271
+ ${visibleSeries.p95 && html`<div>P95: ${formatMs(runLookup[hoveredPoint.point.job_id]?.result?.metrics?.p95_http_req_duration_ms)}</div>`}
1272
+ </div>
1273
+ `}
1274
+ </div>
1275
+ `
1276
+ : html`
1277
+ <div className="item muted">
1278
+ ${selectedRun
1279
+ ? "This run has no per-second latency series yet (or both series are hidden). Re-run this test after backend restart to render line data."
1280
+ : "Select a history run from the right menu to render its own plot."}
1281
+ </div>
1282
+ `}
1283
+
1284
+ <div className="plot-run-summary">
1285
+ <div className="item">
1286
+ <div className="n">Selected Run</div>
1287
+ <div className="v">${selectedPoint ? selectedPoint.job_id : "None"}</div>
1288
+ </div>
1289
+ <div className="item">
1290
+ <div className="n">Duration</div>
1291
+ <div className="v">${selectedRun?.result?.config?.duration_seconds ? `${selectedRun.result.config.duration_seconds}s` : "n/a"}</div>
1292
+ </div>
1293
+ <div className="item">
1294
+ <div className="n">Average</div>
1295
+ <div className="v">${selectedPointSet ? formatMs(selectedPointSet.avg_http_req_duration_ms?.latency) : "n/a"}</div>
1296
+ </div>
1297
+ <div className="item">
1298
+ <div className="n">P95</div>
1299
+ <div className="v">${selectedPointSet ? formatMs(selectedPointSet.p95_http_req_duration_ms?.latency) : "n/a"}</div>
1300
+ </div>
1301
+ <div className="item">
1302
+ <div className="n">Virtual Users</div>
1303
+ <div className="v">${selectedPoint && Number.isFinite(selectedPoint.vus) ? selectedPoint.vus : "n/a"}</div>
1304
+ </div>
1305
+ </div>
1306
+ </section>
1307
+
1308
+ <aside className="card history-runs-panel">
1309
+ <div className="toolbar">
1310
+ <h3>History Runs</h3>
1311
+ <button className="ghost" onClick=${refresh}>Refresh Runs</button>
1312
+ </div>
1313
+ <p className="muted">Scrollable run menu. Click one to focus the chart.</p>
1314
+ <div className="list">
1315
+ ${runs.length
1316
+ ? runs.map((run) => {
1317
+ const active = run.job_id === selectedRun?.job_id;
1318
+ return html`
1319
+ <button
1320
+ key=${run.job_id}
1321
+ className=${`item run-focus-btn ${active ? "active" : ""}`}
1322
+ onClick=${() => setSelectedRun(run)}
1323
+ >
1324
+ <div className="toolbar">
1325
+ <strong>${run.job_id}</strong>
1326
+ ${statusPill(run.status)}
1327
+ </div>
1328
+ <div className="muted">duration=${run?.result?.config?.duration_seconds ?? "n/a"}s, vus=${run?.result?.config?.vus ?? "n/a"}</div>
1329
+ <div className="muted">avg=${formatMs(run?.result?.metrics?.avg_http_req_duration_ms)}, p95=${formatMs(run?.result?.metrics?.p95_http_req_duration_ms)}</div>
1330
+ </button>
1331
+ `;
1332
+ })
1333
+ : html`<div className="item muted">No history runs yet. Start a run first.</div>`}
1334
+ </div>
1335
+ </aside>
1336
+ </div>
1337
+ `;
1338
+ }
1339
+
1021
1340
  function App() {
1022
1341
  const [tab, setTab] = useState("Run Now");
1023
1342
  const [health, setHealth] = useState({ status: "checking", k6_installed: false });
@@ -1222,6 +1541,7 @@ function App() {
1222
1541
  ${tab === "Live Runs" && html`<${LiveRuns} activeJob=${activeJob} setActiveJob=${setActiveJob} activeRuns=${activeRuns} logs=${logs} result=${result} error=${streamError} refreshActive=${loadActive} />`}
1223
1542
  ${tab === "Registry" && html`<${RegistryPage} targets=${registryTargets} refresh=${loadRegistry} />`}
1224
1543
  ${tab === "History" && html`<${History} runs=${historyRuns} refresh=${loadHistory} selectedRun=${historyActiveRun} setSelectedRun=${setHistoryActiveRun} onRunAgain=${rerunHistoryRun} rerunBusy=${historyRerunBusy} rerunError=${historyRerunError} />`}
1544
+ ${tab === "History Plot" && html`<${HistoryPlot} runs=${historyRuns} refresh=${loadHistory} selectedRun=${historyActiveRun} setSelectedRun=${setHistoryActiveRun} />`}
1225
1545
  </div>
1226
1546
  </div>
1227
1547
  </div>
@@ -399,6 +399,191 @@ textarea {
399
399
  border-color: rgba(15, 124, 144, 0.44);
400
400
  }
401
401
 
402
+ .run-focus-btn {
403
+ width: 100%;
404
+ text-align: left;
405
+ display: block;
406
+ background: linear-gradient(180deg, #152130, #121c28);
407
+ }
408
+
409
+ .run-focus-btn.active {
410
+ border-color: rgba(24, 169, 194, 0.7);
411
+ box-shadow: 0 0 0 1px rgba(24, 169, 194, 0.26), 0 14px 24px rgba(0, 0, 0, 0.24);
412
+ }
413
+
414
+ .plot-legend {
415
+ display: flex;
416
+ align-items: center;
417
+ gap: 0.45rem;
418
+ margin: 0.3rem 0 0.2rem;
419
+ flex-wrap: wrap;
420
+ }
421
+
422
+ .legend-chip {
423
+ appearance: none;
424
+ display: inline-flex;
425
+ align-items: center;
426
+ gap: 0.35rem;
427
+ border: 1px solid var(--line);
428
+ background: rgba(14, 24, 36, 0.85);
429
+ border-radius: 999px;
430
+ padding: 0.24rem 0.5rem;
431
+ font-size: 0.75rem;
432
+ color: #c8d9eb;
433
+ box-shadow: none;
434
+ }
435
+
436
+ .legend-chip:hover {
437
+ transform: none;
438
+ box-shadow: none;
439
+ border-color: rgba(148, 179, 209, 0.65);
440
+ }
441
+
442
+ .legend-chip.off {
443
+ opacity: 0.55;
444
+ }
445
+
446
+ .legend-dot {
447
+ width: 9px;
448
+ height: 9px;
449
+ border-radius: 999px;
450
+ }
451
+
452
+ .legend-dot.avg {
453
+ background: #22cce3;
454
+ }
455
+
456
+ .legend-dot.p95 {
457
+ background: #f5a164;
458
+ }
459
+
460
+ .history-plot-wrap {
461
+ margin-top: 0.35rem;
462
+ position: relative;
463
+ border: 1px solid var(--line);
464
+ border-radius: 14px;
465
+ background: linear-gradient(180deg, rgba(13, 22, 33, 0.82), rgba(12, 20, 30, 0.96));
466
+ overflow: hidden;
467
+ }
468
+
469
+ .history-plot {
470
+ display: block;
471
+ width: 100%;
472
+ height: auto;
473
+ }
474
+
475
+ .plot-grid-line {
476
+ stroke: rgba(156, 176, 200, 0.18);
477
+ stroke-width: 1;
478
+ }
479
+
480
+ .plot-grid-line.vertical {
481
+ stroke: rgba(156, 176, 200, 0.1);
482
+ }
483
+
484
+ .plot-axis {
485
+ stroke: rgba(180, 205, 232, 0.56);
486
+ stroke-width: 1.2;
487
+ }
488
+
489
+ .plot-axis-label {
490
+ fill: #a9bfd7;
491
+ font-size: 11px;
492
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
493
+ }
494
+
495
+ .plot-title-label {
496
+ fill: #c8d9eb;
497
+ font-size: 12px;
498
+ font-weight: 600;
499
+ }
500
+
501
+ .plot-point-group {
502
+ cursor: pointer;
503
+ }
504
+
505
+ .plot-line.avg {
506
+ stroke: #22cce3;
507
+ }
508
+
509
+ .plot-line.p95 {
510
+ stroke: #f5a164;
511
+ }
512
+
513
+ .plot-point {
514
+ stroke: rgba(12, 20, 32, 0.84);
515
+ stroke-width: 2;
516
+ transition: transform 120ms ease, fill 120ms ease;
517
+ }
518
+
519
+ .plot-point.avg {
520
+ fill: #18a9c2;
521
+ }
522
+
523
+ .plot-point.p95 {
524
+ fill: #f08d53;
525
+ }
526
+
527
+ .plot-point-group:hover .plot-point {
528
+ fill: #ffc88f;
529
+ }
530
+
531
+ .plot-point-group.selected .plot-point.avg {
532
+ fill: #18d4ec;
533
+ }
534
+
535
+ .plot-point-group.selected .plot-point.p95 {
536
+ fill: #ffc88f;
537
+ }
538
+
539
+ .plot-tooltip {
540
+ position: absolute;
541
+ transform: translate(-50%, -100%);
542
+ pointer-events: none;
543
+ z-index: 4;
544
+ min-width: 180px;
545
+ max-width: min(280px, 70vw);
546
+ border: 1px solid rgba(173, 202, 231, 0.35);
547
+ border-radius: 12px;
548
+ padding: 0.45rem 0.56rem;
549
+ background: rgba(8, 16, 24, 0.94);
550
+ box-shadow: 0 16px 28px rgba(0, 0, 0, 0.32);
551
+ color: #dceafb;
552
+ font-size: 0.77rem;
553
+ line-height: 1.45;
554
+ }
555
+
556
+ .plot-run-summary {
557
+ margin-top: 0.8rem;
558
+ display: grid;
559
+ grid-template-columns: repeat(4, minmax(0, 1fr));
560
+ gap: 0.62rem;
561
+ }
562
+
563
+ .history-plot-layout {
564
+ display: grid;
565
+ grid-template-columns: minmax(0, 1fr) 340px;
566
+ gap: 0.9rem;
567
+ align-items: start;
568
+ }
569
+
570
+ .history-plot-main {
571
+ min-width: 0;
572
+ }
573
+
574
+ .history-runs-panel {
575
+ position: sticky;
576
+ top: 1rem;
577
+ max-height: calc(100vh - 2rem);
578
+ display: flex;
579
+ flex-direction: column;
580
+ }
581
+
582
+ .history-runs-panel .list {
583
+ overflow-y: auto;
584
+ padding-right: 0.18rem;
585
+ }
586
+
402
587
  .metrics {
403
588
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
404
589
  }
@@ -547,6 +732,23 @@ pre {
547
732
  .grid-3 {
548
733
  grid-template-columns: 1fr;
549
734
  }
735
+
736
+ .history-plot-layout {
737
+ grid-template-columns: 1fr;
738
+ }
739
+
740
+ .history-runs-panel {
741
+ position: static;
742
+ max-height: none;
743
+ }
744
+
745
+ .history-runs-panel .list {
746
+ max-height: 360px;
747
+ }
748
+
749
+ .plot-run-summary {
750
+ grid-template-columns: repeat(2, minmax(0, 1fr));
751
+ }
550
752
  }
551
753
 
552
754
  @media (max-width: 760px) {
@@ -574,6 +776,10 @@ pre {
574
776
  grid-template-columns: repeat(2, minmax(0, 1fr));
575
777
  }
576
778
 
779
+ .plot-run-summary {
780
+ grid-template-columns: 1fr;
781
+ }
782
+
577
783
  .topbar,
578
784
  .card,
579
785
  .kpi-card {
@@ -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=20260418o">
10
+ <link rel="stylesheet" href="/assets/styles.css?v=20260423a">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
14
- <script type="module" src="/assets/app.js?v=20260418o"></script>
14
+ <script type="module" src="/assets/app.js?v=20260423a"></script>
15
15
  </body>
16
16
  </html>
@@ -174,7 +174,7 @@ def build_parser():
174
174
  help="Comma-separated Python modules to import for decorators, e.g. demo_registry_targets",
175
175
  )
176
176
  up_parser.add_argument("--host", default="127.0.0.1")
177
- up_parser.add_argument("--port", type=int, default=8000)
177
+ up_parser.add_argument("--port", type=int, default=5050)
178
178
  up_parser.add_argument("--reload", action="store_true")
179
179
  up_parser.add_argument(
180
180
  "--open-browser",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loaderup
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -33,7 +33,7 @@ Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-bas
33
33
 
34
34
  - Python `>=3.12`
35
35
  - `uv` (recommended package manager)
36
- - `k6` (optional but recommended for real load runs)
36
+ - `k6`
37
37
 
38
38
  ## Install
39
39
 
@@ -47,7 +47,7 @@ uv pip install -e .
47
47
  python -m loader.main
48
48
  ```
49
49
 
50
- Server starts on `http://127.0.0.1:8000` by default.
50
+ Server starts on `http://127.0.0.1:5050` by default.
51
51
 
52
52
  ## Run via CLI
53
53
 
@@ -55,7 +55,7 @@ Server starts on `http://127.0.0.1:8000` by default.
55
55
  python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
56
56
  ```
57
57
 
58
- This command opens the dashboard automatically at `http://127.0.0.1:8000/`.
58
+ This command opens the dashboard automatically at `http://127.0.0.1:5050/`.
59
59
  Use `--no-open-browser` if you want to disable auto-open.
60
60
 
61
61
  You can also use fallback-friendly forms like:
@@ -64,10 +64,75 @@ You can also use fallback-friendly forms like:
64
64
  python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
65
65
  ```
66
66
 
67
+ ## Using the decorator with FastAPI
68
+
69
+ Create your FastAPI app as usual:
70
+
71
+ ```python
72
+ # main.py
73
+ from fastapi import FastAPI
74
+
75
+ app = FastAPI()
76
+
77
+
78
+ @app.get("/")
79
+ def home():
80
+ return {"message": "ok"}
81
+
82
+
83
+ @app.post("/users")
84
+ def create_user(payload: dict):
85
+ return {"id": 1, **payload}
86
+ ```
87
+
88
+ Then create a module with the load targets you want LoaderUp to test. The decorated functions do not need to call your route handlers; importing this module registers the target metadata.
89
+
90
+ ```python
91
+ # targets.py
92
+ from loaderup import load_target
93
+
94
+
95
+ @load_target(
96
+ name="home page",
97
+ method="GET",
98
+ path="/",
99
+ expected_status=200,
100
+ tags=["public"],
101
+ )
102
+ def home_page():
103
+ pass
104
+
105
+
106
+ @load_target(
107
+ name="create user",
108
+ method="POST",
109
+ path="/users",
110
+ payload_example={"name": "Ada"},
111
+ expected_status=200,
112
+ tags=["users"],
113
+ )
114
+ def create_user():
115
+ pass
116
+ ```
117
+
118
+ Start LoaderUp and tell it to import the target module:
119
+
120
+ ```bash
121
+ python -m loaderup.cli up --app main:app --targets targets --reload
122
+ ```
123
+
124
+ Run all decorator-registered targets against your FastAPI app:
125
+
126
+ ```bash
127
+ curl -X POST http://127.0.0.1:5050/run/registry \
128
+ -H 'Content-Type: application/json' \
129
+ -d '{"base_url":"http://127.0.0.1:5050","vus":10,"duration_seconds":30}'
130
+ ```
131
+
67
132
  ## Health check
68
133
 
69
134
  ```bash
70
- curl http://127.0.0.1:8000/health
135
+ curl http://127.0.0.1:5050/health
71
136
  ```
72
137
 
73
138
  ## Dashboard
@@ -75,7 +140,7 @@ curl http://127.0.0.1:8000/health
75
140
  Open:
76
141
 
77
142
  ```bash
78
- http://127.0.0.1:8000/
143
+ http://127.0.0.1:5050/
79
144
  ```
80
145
 
81
146
  Dashboard includes:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "loaderup"
7
- version = "0.1.3"
7
+ version = "0.1.4"
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"
loaderup-0.1.3/README.md DELETED
@@ -1,65 +0,0 @@
1
- # LoaderUp
2
-
3
- Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
4
-
5
- ## Requirements
6
-
7
- - Python `>=3.12`
8
- - `uv` (recommended package manager)
9
- - `k6` (optional but recommended for real load runs)
10
-
11
- ## Install
12
-
13
- ```bash
14
- uv pip install -e .
15
- ```
16
-
17
- ## Run API directly
18
-
19
- ```bash
20
- python -m loader.main
21
- ```
22
-
23
- Server starts on `http://127.0.0.1:8000` by default.
24
-
25
- ## Run via CLI
26
-
27
- ```bash
28
- python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
29
- ```
30
-
31
- This command opens the dashboard automatically at `http://127.0.0.1:8000/`.
32
- Use `--no-open-browser` if you want to disable auto-open.
33
-
34
- You can also use fallback-friendly forms like:
35
-
36
- ```bash
37
- python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
38
- ```
39
-
40
- ## Health check
41
-
42
- ```bash
43
- curl http://127.0.0.1:8000/health
44
- ```
45
-
46
- ## Dashboard
47
-
48
- Open:
49
-
50
- ```bash
51
- http://127.0.0.1:8000/
52
- ```
53
-
54
- Dashboard includes:
55
-
56
- - live status + progress stream
57
- - metrics cards + quick chart bars
58
- - saved run history (persisted in `artifacts/history/runs.jsonl`)
59
- - React-based multi-tab control center (`Run Now`, `Live`, `Registered Targets`, `History`)
60
-
61
- ## Notes
62
-
63
- - Registry targets are loaded when the module is imported.
64
- - Use `/run/targets` to submit explicit targets.
65
- - Use `/run/registry` to run all decorator-registered targets.
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