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