loaderup 0.1.0__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.0/PKG-INFO +78 -0
- loaderup-0.1.0/README.md +65 -0
- loaderup-0.1.0/agents/analyzer.py +78 -0
- loaderup-0.1.0/agents/generator.py +110 -0
- loaderup-0.1.0/agents/runner.py +40 -0
- loaderup-0.1.0/loader/__init__.py +0 -0
- loaderup-0.1.0/loader/demo_registry_target.py +25 -0
- loaderup-0.1.0/loader/history.py +36 -0
- loaderup-0.1.0/loader/main.py +150 -0
- loaderup-0.1.0/loader/models.py +93 -0
- loaderup-0.1.0/loader/pipeline.py +111 -0
- loaderup-0.1.0/loader/settings.py +4 -0
- loaderup-0.1.0/loader/store.py +43 -0
- loaderup-0.1.0/loader/web/assets/app.js +773 -0
- loaderup-0.1.0/loader/web/assets/styles.css +570 -0
- loaderup-0.1.0/loader/web/index.html +16 -0
- loaderup-0.1.0/loaderup/__init__.py +10 -0
- loaderup-0.1.0/loaderup/autodiscovery.py +65 -0
- loaderup-0.1.0/loaderup/cli.py +219 -0
- loaderup-0.1.0/loaderup/collector.py +11 -0
- loaderup-0.1.0/loaderup/decorators.py +34 -0
- loaderup-0.1.0/loaderup/importer.py +120 -0
- loaderup-0.1.0/loaderup/models.py +36 -0
- loaderup-0.1.0/loaderup/registry.py +31 -0
- loaderup-0.1.0/loaderup.egg-info/PKG-INFO +78 -0
- loaderup-0.1.0/loaderup.egg-info/SOURCES.txt +30 -0
- loaderup-0.1.0/loaderup.egg-info/dependency_links.txt +1 -0
- loaderup-0.1.0/loaderup.egg-info/entry_points.txt +2 -0
- loaderup-0.1.0/loaderup.egg-info/requires.txt +6 -0
- loaderup-0.1.0/loaderup.egg-info/top_level.txt +3 -0
- loaderup-0.1.0/pyproject.toml +34 -0
- loaderup-0.1.0/setup.cfg +4 -0
loaderup-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loaderup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi>=0.135.3
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: pydantic>=2.12.5
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.24
|
|
11
|
+
Requires-Dist: uvicorn>=0.44.0
|
|
12
|
+
Requires-Dist: rich>=13.10.0
|
|
13
|
+
|
|
14
|
+
# LoaderUp
|
|
15
|
+
|
|
16
|
+
Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python `>=3.12`
|
|
21
|
+
- `uv` (recommended package manager)
|
|
22
|
+
- `k6` (optional but recommended for real load runs)
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv pip install -e .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Run API directly
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python -m loader.main
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Server starts on `http://127.0.0.1:8000` by default.
|
|
37
|
+
|
|
38
|
+
## Run via CLI
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This command opens the dashboard automatically at `http://127.0.0.1:8000/`.
|
|
45
|
+
Use `--no-open-browser` if you want to disable auto-open.
|
|
46
|
+
|
|
47
|
+
You can also use fallback-friendly forms like:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Health check
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl http://127.0.0.1:8000/health
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Dashboard
|
|
60
|
+
|
|
61
|
+
Open:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
http://127.0.0.1:8000/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Dashboard includes:
|
|
68
|
+
|
|
69
|
+
- live status + progress stream
|
|
70
|
+
- metrics cards + quick chart bars
|
|
71
|
+
- saved run history (persisted in `artifacts/history/runs.jsonl`)
|
|
72
|
+
- React-based multi-tab control center (`Run Now`, `Live`, `Registered Targets`, `History`)
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- Registry targets are loaded when the module is imported.
|
|
77
|
+
- Use `/run/targets` to submit explicit targets.
|
|
78
|
+
- Use `/run/registry` to run all decorator-registered targets.
|
loaderup-0.1.0/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, Tuple
|
|
3
|
+
from loader.models import SummaryMetrics
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _metric_value(metrics: Dict[str, Any], metric_name: str, field: str):
|
|
7
|
+
metric = metrics.get(metric_name, {})
|
|
8
|
+
if not isinstance(metric, dict):
|
|
9
|
+
return None
|
|
10
|
+
return metric.get(field)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _checks_pass_rate(metrics: Dict[str, Any]) -> float | None:
|
|
14
|
+
checks = metrics.get("checks", {})
|
|
15
|
+
if not isinstance(checks, dict):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
# Some k6 outputs provide "value"
|
|
19
|
+
if "value" in checks:
|
|
20
|
+
return checks.get("value")
|
|
21
|
+
|
|
22
|
+
passes = checks.get("passes")
|
|
23
|
+
fails = checks.get("fails")
|
|
24
|
+
|
|
25
|
+
if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
|
|
26
|
+
total = passes + fails
|
|
27
|
+
if total > 0:
|
|
28
|
+
return passes / total
|
|
29
|
+
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _http_req_failed_rate(metrics: Dict[str, Any]) -> float | None:
|
|
34
|
+
failed = metrics.get("http_req_failed", {})
|
|
35
|
+
if not isinstance(failed, dict):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# Best source in k6 summary output
|
|
39
|
+
value = failed.get("value")
|
|
40
|
+
if isinstance(value, (int, float)):
|
|
41
|
+
return float(value)
|
|
42
|
+
|
|
43
|
+
# Some formats may expose "rate"
|
|
44
|
+
rate = failed.get("rate")
|
|
45
|
+
if isinstance(rate, (int, float)):
|
|
46
|
+
return float(rate)
|
|
47
|
+
|
|
48
|
+
# Fallback: in k6 this metric is a Rate metric, and "passes"
|
|
49
|
+
# usually means failed requests, while "fails" means non-failed requests.
|
|
50
|
+
passes = failed.get("passes")
|
|
51
|
+
fails = failed.get("fails")
|
|
52
|
+
|
|
53
|
+
if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
|
|
54
|
+
total = passes + fails
|
|
55
|
+
if total > 0:
|
|
56
|
+
return float(passes) / float(total)
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_k6_summary(summary_path: str) -> Tuple[SummaryMetrics, Dict[str, Any]]:
|
|
62
|
+
with open(summary_path, "r", encoding="utf-8") as f:
|
|
63
|
+
data = json.load(f)
|
|
64
|
+
|
|
65
|
+
metrics = data.get("metrics", {})
|
|
66
|
+
|
|
67
|
+
parsed = SummaryMetrics(
|
|
68
|
+
total_requests=_metric_value(metrics, "http_reqs", "count"),
|
|
69
|
+
avg_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "avg"),
|
|
70
|
+
med_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "med"),
|
|
71
|
+
p90_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(90)"),
|
|
72
|
+
p95_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(95)"),
|
|
73
|
+
max_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "max"),
|
|
74
|
+
http_req_failed_rate=_http_req_failed_rate(metrics),
|
|
75
|
+
checks_pass_rate=_checks_pass_rate(metrics),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return parsed, data
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
from loader.models import LoadTarget
|
|
5
|
+
from loader.settings import ARTIFACTS_DIR
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _js_string(value: str) -> str:
|
|
9
|
+
return json.dumps(value)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_k6_script(
|
|
13
|
+
base_url: str,
|
|
14
|
+
targets: List[LoadTarget],
|
|
15
|
+
vus: int,
|
|
16
|
+
duration_seconds: int,
|
|
17
|
+
) -> str:
|
|
18
|
+
target_entries = []
|
|
19
|
+
|
|
20
|
+
for target in targets:
|
|
21
|
+
entry = {
|
|
22
|
+
"name": target.name,
|
|
23
|
+
"method": target.method,
|
|
24
|
+
"path": target.path,
|
|
25
|
+
"headers": target.headers,
|
|
26
|
+
"payload": target.payload_example,
|
|
27
|
+
"expected_status": target.expected_status,
|
|
28
|
+
"weight": target.weight,
|
|
29
|
+
}
|
|
30
|
+
target_entries.append(entry)
|
|
31
|
+
|
|
32
|
+
targets_json = json.dumps(target_entries, indent=2)
|
|
33
|
+
|
|
34
|
+
script = f"""import http from 'k6/http';
|
|
35
|
+
import {{ check, sleep }} from 'k6';
|
|
36
|
+
|
|
37
|
+
export const options = {{
|
|
38
|
+
vus: {vus},
|
|
39
|
+
duration: '{duration_seconds}s',
|
|
40
|
+
}};
|
|
41
|
+
|
|
42
|
+
const BASE_URL = {_js_string(base_url.rstrip("/"))};
|
|
43
|
+
const TARGETS = {targets_json};
|
|
44
|
+
|
|
45
|
+
function weightedPick(targets) {{
|
|
46
|
+
const expanded = [];
|
|
47
|
+
for (const t of targets) {{
|
|
48
|
+
const weight = Math.max(1, Math.round(t.weight || 1));
|
|
49
|
+
for (let i = 0; i < weight; i++) {{
|
|
50
|
+
expanded.push(t);
|
|
51
|
+
}}
|
|
52
|
+
}}
|
|
53
|
+
const idx = Math.floor(Math.random() * expanded.length);
|
|
54
|
+
return expanded[idx];
|
|
55
|
+
}}
|
|
56
|
+
|
|
57
|
+
export default function () {{
|
|
58
|
+
const target = weightedPick(TARGETS);
|
|
59
|
+
const url = `${{BASE_URL}}${{target.path}}`;
|
|
60
|
+
const method = String(target.method || 'GET').toUpperCase();
|
|
61
|
+
const payloadMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
62
|
+
|
|
63
|
+
let res;
|
|
64
|
+
|
|
65
|
+
if (method === 'GET') {{
|
|
66
|
+
res = http.get(url, {{
|
|
67
|
+
headers: target.headers || {{}},
|
|
68
|
+
tags: {{
|
|
69
|
+
target_name: target.name,
|
|
70
|
+
method,
|
|
71
|
+
path: target.path,
|
|
72
|
+
}},
|
|
73
|
+
}});
|
|
74
|
+
}} else if (payloadMethods.has(method)) {{
|
|
75
|
+
const payload = target.payload ? JSON.stringify(target.payload) : null;
|
|
76
|
+
const headers = {{ ...(target.headers || {{}}) }};
|
|
77
|
+
if (payload !== null && !headers['Content-Type'] && !headers['content-type']) {{
|
|
78
|
+
headers['Content-Type'] = 'application/json';
|
|
79
|
+
}}
|
|
80
|
+
|
|
81
|
+
res = http.request(method, url, payload, {{
|
|
82
|
+
headers,
|
|
83
|
+
tags: {{
|
|
84
|
+
target_name: target.name,
|
|
85
|
+
method,
|
|
86
|
+
path: target.path,
|
|
87
|
+
}},
|
|
88
|
+
}});
|
|
89
|
+
}} else {{
|
|
90
|
+
throw new Error(`Unsupported method: ${{method}}`);
|
|
91
|
+
}}
|
|
92
|
+
|
|
93
|
+
check(res, {{
|
|
94
|
+
[`status is expected`]: (r) => r.status === target.expected_status,
|
|
95
|
+
[`status is < 500`]: (r) => r.status < 500,
|
|
96
|
+
}});
|
|
97
|
+
|
|
98
|
+
sleep(1);
|
|
99
|
+
}}
|
|
100
|
+
"""
|
|
101
|
+
return script
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_k6_script(job_id: str, script_text: str) -> str:
|
|
105
|
+
out_dir = ARTIFACTS_DIR / job_id
|
|
106
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
script_path = out_dir / "script.js"
|
|
109
|
+
script_path.write_text(script_text, encoding="utf-8")
|
|
110
|
+
return str(script_path)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from loader.settings import ARTIFACTS_DIR
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def run_k6(script_path: str, job_id: str) -> dict:
|
|
6
|
+
out_dir = ARTIFACTS_DIR / job_id
|
|
7
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
|
|
9
|
+
summary_path = out_dir / "summary.json"
|
|
10
|
+
|
|
11
|
+
process = await asyncio.create_subprocess_exec(
|
|
12
|
+
"k6",
|
|
13
|
+
"run",
|
|
14
|
+
"--summary-export",
|
|
15
|
+
str(summary_path),
|
|
16
|
+
script_path,
|
|
17
|
+
stdout=asyncio.subprocess.PIPE,
|
|
18
|
+
stderr=asyncio.subprocess.PIPE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
stdout, stderr = await process.communicate()
|
|
22
|
+
|
|
23
|
+
stdout_text = stdout.decode(errors="replace")
|
|
24
|
+
stderr_text = stderr.decode(errors="replace")
|
|
25
|
+
|
|
26
|
+
if process.returncode != 0:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"k6 execution failed\n"
|
|
29
|
+
f"stdout:\n{stdout_text}\n"
|
|
30
|
+
f"stderr:\n{stderr_text}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not summary_path.exists():
|
|
34
|
+
raise RuntimeError("k6 completed but summary.json was not created")
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"summary_path": str(summary_path),
|
|
38
|
+
"stdout": stdout_text,
|
|
39
|
+
"stderr": stderr_text,
|
|
40
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# demo_registry_targets.py
|
|
2
|
+
|
|
3
|
+
from loaderup import load_target
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@load_target(
|
|
7
|
+
name="home",
|
|
8
|
+
method="GET",
|
|
9
|
+
path="/",
|
|
10
|
+
expected_status=200,
|
|
11
|
+
tags=["public", "homepage"],
|
|
12
|
+
)
|
|
13
|
+
def home():
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@load_target(
|
|
17
|
+
name="home",
|
|
18
|
+
method="POST",
|
|
19
|
+
path="/gg",
|
|
20
|
+
expected_status=200,
|
|
21
|
+
tags=["public", "homepage"],
|
|
22
|
+
)
|
|
23
|
+
def gg():
|
|
24
|
+
pass
|
|
25
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from loader.models import Job
|
|
6
|
+
|
|
7
|
+
HISTORY_FILE = (
|
|
8
|
+
Path(__file__).resolve().parents[1] / "artifacts" / "history" / "runs.jsonl"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def persist_job(job: Job) -> None:
|
|
13
|
+
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
with HISTORY_FILE.open("a", encoding="utf-8") as handle:
|
|
15
|
+
handle.write(json.dumps(job.model_dump(), ensure_ascii=True) + "\n")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_runs(limit: int = 20) -> List[Dict[str, Any]]:
|
|
19
|
+
if not HISTORY_FILE.exists():
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
rows: List[Dict[str, Any]] = []
|
|
23
|
+
with HISTORY_FILE.open("r", encoding="utf-8") as handle:
|
|
24
|
+
for line in handle:
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line:
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
rows.append(json.loads(line))
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
if limit < 1:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
return list(reversed(rows[-limit:]))
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import uuid
|
|
7
|
+
from fastapi import FastAPI, HTTPException
|
|
8
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
9
|
+
from fastapi.staticfiles import StaticFiles
|
|
10
|
+
|
|
11
|
+
from loader.models import Job, RunTargetsRequest, RunConfig
|
|
12
|
+
from loader.history import list_runs
|
|
13
|
+
from loaderup.importer import import_target_modules
|
|
14
|
+
from loaderup.models import RunRegistryRequest
|
|
15
|
+
from loader.store import create_job, get_job, get_queue, list_jobs
|
|
16
|
+
from loader.pipeline import run_targets_pipeline
|
|
17
|
+
from loader import demo_registry_target
|
|
18
|
+
|
|
19
|
+
app = FastAPI(title="LoadAgent")
|
|
20
|
+
WEB_DIR = Path(__file__).resolve().parent / "web"
|
|
21
|
+
|
|
22
|
+
app.mount("/assets", StaticFiles(directory=str(WEB_DIR / "assets")), name="assets")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.get("/", include_in_schema=False)
|
|
26
|
+
async def dashboard():
|
|
27
|
+
return FileResponse(str(WEB_DIR / "index.html"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.get("/health")
|
|
31
|
+
async def health():
|
|
32
|
+
return {"status": "ok", "k6_installed": shutil.which("k6") is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.on_event("startup")
|
|
36
|
+
async def load_cli_target_modules():
|
|
37
|
+
modules_csv = os.getenv("LOADERUP_TARGET_MODULES", "").strip()
|
|
38
|
+
if not modules_csv:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
modules = [name.strip() for name in modules_csv.split(",") if name.strip()]
|
|
42
|
+
if modules:
|
|
43
|
+
import_target_modules(modules)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.post("/run/targets")
|
|
47
|
+
async def run_targets(payload: RunTargetsRequest):
|
|
48
|
+
job_id = str(uuid.uuid4())
|
|
49
|
+
|
|
50
|
+
job = Job(job_id=job_id)
|
|
51
|
+
create_job(job)
|
|
52
|
+
|
|
53
|
+
config = RunConfig(
|
|
54
|
+
base_url=payload.base_url.rstrip("/"),
|
|
55
|
+
vus=payload.vus,
|
|
56
|
+
duration_seconds=payload.duration_seconds,
|
|
57
|
+
targets=payload.targets,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
asyncio.create_task(run_targets_pipeline(job_id, config))
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"job_id": job_id,
|
|
64
|
+
"status": job.status,
|
|
65
|
+
"stream_url": f"/run/{job_id}/stream",
|
|
66
|
+
"result_url": f"/run/{job_id}/result",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.get("/run/{job_id}/result")
|
|
71
|
+
async def get_result(job_id: str):
|
|
72
|
+
job = get_job(job_id)
|
|
73
|
+
if not job:
|
|
74
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
75
|
+
return job
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.get("/run/{job_id}/stream")
|
|
79
|
+
async def stream_run(job_id: str):
|
|
80
|
+
q = get_queue(job_id)
|
|
81
|
+
if not q:
|
|
82
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
83
|
+
|
|
84
|
+
async def event_generator():
|
|
85
|
+
while True:
|
|
86
|
+
item = await q.get()
|
|
87
|
+
if item is None:
|
|
88
|
+
break
|
|
89
|
+
yield f"data: {json.dumps(item)}\n\n"
|
|
90
|
+
|
|
91
|
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.post("/run/registry")
|
|
95
|
+
async def run_registry(payload: RunRegistryRequest):
|
|
96
|
+
from loaderup import collect_load_targets
|
|
97
|
+
|
|
98
|
+
registry_targets = collect_load_targets()
|
|
99
|
+
if not registry_targets:
|
|
100
|
+
raise HTTPException(status_code=400, detail="No registered targets found")
|
|
101
|
+
|
|
102
|
+
job_id = str(uuid.uuid4())
|
|
103
|
+
|
|
104
|
+
job = Job(job_id=job_id)
|
|
105
|
+
create_job(job)
|
|
106
|
+
|
|
107
|
+
config = RunConfig(
|
|
108
|
+
base_url=payload.base_url.rstrip("/"),
|
|
109
|
+
vus=payload.vus,
|
|
110
|
+
duration_seconds=payload.duration_seconds,
|
|
111
|
+
targets=registry_targets,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
asyncio.create_task(run_targets_pipeline(job_id, config))
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"job_id": job_id,
|
|
118
|
+
"status": job.status,
|
|
119
|
+
"registered_targets": len(registry_targets),
|
|
120
|
+
"stream_url": f"/run/{job_id}/stream",
|
|
121
|
+
"result_url": f"/run/{job_id}/result",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.get("/registry/targets")
|
|
126
|
+
async def get_registry_targets():
|
|
127
|
+
from loaderup import collect_load_targets
|
|
128
|
+
|
|
129
|
+
targets = collect_load_targets()
|
|
130
|
+
return {
|
|
131
|
+
"count": len(targets),
|
|
132
|
+
"targets": [target.model_dump() for target in targets],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/runs/active")
|
|
137
|
+
async def get_active_runs(limit: int = 20):
|
|
138
|
+
jobs = list_jobs(limit=limit)
|
|
139
|
+
return {"runs": [job.model_dump() for job in jobs]}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.get("/runs/history")
|
|
143
|
+
async def get_run_history(limit: int = 20):
|
|
144
|
+
return {"runs": list_runs(limit=limit)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
import uvicorn
|
|
149
|
+
|
|
150
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional, List, Dict, Any, Literal
|
|
3
|
+
from pydantic import BaseModel, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JobStatus(str, Enum):
|
|
7
|
+
pending = "pending"
|
|
8
|
+
generating = "generating"
|
|
9
|
+
running = "running"
|
|
10
|
+
analyzing = "analyzing"
|
|
11
|
+
done = "done"
|
|
12
|
+
failed = "failed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoadTarget(BaseModel):
|
|
16
|
+
name: str
|
|
17
|
+
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
18
|
+
path: str
|
|
19
|
+
headers: Dict[str, str] = Field(default_factory=dict)
|
|
20
|
+
payload_example: Optional[Dict[str, Any]] = None
|
|
21
|
+
weight: float = 1.0
|
|
22
|
+
tags: List[str] = Field(default_factory=list)
|
|
23
|
+
expected_status: int = 200
|
|
24
|
+
|
|
25
|
+
@model_validator(mode="after")
|
|
26
|
+
def validate_target(self):
|
|
27
|
+
if not self.path.startswith("/"):
|
|
28
|
+
raise ValueError("path must start with '/'")
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RunTargetsRequest(BaseModel):
|
|
33
|
+
base_url: str
|
|
34
|
+
targets: List[LoadTarget]
|
|
35
|
+
vus: int = 10
|
|
36
|
+
duration_seconds: int = 30
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def validate_request(self):
|
|
40
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
41
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
42
|
+
if not self.targets:
|
|
43
|
+
raise ValueError("targets cannot be empty")
|
|
44
|
+
if self.vus < 1:
|
|
45
|
+
raise ValueError("vus must be >= 1")
|
|
46
|
+
if self.duration_seconds < 1:
|
|
47
|
+
raise ValueError("duration_seconds must be >= 1")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RunConfig(BaseModel):
|
|
52
|
+
base_url: str
|
|
53
|
+
vus: int
|
|
54
|
+
duration_seconds: int
|
|
55
|
+
targets: List[LoadTarget]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SummaryMetrics(BaseModel):
|
|
59
|
+
total_requests: Optional[int] = None
|
|
60
|
+
avg_http_req_duration_ms: Optional[float] = None
|
|
61
|
+
med_http_req_duration_ms: Optional[float] = None
|
|
62
|
+
p90_http_req_duration_ms: Optional[float] = None
|
|
63
|
+
p95_http_req_duration_ms: Optional[float] = None
|
|
64
|
+
max_http_req_duration_ms: Optional[float] = None
|
|
65
|
+
http_req_failed_rate: Optional[float] = None
|
|
66
|
+
checks_pass_rate: Optional[float] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TargetMetric(BaseModel):
|
|
70
|
+
name: str
|
|
71
|
+
method: str
|
|
72
|
+
path: str
|
|
73
|
+
expected_status: int
|
|
74
|
+
note: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class JobResult(BaseModel):
|
|
78
|
+
config: Optional[RunConfig] = None
|
|
79
|
+
k6_script_path: Optional[str] = None
|
|
80
|
+
k6_summary_path: Optional[str] = None
|
|
81
|
+
k6_stdout: Optional[str] = None
|
|
82
|
+
k6_stderr: Optional[str] = None
|
|
83
|
+
metrics: Optional[SummaryMetrics] = None
|
|
84
|
+
target_metrics: List[TargetMetric] = Field(default_factory=list)
|
|
85
|
+
raw_summary: Optional[Dict[str, Any]] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Job(BaseModel):
|
|
89
|
+
job_id: str
|
|
90
|
+
status: JobStatus = JobStatus.pending
|
|
91
|
+
progress: List[str] = Field(default_factory=list)
|
|
92
|
+
error: Optional[str] = None
|
|
93
|
+
result: JobResult = Field(default_factory=JobResult)
|