overload-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overload/__init__.py +3 -0
- overload/__main__.py +5 -0
- overload/cli.py +393 -0
- overload/collection/__init__.py +1 -0
- overload/collection/environment.py +23 -0
- overload/collection/models.py +88 -0
- overload/collection/parser.py +220 -0
- overload/collection/variables.py +84 -0
- overload/config_file.py +73 -0
- overload/engine/__init__.py +1 -0
- overload/engine/assertions.py +151 -0
- overload/engine/auth.py +87 -0
- overload/engine/events.py +50 -0
- overload/engine/http_client.py +274 -0
- overload/engine/load_patterns.py +730 -0
- overload/engine/models.py +254 -0
- overload/engine/rate_limiter.py +124 -0
- overload/engine/runner.py +86 -0
- overload/report/__init__.py +1 -0
- overload/report/exporters.py +77 -0
- overload/report/generator.py +71 -0
- overload/report/templates/report.html +369 -0
- overload/utils/__init__.py +1 -0
- overload/utils/naming.py +26 -0
- overload/web/__init__.py +1 -0
- overload/web/app.py +38 -0
- overload/web/routes/__init__.py +1 -0
- overload/web/routes/api.py +461 -0
- overload/web/routes/ws.py +77 -0
- overload/web/static/css/app.css +242 -0
- overload/web/static/js/app.js +241 -0
- overload/web/static/js/charts.js +385 -0
- overload/web/static/js/collection.js +344 -0
- overload/web/static/js/runner.js +625 -0
- overload/web/templates/index.html +23 -0
- overload_cli-0.1.0.dist-info/METADATA +267 -0
- overload_cli-0.1.0.dist-info/RECORD +40 -0
- overload_cli-0.1.0.dist-info/WHEEL +4 -0
- overload_cli-0.1.0.dist-info/entry_points.txt +2 -0
- overload_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, File, UploadFile
|
|
11
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
12
|
+
|
|
13
|
+
from overload.collection.environment import parse_environment
|
|
14
|
+
from overload.collection.models import ParsedCollection
|
|
15
|
+
from overload.collection.parser import parse_collection
|
|
16
|
+
from overload.collection.variables import VariableContext
|
|
17
|
+
from overload.config_file import extract_config, extract_test_type, extract_thresholds, load_config, save_config
|
|
18
|
+
from overload.engine.assertions import evaluate, print_verdict, write_junit_xml
|
|
19
|
+
from overload.engine.http_client import HttpClient
|
|
20
|
+
from overload.engine.load_patterns import get_pattern
|
|
21
|
+
from overload.engine.models import PatternConfig, RunProgress, Stats, TestType, Threshold
|
|
22
|
+
from overload.engine.rate_limiter import run_rate_limit_test
|
|
23
|
+
from overload.engine.runner import run_sequential
|
|
24
|
+
from overload.report.exporters import export_csv, export_json
|
|
25
|
+
from overload.report.generator import generate_report
|
|
26
|
+
from overload.utils.naming import generate_run_id
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
router = APIRouter()
|
|
31
|
+
|
|
32
|
+
_state: dict[str, Any] = {
|
|
33
|
+
"collection": None,
|
|
34
|
+
"environment": None,
|
|
35
|
+
"variables": None,
|
|
36
|
+
"runs": {},
|
|
37
|
+
"current_task": None,
|
|
38
|
+
"cancel_event": None,
|
|
39
|
+
"working_dir": os.getcwd(),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _detect_postman_files(directory: str) -> dict[str, list[dict]]:
|
|
44
|
+
collections: list[dict] = []
|
|
45
|
+
environments: list[dict] = []
|
|
46
|
+
|
|
47
|
+
dir_path = Path(directory)
|
|
48
|
+
if not dir_path.is_dir():
|
|
49
|
+
return {"collections": collections, "environments": environments}
|
|
50
|
+
|
|
51
|
+
for f in sorted(dir_path.glob("*.json")):
|
|
52
|
+
try:
|
|
53
|
+
with open(f, encoding="utf-8") as fh:
|
|
54
|
+
data = json.load(fh)
|
|
55
|
+
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
info = data.get("info", {})
|
|
58
|
+
schema = info.get("schema", "")
|
|
59
|
+
if "postman" in schema.lower() or (info.get("name") and "item" in data):
|
|
60
|
+
collections.append({
|
|
61
|
+
"name": info.get("name", f.stem),
|
|
62
|
+
"filename": f.name,
|
|
63
|
+
"path": str(f),
|
|
64
|
+
"request_count": _count_requests(data.get("item", [])),
|
|
65
|
+
})
|
|
66
|
+
elif "values" in data and isinstance(data.get("values"), list):
|
|
67
|
+
environments.append({
|
|
68
|
+
"name": data.get("name", f.stem),
|
|
69
|
+
"filename": f.name,
|
|
70
|
+
"path": str(f),
|
|
71
|
+
"variable_count": len(data.get("values", [])),
|
|
72
|
+
})
|
|
73
|
+
except (json.JSONDecodeError, OSError):
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
return {"collections": collections, "environments": environments}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _count_requests(items: list) -> int:
|
|
80
|
+
count = 0
|
|
81
|
+
for item in items:
|
|
82
|
+
if "request" in item:
|
|
83
|
+
count += 1
|
|
84
|
+
if "item" in item:
|
|
85
|
+
count += _count_requests(item["item"])
|
|
86
|
+
return count
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get("/detect")
|
|
90
|
+
async def detect_files() -> JSONResponse:
|
|
91
|
+
working_dir = _state.get("working_dir", os.getcwd())
|
|
92
|
+
detected = _detect_postman_files(working_dir)
|
|
93
|
+
return JSONResponse({
|
|
94
|
+
"status": "ok",
|
|
95
|
+
"working_dir": working_dir,
|
|
96
|
+
**detected,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.post("/collection/load-local")
|
|
101
|
+
async def load_local_collection(body: dict) -> JSONResponse:
|
|
102
|
+
filepath = body.get("path", "")
|
|
103
|
+
if not filepath or not os.path.isfile(filepath):
|
|
104
|
+
return JSONResponse({"status": "error", "message": "File not found"}, status_code=400)
|
|
105
|
+
try:
|
|
106
|
+
collection = parse_collection(filepath)
|
|
107
|
+
_state["collection"] = collection
|
|
108
|
+
|
|
109
|
+
env_vars = _state["environment"] or {}
|
|
110
|
+
_state["variables"] = VariableContext(
|
|
111
|
+
collection_vars=collection.variables,
|
|
112
|
+
environment_vars=env_vars,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
logger.info("Collection loaded from disk: %s (%d requests)", collection.name, len(collection.requests))
|
|
116
|
+
return JSONResponse({"status": "ok", "collection": collection.to_dict()})
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
logger.exception("Error parsing collection")
|
|
119
|
+
return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.post("/environment/load-local")
|
|
123
|
+
async def load_local_environment(body: dict) -> JSONResponse:
|
|
124
|
+
filepath = body.get("path", "")
|
|
125
|
+
if not filepath or not os.path.isfile(filepath):
|
|
126
|
+
return JSONResponse({"status": "error", "message": "File not found"}, status_code=400)
|
|
127
|
+
try:
|
|
128
|
+
env_vars = parse_environment(filepath)
|
|
129
|
+
_state["environment"] = env_vars
|
|
130
|
+
|
|
131
|
+
if _state["collection"]:
|
|
132
|
+
_state["variables"] = VariableContext(
|
|
133
|
+
collection_vars=_state["collection"].variables,
|
|
134
|
+
environment_vars=env_vars,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
logger.info("Environment loaded from disk: %d variables", len(env_vars))
|
|
138
|
+
return JSONResponse({"status": "ok", "variables": env_vars})
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
logger.exception("Error parsing environment")
|
|
141
|
+
return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.post("/collection/upload")
|
|
145
|
+
async def upload_collection(file: UploadFile = File(...)) -> JSONResponse:
|
|
146
|
+
try:
|
|
147
|
+
content = await file.read()
|
|
148
|
+
data = json.loads(content)
|
|
149
|
+
collection = parse_collection(data)
|
|
150
|
+
_state["collection"] = collection
|
|
151
|
+
|
|
152
|
+
env_vars = _state["environment"] or {}
|
|
153
|
+
_state["variables"] = VariableContext(
|
|
154
|
+
collection_vars=collection.variables,
|
|
155
|
+
environment_vars=env_vars,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
logger.info("Collection loaded: %s (%d requests)", collection.name, len(collection.requests))
|
|
159
|
+
return JSONResponse({"status": "ok", "collection": collection.to_dict()})
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
return JSONResponse({"status": "error", "message": "Invalid JSON file"}, status_code=400)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
logger.exception("Error parsing collection")
|
|
164
|
+
return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.post("/environment/upload")
|
|
168
|
+
async def upload_environment(file: UploadFile = File(...)) -> JSONResponse:
|
|
169
|
+
try:
|
|
170
|
+
content = await file.read()
|
|
171
|
+
data = json.loads(content)
|
|
172
|
+
env_vars = parse_environment(data)
|
|
173
|
+
_state["environment"] = env_vars
|
|
174
|
+
|
|
175
|
+
if _state["collection"]:
|
|
176
|
+
_state["variables"] = VariableContext(
|
|
177
|
+
collection_vars=_state["collection"].variables,
|
|
178
|
+
environment_vars=env_vars,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
logger.info("Environment loaded: %d variables", len(env_vars))
|
|
182
|
+
return JSONResponse({"status": "ok", "variables": env_vars})
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.exception("Error parsing environment")
|
|
185
|
+
return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.post("/variables/update")
|
|
189
|
+
async def update_variables(body: dict) -> JSONResponse:
|
|
190
|
+
overrides = body.get("variables", {})
|
|
191
|
+
if _state["variables"]:
|
|
192
|
+
for key, value in overrides.items():
|
|
193
|
+
_state["variables"].set_variable(key, value)
|
|
194
|
+
else:
|
|
195
|
+
collection = _state.get("collection")
|
|
196
|
+
_state["variables"] = VariableContext(
|
|
197
|
+
collection_vars=collection.variables if collection else [],
|
|
198
|
+
runtime_vars=overrides,
|
|
199
|
+
)
|
|
200
|
+
return JSONResponse({"status": "ok"})
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.post("/test/start")
|
|
204
|
+
async def start_test(body: dict) -> JSONResponse:
|
|
205
|
+
collection: ParsedCollection | None = _state.get("collection")
|
|
206
|
+
if not collection or not collection.requests:
|
|
207
|
+
return JSONResponse(
|
|
208
|
+
{"status": "error", "message": "No collection loaded"},
|
|
209
|
+
status_code=400,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if _state.get("current_task") and not _state["current_task"].done():
|
|
213
|
+
return JSONResponse(
|
|
214
|
+
{"status": "error", "message": "A test is already running"},
|
|
215
|
+
status_code=409,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
test_type = body.get("test_type", "burst")
|
|
219
|
+
config_dict = body.get("config", {})
|
|
220
|
+
selected_indices = body.get("selected_requests")
|
|
221
|
+
|
|
222
|
+
thresholds: list[Threshold] = []
|
|
223
|
+
for entry in body.get("thresholds", []):
|
|
224
|
+
if isinstance(entry, dict) and "metric" in entry:
|
|
225
|
+
thresholds.append(Threshold(
|
|
226
|
+
metric=entry["metric"],
|
|
227
|
+
operator=entry.get("operator", "<"),
|
|
228
|
+
value=float(entry.get("value", 0)),
|
|
229
|
+
))
|
|
230
|
+
|
|
231
|
+
run_id = generate_run_id()
|
|
232
|
+
cancel_event = asyncio.Event()
|
|
233
|
+
_state["cancel_event"] = cancel_event
|
|
234
|
+
|
|
235
|
+
config = PatternConfig(**{k: v for k, v in config_dict.items() if hasattr(PatternConfig, k)})
|
|
236
|
+
|
|
237
|
+
requests = collection.requests
|
|
238
|
+
if selected_indices is not None:
|
|
239
|
+
requests = [collection.requests[i] for i in selected_indices if i < len(collection.requests)]
|
|
240
|
+
|
|
241
|
+
variables = _state.get("variables") or VariableContext(collection_vars=collection.variables)
|
|
242
|
+
output_dir = os.path.join(_state.get("working_dir", os.getcwd()), "reports")
|
|
243
|
+
|
|
244
|
+
from overload.web.routes.ws import broadcast_progress
|
|
245
|
+
|
|
246
|
+
async def on_progress(progress: RunProgress) -> None:
|
|
247
|
+
await broadcast_progress(run_id, progress)
|
|
248
|
+
|
|
249
|
+
async def _run_test() -> None:
|
|
250
|
+
stats = Stats()
|
|
251
|
+
ramp_rows: list[dict] = []
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
async with HttpClient(
|
|
255
|
+
timeout=config.timeout_seconds,
|
|
256
|
+
verify_ssl=config.verify_ssl,
|
|
257
|
+
follow_redirects=config.follow_redirects,
|
|
258
|
+
max_connections=config.concurrency * 2,
|
|
259
|
+
save_responses=config.save_responses,
|
|
260
|
+
) as client:
|
|
261
|
+
await client.prepare_collection_auth(collection.auth, variables)
|
|
262
|
+
if test_type == TestType.SEQUENTIAL:
|
|
263
|
+
results = await run_sequential(
|
|
264
|
+
client, requests, variables, config,
|
|
265
|
+
run_id, cancel_event, on_progress,
|
|
266
|
+
)
|
|
267
|
+
stats.add_all(results)
|
|
268
|
+
elif test_type == TestType.RATE_LIMIT:
|
|
269
|
+
results, ramp_rows = await run_rate_limit_test(
|
|
270
|
+
client, requests, variables, config,
|
|
271
|
+
run_id, cancel_event, on_progress,
|
|
272
|
+
)
|
|
273
|
+
stats.add_all(results)
|
|
274
|
+
else:
|
|
275
|
+
pattern = get_pattern(test_type)
|
|
276
|
+
results = await pattern.execute(
|
|
277
|
+
client, requests, variables, config,
|
|
278
|
+
run_id, cancel_event, on_progress,
|
|
279
|
+
)
|
|
280
|
+
stats.add_all(results)
|
|
281
|
+
|
|
282
|
+
computed = stats.compute()
|
|
283
|
+
|
|
284
|
+
verdict_data = None
|
|
285
|
+
if thresholds and computed:
|
|
286
|
+
verdict = evaluate(computed, thresholds)
|
|
287
|
+
verdict_data = {
|
|
288
|
+
"passed": verdict.passed,
|
|
289
|
+
"results": [
|
|
290
|
+
{
|
|
291
|
+
"metric": r.metric,
|
|
292
|
+
"operator": r.operator,
|
|
293
|
+
"expected": r.expected,
|
|
294
|
+
"actual": round(r.actual, 2),
|
|
295
|
+
"passed": r.passed,
|
|
296
|
+
}
|
|
297
|
+
for r in verdict.results
|
|
298
|
+
],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
report_config = {
|
|
302
|
+
"test_type": test_type,
|
|
303
|
+
"concurrency": config.concurrency,
|
|
304
|
+
"total_requests_configured": config.total_requests,
|
|
305
|
+
}
|
|
306
|
+
report_path = generate_report(
|
|
307
|
+
stats, test_type, report_config,
|
|
308
|
+
run_id=run_id, ramp_rows=ramp_rows,
|
|
309
|
+
output_dir=output_dir,
|
|
310
|
+
verdict=verdict_data,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
_state["runs"][run_id] = {
|
|
314
|
+
"run_id": run_id,
|
|
315
|
+
"test_type": test_type,
|
|
316
|
+
"stats": computed,
|
|
317
|
+
"ramp_rows": ramp_rows,
|
|
318
|
+
"report_path": report_path,
|
|
319
|
+
"status": "complete",
|
|
320
|
+
"verdict": verdict_data,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await broadcast_progress(run_id, RunProgress(
|
|
324
|
+
run_id=run_id,
|
|
325
|
+
total_requests=stats.total,
|
|
326
|
+
completed_requests=stats.total,
|
|
327
|
+
current_rps=0,
|
|
328
|
+
phase="complete",
|
|
329
|
+
elapsed_seconds=computed["duration_seconds"] if computed else 0,
|
|
330
|
+
))
|
|
331
|
+
logger.info("Test complete: %s (%d requests)", run_id, stats.total)
|
|
332
|
+
|
|
333
|
+
except asyncio.CancelledError:
|
|
334
|
+
logger.info("Test cancelled: %s", run_id)
|
|
335
|
+
computed = stats.compute() if stats.total > 0 else None
|
|
336
|
+
_state["runs"][run_id] = {
|
|
337
|
+
"run_id": run_id,
|
|
338
|
+
"test_type": test_type,
|
|
339
|
+
"stats": computed,
|
|
340
|
+
"status": "cancelled",
|
|
341
|
+
}
|
|
342
|
+
await broadcast_progress(run_id, RunProgress(
|
|
343
|
+
run_id=run_id,
|
|
344
|
+
total_requests=stats.total,
|
|
345
|
+
completed_requests=stats.total,
|
|
346
|
+
current_rps=0,
|
|
347
|
+
phase="complete (stopped)",
|
|
348
|
+
elapsed_seconds=computed["duration_seconds"] if computed else 0,
|
|
349
|
+
))
|
|
350
|
+
except Exception:
|
|
351
|
+
logger.exception("Test failed: %s", run_id)
|
|
352
|
+
_state["runs"][run_id] = {"run_id": run_id, "status": "error"}
|
|
353
|
+
|
|
354
|
+
task = asyncio.create_task(_run_test())
|
|
355
|
+
_state["current_task"] = task
|
|
356
|
+
|
|
357
|
+
return JSONResponse({"status": "ok", "run_id": run_id})
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@router.post("/test/stop")
|
|
361
|
+
async def stop_test() -> JSONResponse:
|
|
362
|
+
cancel_event = _state.get("cancel_event")
|
|
363
|
+
if cancel_event:
|
|
364
|
+
cancel_event.set()
|
|
365
|
+
logger.info("Stop signal sent")
|
|
366
|
+
task = _state.get("current_task")
|
|
367
|
+
if task and not task.done():
|
|
368
|
+
task.cancel()
|
|
369
|
+
logger.info("Task cancelled")
|
|
370
|
+
return JSONResponse({"status": "ok"})
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@router.get("/runs")
|
|
374
|
+
async def list_runs() -> JSONResponse:
|
|
375
|
+
runs = []
|
|
376
|
+
for run_id, run_data in _state["runs"].items():
|
|
377
|
+
summary = {
|
|
378
|
+
"run_id": run_id,
|
|
379
|
+
"test_type": run_data.get("test_type", ""),
|
|
380
|
+
"status": run_data.get("status", ""),
|
|
381
|
+
}
|
|
382
|
+
stats = run_data.get("stats")
|
|
383
|
+
if stats:
|
|
384
|
+
summary["total"] = stats.get("total", 0)
|
|
385
|
+
summary["ok"] = stats.get("ok", 0)
|
|
386
|
+
summary["errors"] = stats.get("errors", 0)
|
|
387
|
+
summary["avg_rps"] = stats.get("avg_rps", 0)
|
|
388
|
+
summary["duration"] = stats.get("duration_seconds", 0)
|
|
389
|
+
verdict = run_data.get("verdict")
|
|
390
|
+
if verdict is not None:
|
|
391
|
+
summary["verdict"] = verdict["passed"]
|
|
392
|
+
runs.append(summary)
|
|
393
|
+
return JSONResponse({"runs": runs})
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@router.get("/runs/{run_id}/data")
|
|
397
|
+
async def get_run_data(run_id: str) -> JSONResponse:
|
|
398
|
+
run_data = _state["runs"].get(run_id)
|
|
399
|
+
if not run_data:
|
|
400
|
+
return JSONResponse({"status": "error", "message": "Run not found"}, status_code=404)
|
|
401
|
+
return JSONResponse(run_data)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@router.get("/runs/{run_id}/report")
|
|
405
|
+
async def get_run_report(run_id: str) -> FileResponse:
|
|
406
|
+
run_data = _state["runs"].get(run_id)
|
|
407
|
+
if not run_data or not run_data.get("report_path"):
|
|
408
|
+
return JSONResponse({"status": "error", "message": "Report not found"}, status_code=404)
|
|
409
|
+
return FileResponse(run_data["report_path"], media_type="text/html")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@router.get("/runs/{run_id}/export/csv")
|
|
413
|
+
async def export_run_csv(run_id: str) -> JSONResponse:
|
|
414
|
+
run_data = _state["runs"].get(run_id)
|
|
415
|
+
if not run_data:
|
|
416
|
+
return JSONResponse({"status": "error", "message": "Run not found"}, status_code=404)
|
|
417
|
+
return JSONResponse({"status": "error", "message": "CSV export requires stored results"}, status_code=501)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@router.get("/runs/{run_id}/export/json")
|
|
421
|
+
async def export_run_json(run_id: str) -> JSONResponse:
|
|
422
|
+
run_data = _state["runs"].get(run_id)
|
|
423
|
+
if not run_data:
|
|
424
|
+
return JSONResponse({"status": "error", "message": "Run not found"}, status_code=404)
|
|
425
|
+
return JSONResponse(run_data)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@router.get("/test/status")
|
|
429
|
+
async def test_status() -> JSONResponse:
|
|
430
|
+
task = _state.get("current_task")
|
|
431
|
+
if task and not task.done():
|
|
432
|
+
return JSONResponse({"status": "running"})
|
|
433
|
+
return JSONResponse({"status": "idle"})
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@router.post("/config/save")
|
|
437
|
+
async def save_config_endpoint(body: dict) -> JSONResponse:
|
|
438
|
+
working_dir = _state.get("working_dir", os.getcwd())
|
|
439
|
+
test_type = body.get("test_type", "burst")
|
|
440
|
+
config_dict = body.get("config", {})
|
|
441
|
+
threshold_list: list[Threshold] = []
|
|
442
|
+
for entry in body.get("thresholds", []):
|
|
443
|
+
if isinstance(entry, dict) and "metric" in entry:
|
|
444
|
+
threshold_list.append(Threshold(
|
|
445
|
+
metric=entry["metric"],
|
|
446
|
+
operator=entry.get("operator", "<"),
|
|
447
|
+
value=float(entry.get("value", 0)),
|
|
448
|
+
))
|
|
449
|
+
path = os.path.join(working_dir, "overload.config.yaml")
|
|
450
|
+
save_config(path, test_type, config_dict, threshold_list if threshold_list else None)
|
|
451
|
+
return JSONResponse({"status": "ok", "path": path})
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@router.get("/config/load")
|
|
455
|
+
async def load_config_endpoint() -> JSONResponse:
|
|
456
|
+
working_dir = _state.get("working_dir", os.getcwd())
|
|
457
|
+
path = os.path.join(working_dir, "overload.config.yaml")
|
|
458
|
+
if not os.path.isfile(path):
|
|
459
|
+
return JSONResponse({"status": "error", "message": "No overload.config.yaml found"}, status_code=404)
|
|
460
|
+
raw = load_config(path)
|
|
461
|
+
return JSONResponse({"status": "ok", "config": raw})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
9
|
+
|
|
10
|
+
from overload.engine.models import RunProgress
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
_connections: dict[str, list[WebSocket]] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def broadcast_progress(run_id: str, progress: RunProgress) -> None:
|
|
20
|
+
sockets = _connections.get(run_id, [])
|
|
21
|
+
if not sockets:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
message = json.dumps({
|
|
25
|
+
"type": "progress",
|
|
26
|
+
"data": asdict(progress),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
dead: list[WebSocket] = []
|
|
30
|
+
for ws in sockets:
|
|
31
|
+
try:
|
|
32
|
+
await ws.send_text(message)
|
|
33
|
+
except Exception:
|
|
34
|
+
dead.append(ws)
|
|
35
|
+
|
|
36
|
+
for ws in dead:
|
|
37
|
+
sockets.remove(ws)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.websocket("/ws")
|
|
41
|
+
async def websocket_endpoint(ws: WebSocket) -> None:
|
|
42
|
+
await ws.accept()
|
|
43
|
+
subscribed_run: str | None = None
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
while True:
|
|
47
|
+
data = await ws.receive_text()
|
|
48
|
+
try:
|
|
49
|
+
msg = json.loads(data)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
await ws.send_text(json.dumps({"type": "error", "message": "Invalid JSON"}))
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if msg.get("type") == "subscribe":
|
|
55
|
+
run_id = msg.get("run_id", "")
|
|
56
|
+
if subscribed_run and subscribed_run in _connections:
|
|
57
|
+
conns = _connections[subscribed_run]
|
|
58
|
+
if ws in conns:
|
|
59
|
+
conns.remove(ws)
|
|
60
|
+
|
|
61
|
+
subscribed_run = run_id
|
|
62
|
+
_connections.setdefault(run_id, []).append(ws)
|
|
63
|
+
await ws.send_text(json.dumps({"type": "subscribed", "run_id": run_id}))
|
|
64
|
+
logger.debug("WebSocket subscribed to run %s", run_id)
|
|
65
|
+
|
|
66
|
+
elif msg.get("type") == "ping":
|
|
67
|
+
await ws.send_text(json.dumps({"type": "pong"}))
|
|
68
|
+
|
|
69
|
+
except WebSocketDisconnect:
|
|
70
|
+
logger.debug("WebSocket disconnected")
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.exception("WebSocket error")
|
|
73
|
+
finally:
|
|
74
|
+
if subscribed_run and subscribed_run in _connections:
|
|
75
|
+
conns = _connections[subscribed_run]
|
|
76
|
+
if ws in conns:
|
|
77
|
+
conns.remove(ws)
|