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.
- {loaderup-0.1.3 → loaderup-0.1.4}/PKG-INFO +71 -6
- loaderup-0.1.4/README.md +130 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/agents/analyzer.py +79 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/agents/runner.py +5 -1
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/demo_registry_target.py +2 -3
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/main.py +1 -1
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/models.py +8 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/pipeline.py +8 -1
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/web/assets/app.js +323 -3
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/web/assets/styles.css +206 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/web/index.html +2 -2
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/cli.py +1 -1
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/PKG-INFO +71 -6
- {loaderup-0.1.3 → loaderup-0.1.4}/pyproject.toml +1 -1
- loaderup-0.1.3/README.md +0 -65
- {loaderup-0.1.3 → loaderup-0.1.4}/agents/generator.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/__init__.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/history.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/settings.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loader/store.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/__init__.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/autodiscovery.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/collector.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/decorators.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/importer.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/models.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup/registry.py +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/SOURCES.txt +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/dependency_links.txt +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/entry_points.txt +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/requires.txt +0 -0
- {loaderup-0.1.3 → loaderup-0.1.4}/loaderup.egg-info/top_level.txt +0 -0
- {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
|
+
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`
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
143
|
+
http://127.0.0.1:5050/
|
|
79
144
|
```
|
|
80
145
|
|
|
81
146
|
Dashboard includes:
|
loaderup-0.1.4/README.md
ADDED
|
@@ -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
|
-
"""
|
|
@@ -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
|
|
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 = "
|
|
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:
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
+
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`
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|