@henriquecosta/chaos-api 1.0.0
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.
- package/dist/adapters/express.d.ts +21 -0
- package/dist/adapters/express.js +40 -0
- package/dist/adapters/fastify.d.ts +12 -0
- package/dist/adapters/fastify.js +44 -0
- package/dist/adapters/koa.d.ts +12 -0
- package/dist/adapters/koa.js +47 -0
- package/dist/adapters/nestjs.d.ts +34 -0
- package/dist/adapters/nestjs.js +52 -0
- package/dist/bin/chaos-api.d.ts +2 -0
- package/dist/bin/chaos-api.js +33 -0
- package/dist/core/activity-log.d.ts +24 -0
- package/dist/core/activity-log.js +26 -0
- package/dist/core/scenario-engine.d.ts +12 -0
- package/dist/core/scenario-engine.js +38 -0
- package/dist/core/state-store.d.ts +26 -0
- package/dist/core/state-store.js +69 -0
- package/dist/core/types.d.ts +41 -0
- package/dist/core/types.js +1 -0
- package/dist/dashboard/server/control-api.d.ts +9 -0
- package/dist/dashboard/server/control-api.js +140 -0
- package/dist/dashboard/server/index.d.ts +10 -0
- package/dist/dashboard/server/index.js +49 -0
- package/dist/dashboard/ui/app.js +336 -0
- package/dist/dashboard/ui/index.html +215 -0
- package/dist/guardrail.d.ts +10 -0
- package/dist/guardrail.js +20 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +13 -0
- package/dist/outbound/chaos-fetch.d.ts +13 -0
- package/dist/outbound/chaos-fetch.js +50 -0
- package/dist/outbound/index.d.ts +1 -0
- package/dist/outbound/index.js +1 -0
- package/dist/presets/catalog.d.ts +8 -0
- package/dist/presets/catalog.js +157 -0
- package/dist/presets/index.d.ts +15 -0
- package/dist/presets/index.js +22 -0
- package/dist/presets/types.d.ts +17 -0
- package/dist/presets/types.js +1 -0
- package/dist/scenarios/connection-reset.d.ts +7 -0
- package/dist/scenarios/connection-reset.js +8 -0
- package/dist/scenarios/delay.d.ts +6 -0
- package/dist/scenarios/delay.js +9 -0
- package/dist/scenarios/error-response.d.ts +9 -0
- package/dist/scenarios/error-response.js +13 -0
- package/dist/scenarios/index.d.ts +7 -0
- package/dist/scenarios/index.js +7 -0
- package/dist/scenarios/malformed-response.d.ts +11 -0
- package/dist/scenarios/malformed-response.js +13 -0
- package/dist/scenarios/random-error.d.ts +6 -0
- package/dist/scenarios/random-error.js +7 -0
- package/dist/scenarios/random-timeout.d.ts +7 -0
- package/dist/scenarios/random-timeout.js +8 -0
- package/dist/scenarios/registry.d.ts +12 -0
- package/dist/scenarios/registry.js +20 -0
- package/dist/scenarios/stale-response.d.ts +9 -0
- package/dist/scenarios/stale-response.js +10 -0
- package/dist/scenarios/unavailable-503.d.ts +5 -0
- package/dist/scenarios/unavailable-503.js +9 -0
- package/dist/scenarios/unavailable.d.ts +7 -0
- package/dist/scenarios/unavailable.js +9 -0
- package/package.json +59 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { applyPreset, findPreset, listPresets } from "../../presets/index.js";
|
|
3
|
+
const CORS_HEADERS = {
|
|
4
|
+
"Access-Control-Allow-Origin": "*",
|
|
5
|
+
"Access-Control-Allow-Methods": "GET,POST,PATCH,DELETE,OPTIONS",
|
|
6
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Local control API for the scenario StateStore living inside the user's app process.
|
|
10
|
+
* dashboard-ui talks to this directly (CORS-open, localhost-only by convention) — the
|
|
11
|
+
* dashboard-server process only serves static UI files, it does not proxy this API.
|
|
12
|
+
*/
|
|
13
|
+
export function createControlApi(store, activityLog) {
|
|
14
|
+
return createServer((req, res) => {
|
|
15
|
+
void handleRequest(req, res, store, activityLog);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function handleRequest(req, res, store, activityLog) {
|
|
19
|
+
for (const [key, value] of Object.entries(CORS_HEADERS))
|
|
20
|
+
res.setHeader(key, value);
|
|
21
|
+
if (req.method === "OPTIONS") {
|
|
22
|
+
res.writeHead(204).end();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
26
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
27
|
+
try {
|
|
28
|
+
if (segments[0] !== "api") {
|
|
29
|
+
notFound(res);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (segments[1] === "activity" && segments.length === 2 && req.method === "GET") {
|
|
33
|
+
const limitParam = url.searchParams.get("limit");
|
|
34
|
+
sendJson(res, 200, activityLog?.list(limitParam ? Number(limitParam) : undefined) ?? []);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (segments[1] === "presets") {
|
|
38
|
+
if (segments.length === 2 && req.method === "GET") {
|
|
39
|
+
const category = url.searchParams.get("category");
|
|
40
|
+
sendJson(res, 200, listPresets((category ?? undefined)));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (segments.length === 4 && segments[3] === "apply" && req.method === "POST") {
|
|
44
|
+
const name = segments[2];
|
|
45
|
+
if (!findPreset(name)) {
|
|
46
|
+
notFound(res);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const body = await readJsonBody(req);
|
|
50
|
+
sendJson(res, 201, applyPreset(store, name, body));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
notFound(res);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (segments[1] === "config") {
|
|
57
|
+
if (segments.length === 2 && req.method === "GET") {
|
|
58
|
+
sendJson(res, 200, { scenarios: store.list() });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (segments.length === 2 && req.method === "POST") {
|
|
62
|
+
const body = await readJsonBody(req);
|
|
63
|
+
if (!Array.isArray(body.scenarios)) {
|
|
64
|
+
throw new Error('body must be { "scenarios": [...] }');
|
|
65
|
+
}
|
|
66
|
+
store.clear();
|
|
67
|
+
for (const scenario of body.scenarios)
|
|
68
|
+
store.register(scenario);
|
|
69
|
+
sendJson(res, 200, { scenarios: store.list() });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
notFound(res);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (segments[1] !== "scenarios") {
|
|
76
|
+
notFound(res);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (segments.length === 2 && req.method === "GET") {
|
|
80
|
+
sendJson(res, 200, store.list());
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (segments.length === 2 && req.method === "POST") {
|
|
84
|
+
const body = await readJsonBody(req);
|
|
85
|
+
sendJson(res, 201, store.register(body));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (segments.length === 3 && req.method === "PATCH") {
|
|
89
|
+
const body = await readJsonBody(req);
|
|
90
|
+
const updated = store.update(segments[2], body);
|
|
91
|
+
if (!updated) {
|
|
92
|
+
notFound(res);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
sendJson(res, 200, updated);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (segments.length === 3 && req.method === "DELETE") {
|
|
99
|
+
const removed = store.remove(segments[2]);
|
|
100
|
+
if (!removed) {
|
|
101
|
+
notFound(res);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
res.writeHead(204).end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
notFound(res);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
sendJson(res, 400, { error: error.message });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function sendJson(res, status, payload) {
|
|
114
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
115
|
+
res.end(JSON.stringify(payload));
|
|
116
|
+
}
|
|
117
|
+
function notFound(res) {
|
|
118
|
+
sendJson(res, 404, { error: "not found" });
|
|
119
|
+
}
|
|
120
|
+
function readJsonBody(req) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
let raw = "";
|
|
123
|
+
req.on("data", (chunk) => {
|
|
124
|
+
raw += chunk;
|
|
125
|
+
});
|
|
126
|
+
req.on("end", () => {
|
|
127
|
+
if (!raw) {
|
|
128
|
+
resolve({});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
resolve(JSON.parse(raw));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
reject(new Error("invalid JSON body"));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
req.on("error", reject);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
export interface StartDashboardOptions {
|
|
3
|
+
port?: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Serves the static dashboard-ui. This process does not hold any scenario state —
|
|
7
|
+
* dashboard-ui talks directly to the control API running inside the target app
|
|
8
|
+
* (see src/dashboard/server/control-api.ts and docs/architecture-and-walkthrough.md).
|
|
9
|
+
*/
|
|
10
|
+
export declare function startDashboard(options?: StartDashboardOptions): Server;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const UI_DIR = path.join(__dirname, "..", "ui");
|
|
7
|
+
const MIME = {
|
|
8
|
+
".html": "text/html; charset=utf-8",
|
|
9
|
+
".js": "text/javascript; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Serves the static dashboard-ui. This process does not hold any scenario state —
|
|
14
|
+
* dashboard-ui talks directly to the control API running inside the target app
|
|
15
|
+
* (see src/dashboard/server/control-api.ts and docs/architecture-and-walkthrough.md).
|
|
16
|
+
*/
|
|
17
|
+
export function startDashboard(options = {}) {
|
|
18
|
+
const port = options.port ?? 4000;
|
|
19
|
+
const server = createServer((req, res) => {
|
|
20
|
+
void serveStatic(req.url ?? "/", res);
|
|
21
|
+
});
|
|
22
|
+
server.listen(port, () => {
|
|
23
|
+
console.log(`[chaos-api] dashboard running at http://localhost:${port}/dashboard`);
|
|
24
|
+
});
|
|
25
|
+
return server;
|
|
26
|
+
}
|
|
27
|
+
async function serveStatic(url, res) {
|
|
28
|
+
let pathname = url.split("?")[0];
|
|
29
|
+
if (pathname === "/" || pathname === "/dashboard" || pathname === "/dashboard/") {
|
|
30
|
+
pathname = "/index.html";
|
|
31
|
+
}
|
|
32
|
+
else if (pathname.startsWith("/dashboard/")) {
|
|
33
|
+
pathname = pathname.slice("/dashboard".length);
|
|
34
|
+
}
|
|
35
|
+
const filePath = path.join(UI_DIR, pathname);
|
|
36
|
+
if (!filePath.startsWith(UI_DIR)) {
|
|
37
|
+
res.writeHead(403).end();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const content = await readFile(filePath);
|
|
42
|
+
const ext = path.extname(filePath);
|
|
43
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
44
|
+
res.end(content);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
res.writeHead(404).end("Not found");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
const STORAGE_KEY = "chaos-api:control-url";
|
|
2
|
+
|
|
3
|
+
const controlUrlInput = document.getElementById("control-url");
|
|
4
|
+
const connectButton = document.getElementById("connect");
|
|
5
|
+
const listEl = document.getElementById("scenario-list");
|
|
6
|
+
const bannerEl = document.getElementById("status-banner");
|
|
7
|
+
const errorEl = document.getElementById("error");
|
|
8
|
+
const addForm = document.getElementById("add-form");
|
|
9
|
+
const activityListEl = document.getElementById("activity-list");
|
|
10
|
+
const presetCategoriesEl = document.getElementById("preset-categories");
|
|
11
|
+
const presetListEl = document.getElementById("preset-list");
|
|
12
|
+
const exportButton = document.getElementById("export-config");
|
|
13
|
+
const importButton = document.getElementById("import-config");
|
|
14
|
+
const importFileInput = document.getElementById("import-file");
|
|
15
|
+
const runnerForm = document.getElementById("runner-form");
|
|
16
|
+
const runnerResponseEl = document.getElementById("runner-response");
|
|
17
|
+
|
|
18
|
+
const PRESET_CATEGORIES = [
|
|
19
|
+
{ value: "", label: "todas" },
|
|
20
|
+
{ value: "seguranca", label: "segurança" },
|
|
21
|
+
{ value: "dependencias-externas", label: "deps. externas" },
|
|
22
|
+
{ value: "configuracao", label: "configuração" },
|
|
23
|
+
{ value: "resource-exhaustion", label: "resource exhaustion" },
|
|
24
|
+
{ value: "filesystem", label: "filesystem" },
|
|
25
|
+
];
|
|
26
|
+
let activePresetCategory = "";
|
|
27
|
+
|
|
28
|
+
controlUrlInput.value = localStorage.getItem(STORAGE_KEY) || controlUrlInput.value;
|
|
29
|
+
|
|
30
|
+
function controlUrl() {
|
|
31
|
+
return controlUrlInput.value.replace(/\/$/, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function api(path, options) {
|
|
35
|
+
const res = await fetch(`${controlUrl()}${path}`, {
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
...options,
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok && res.status !== 204) {
|
|
40
|
+
const body = await res.json().catch(() => ({}));
|
|
41
|
+
throw new Error(body.error || `request failed: ${res.status}`);
|
|
42
|
+
}
|
|
43
|
+
return res.status === 204 ? undefined : res.json();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scopeLabel(scenario) {
|
|
47
|
+
return scenario.scope === "global" ? "global" : scenario.scope.pattern;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderScenarios(scenarios) {
|
|
51
|
+
listEl.innerHTML = "";
|
|
52
|
+
const activeOnes = scenarios.filter((s) => s.enabled);
|
|
53
|
+
|
|
54
|
+
bannerEl.style.display = activeOnes.length ? "block" : "none";
|
|
55
|
+
bannerEl.textContent = activeOnes.length
|
|
56
|
+
? `${activeOnes.length} cenário(s) ativo(s): ${activeOnes.map((s) => `${s.type} (${scopeLabel(s)})`).join(", ")}`
|
|
57
|
+
: "";
|
|
58
|
+
|
|
59
|
+
if (!scenarios.length) {
|
|
60
|
+
listEl.innerHTML = '<p style="color: var(--text-muted)">Nenhum cenário registrado ainda.</p>';
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const scenario of scenarios) {
|
|
65
|
+
const card = document.createElement("div");
|
|
66
|
+
card.className = `card ${scenario.enabled ? "on" : ""}`;
|
|
67
|
+
card.innerHTML = `
|
|
68
|
+
<input type="checkbox" ${scenario.enabled ? "checked" : ""} data-id="${scenario.id}" class="toggle" />
|
|
69
|
+
<div class="meta">
|
|
70
|
+
<div class="type">${scenario.type}</div>
|
|
71
|
+
<div class="scope">${scopeLabel(scenario)} · rate ${scenario.rate}</div>
|
|
72
|
+
</div>
|
|
73
|
+
<button class="danger" data-id="${scenario.id}" data-action="remove">Remover</button>
|
|
74
|
+
`;
|
|
75
|
+
listEl.appendChild(card);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
listEl.querySelectorAll(".toggle").forEach((el) => {
|
|
79
|
+
el.addEventListener("change", async (e) => {
|
|
80
|
+
const id = e.target.getAttribute("data-id");
|
|
81
|
+
await withErrorHandling(() => api(`/api/scenarios/${id}`, {
|
|
82
|
+
method: "PATCH",
|
|
83
|
+
body: JSON.stringify({ enabled: e.target.checked }),
|
|
84
|
+
}));
|
|
85
|
+
await refresh();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
listEl.querySelectorAll('[data-action="remove"]').forEach((el) => {
|
|
90
|
+
el.addEventListener("click", async (e) => {
|
|
91
|
+
const id = e.target.getAttribute("data-id");
|
|
92
|
+
await withErrorHandling(() => api(`/api/scenarios/${id}`, { method: "DELETE" }));
|
|
93
|
+
await refresh();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function withErrorHandling(fn) {
|
|
99
|
+
errorEl.textContent = "";
|
|
100
|
+
try {
|
|
101
|
+
return await fn();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
errorEl.textContent = err.message;
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function refresh() {
|
|
109
|
+
const scenarios = await withErrorHandling(() => api("/api/scenarios"));
|
|
110
|
+
if (scenarios) renderScenarios(scenarios);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatTime(timestamp) {
|
|
114
|
+
return new Date(timestamp).toLocaleTimeString("pt-BR");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderActivity(events) {
|
|
118
|
+
if (!events.length) {
|
|
119
|
+
activityListEl.innerHTML = '<p style="color: var(--text-muted); padding: 12px">Nenhum cenário disparado ainda.</p>';
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
activityListEl.innerHTML = events
|
|
124
|
+
.map(
|
|
125
|
+
(event) => `
|
|
126
|
+
<div class="activity-row">
|
|
127
|
+
<span class="activity-time">${formatTime(event.timestamp)}</span>
|
|
128
|
+
<span class="activity-type">${event.scenarioType}</span>
|
|
129
|
+
<span class="activity-dir">${event.direction}</span>
|
|
130
|
+
<span class="activity-path">${event.method} ${event.path}</span>
|
|
131
|
+
</div>
|
|
132
|
+
`,
|
|
133
|
+
)
|
|
134
|
+
.join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function refreshActivity() {
|
|
138
|
+
const events = await withErrorHandling(() => api("/api/activity?limit=50"));
|
|
139
|
+
if (events) renderActivity(events);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderPresetCategories() {
|
|
143
|
+
presetCategoriesEl.innerHTML = "";
|
|
144
|
+
for (const category of PRESET_CATEGORIES) {
|
|
145
|
+
const button = document.createElement("button");
|
|
146
|
+
button.textContent = category.label;
|
|
147
|
+
button.className = category.value === activePresetCategory ? "active" : "";
|
|
148
|
+
button.addEventListener("click", () => {
|
|
149
|
+
activePresetCategory = category.value;
|
|
150
|
+
refreshPresets();
|
|
151
|
+
});
|
|
152
|
+
presetCategoriesEl.appendChild(button);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderPresets(presets) {
|
|
157
|
+
if (!presets.length) {
|
|
158
|
+
presetListEl.innerHTML = '<p style="color: var(--text-muted)">Nenhum preset nessa categoria.</p>';
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
presetListEl.innerHTML = "";
|
|
163
|
+
for (const preset of presets) {
|
|
164
|
+
const card = document.createElement("div");
|
|
165
|
+
card.className = "preset-card";
|
|
166
|
+
card.innerHTML = `
|
|
167
|
+
<div class="meta">
|
|
168
|
+
<div class="name">${preset.name}</div>
|
|
169
|
+
<div class="description">${preset.description}</div>
|
|
170
|
+
</div>
|
|
171
|
+
<button data-name="${preset.name}" data-action="apply-preset">Aplicar</button>
|
|
172
|
+
`;
|
|
173
|
+
presetListEl.appendChild(card);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
presetListEl.querySelectorAll('[data-action="apply-preset"]').forEach((el) => {
|
|
177
|
+
el.addEventListener("click", async (e) => {
|
|
178
|
+
const name = e.target.getAttribute("data-name");
|
|
179
|
+
await withErrorHandling(() => api(`/api/presets/${name}/apply`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
body: JSON.stringify({}),
|
|
182
|
+
}));
|
|
183
|
+
await refresh();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function refreshPresets() {
|
|
189
|
+
renderPresetCategories();
|
|
190
|
+
const query = activePresetCategory ? `?category=${activePresetCategory}` : "";
|
|
191
|
+
const presets = await withErrorHandling(() => api(`/api/presets${query}`));
|
|
192
|
+
if (presets) renderPresets(presets);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
connectButton.addEventListener("click", () => {
|
|
196
|
+
localStorage.setItem(STORAGE_KEY, controlUrlInput.value);
|
|
197
|
+
refresh();
|
|
198
|
+
refreshActivity();
|
|
199
|
+
refreshPresets();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
exportButton.addEventListener("click", async () => {
|
|
203
|
+
const config = await withErrorHandling(() => api("/api/config"));
|
|
204
|
+
if (!config) return;
|
|
205
|
+
|
|
206
|
+
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
|
207
|
+
const url = URL.createObjectURL(blob);
|
|
208
|
+
const link = document.createElement("a");
|
|
209
|
+
link.href = url;
|
|
210
|
+
link.download = "chaos-api-config.json";
|
|
211
|
+
link.click();
|
|
212
|
+
URL.revokeObjectURL(url);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
importButton.addEventListener("click", () => importFileInput.click());
|
|
216
|
+
|
|
217
|
+
importFileInput.addEventListener("change", async () => {
|
|
218
|
+
const file = importFileInput.files[0];
|
|
219
|
+
if (!file) return;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const config = JSON.parse(await file.text());
|
|
223
|
+
await withErrorHandling(() => api("/api/config", { method: "POST", body: JSON.stringify(config) }));
|
|
224
|
+
await refresh();
|
|
225
|
+
} catch (err) {
|
|
226
|
+
errorEl.textContent = err.message;
|
|
227
|
+
} finally {
|
|
228
|
+
importFileInput.value = "";
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
runnerForm.addEventListener("submit", async (e) => {
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
const formData = new FormData(runnerForm);
|
|
235
|
+
const method = formData.get("method")?.toString() || "GET";
|
|
236
|
+
const url = formData.get("url")?.toString().trim();
|
|
237
|
+
const headersRaw = formData.get("headers")?.toString().trim();
|
|
238
|
+
const bodyRaw = formData.get("body")?.toString().trim();
|
|
239
|
+
|
|
240
|
+
runnerResponseEl.innerHTML = "";
|
|
241
|
+
if (!url) return;
|
|
242
|
+
|
|
243
|
+
let headers = {};
|
|
244
|
+
if (headersRaw) {
|
|
245
|
+
try {
|
|
246
|
+
headers = JSON.parse(headersRaw);
|
|
247
|
+
} catch {
|
|
248
|
+
runnerResponseEl.innerHTML = '<div class="status error">headers: JSON inválido</div>';
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const hasBody = bodyRaw && !["GET", "HEAD", "DELETE"].includes(method);
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const started = performance.now();
|
|
257
|
+
const res = await fetch(url, { method, headers, body: hasBody ? bodyRaw : undefined });
|
|
258
|
+
const elapsedMs = Math.round(performance.now() - started);
|
|
259
|
+
const text = await res.text();
|
|
260
|
+
let pretty = text;
|
|
261
|
+
try {
|
|
262
|
+
pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
263
|
+
} catch {
|
|
264
|
+
// not JSON — show as-is
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const responseHeaders = [...res.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
268
|
+
runnerResponseEl.innerHTML = "";
|
|
269
|
+
runnerResponseEl.appendChild(
|
|
270
|
+
buildRunnerResult(res.ok, `${res.status} ${res.statusText} · ${elapsedMs}ms`, responseHeaders, pretty),
|
|
271
|
+
);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
runnerResponseEl.innerHTML = "";
|
|
274
|
+
runnerResponseEl.appendChild(buildRunnerResult(false, err.message, "", ""));
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
function buildRunnerResult(ok, statusLine, headersText, bodyText) {
|
|
279
|
+
const wrapper = document.createElement("div");
|
|
280
|
+
|
|
281
|
+
const status = document.createElement("div");
|
|
282
|
+
status.className = `status ${ok ? "ok" : "error"}`;
|
|
283
|
+
status.textContent = statusLine;
|
|
284
|
+
wrapper.appendChild(status);
|
|
285
|
+
|
|
286
|
+
if (headersText) {
|
|
287
|
+
const headers = document.createElement("div");
|
|
288
|
+
headers.textContent = headersText;
|
|
289
|
+
wrapper.appendChild(headers);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (bodyText) {
|
|
293
|
+
const body = document.createElement("div");
|
|
294
|
+
body.style.marginTop = "8px";
|
|
295
|
+
body.textContent = bodyText;
|
|
296
|
+
wrapper.appendChild(body);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return wrapper;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
setInterval(refreshActivity, 3000);
|
|
303
|
+
|
|
304
|
+
addForm.addEventListener("submit", async (e) => {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
const formData = new FormData(addForm);
|
|
307
|
+
const pattern = formData.get("pattern")?.toString().trim();
|
|
308
|
+
const optionsRaw = formData.get("options")?.toString().trim();
|
|
309
|
+
|
|
310
|
+
let options = {};
|
|
311
|
+
if (optionsRaw) {
|
|
312
|
+
try {
|
|
313
|
+
options = JSON.parse(optionsRaw);
|
|
314
|
+
} catch {
|
|
315
|
+
errorEl.textContent = "options: JSON inválido";
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await withErrorHandling(() => api("/api/scenarios", {
|
|
321
|
+
method: "POST",
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
type: formData.get("type"),
|
|
324
|
+
rate: Number(formData.get("rate")),
|
|
325
|
+
scope: pattern ? { pattern } : "global",
|
|
326
|
+
options,
|
|
327
|
+
}),
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
addForm.reset();
|
|
331
|
+
await refresh();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
refresh();
|
|
335
|
+
refreshActivity();
|
|
336
|
+
refreshPresets();
|