shadowbox 0.3.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.
Files changed (61) hide show
  1. shadowbox-0.3.0/.github/workflows/ci.yml +45 -0
  2. shadowbox-0.3.0/.github/workflows/publish.yml +33 -0
  3. shadowbox-0.3.0/.gitignore +26 -0
  4. shadowbox-0.3.0/.python-version +1 -0
  5. shadowbox-0.3.0/PKG-INFO +130 -0
  6. shadowbox-0.3.0/README.md +115 -0
  7. shadowbox-0.3.0/apps/api/worker.py +22 -0
  8. shadowbox-0.3.0/apps/studio/index.html +12 -0
  9. shadowbox-0.3.0/apps/studio/package.json +24 -0
  10. shadowbox-0.3.0/apps/studio/src/App.tsx +73 -0
  11. shadowbox-0.3.0/apps/studio/src/api.ts +86 -0
  12. shadowbox-0.3.0/apps/studio/src/fixtures.ts +23 -0
  13. shadowbox-0.3.0/apps/studio/src/graph.ts +45 -0
  14. shadowbox-0.3.0/apps/studio/src/main.tsx +9 -0
  15. shadowbox-0.3.0/apps/studio/tsconfig.json +14 -0
  16. shadowbox-0.3.0/apps/studio/vite.config.ts +11 -0
  17. shadowbox-0.3.0/apps/web/app.js +117 -0
  18. shadowbox-0.3.0/apps/web/index.html +25 -0
  19. shadowbox-0.3.0/apps/web/styles.css +9 -0
  20. shadowbox-0.3.0/pyproject.toml +57 -0
  21. shadowbox-0.3.0/schema.sql +13 -0
  22. shadowbox-0.3.0/schemas/model-v1.json +51 -0
  23. shadowbox-0.3.0/schemas/scenario-v1.json +41 -0
  24. shadowbox-0.3.0/src/shadowbox/__init__.py +3 -0
  25. shadowbox-0.3.0/src/shadowbox/api.py +136 -0
  26. shadowbox-0.3.0/src/shadowbox/cards.py +59 -0
  27. shadowbox-0.3.0/src/shadowbox/cli.py +222 -0
  28. shadowbox-0.3.0/src/shadowbox/compare.py +103 -0
  29. shadowbox-0.3.0/src/shadowbox/data/__init__.py +1 -0
  30. shadowbox-0.3.0/src/shadowbox/data/cards/cache-poison.yaml +7 -0
  31. shadowbox-0.3.0/src/shadowbox/data/cards/db-down.yaml +7 -0
  32. shadowbox-0.3.0/src/shadowbox/data/cards/latency-500ms.yaml +8 -0
  33. shadowbox-0.3.0/src/shadowbox/data/cards/queue-overflow.yaml +6 -0
  34. shadowbox-0.3.0/src/shadowbox/data/cards/slow-dependency.yaml +8 -0
  35. shadowbox-0.3.0/src/shadowbox/data/cards/traffic-10x.yaml +6 -0
  36. shadowbox-0.3.0/src/shadowbox/data/cards/zone-loss.yaml +8 -0
  37. shadowbox-0.3.0/src/shadowbox/data/example/docker-compose.yaml +13 -0
  38. shadowbox-0.3.0/src/shadowbox/data/example/model.yaml +33 -0
  39. shadowbox-0.3.0/src/shadowbox/data/example/scenarios/db-failure.yaml +12 -0
  40. shadowbox-0.3.0/src/shadowbox/dsl.py +94 -0
  41. shadowbox-0.3.0/src/shadowbox/engine.py +220 -0
  42. shadowbox-0.3.0/src/shadowbox/errors.py +38 -0
  43. shadowbox-0.3.0/src/shadowbox/importers/__init__.py +3 -0
  44. shadowbox-0.3.0/src/shadowbox/importers/compose.py +109 -0
  45. shadowbox-0.3.0/src/shadowbox/metrics.py +49 -0
  46. shadowbox-0.3.0/src/shadowbox/model.py +74 -0
  47. shadowbox-0.3.0/src/shadowbox/report.py +48 -0
  48. shadowbox-0.3.0/src/shadowbox/store.py +85 -0
  49. shadowbox-0.3.0/tests/deterministic/test_golden.py +48 -0
  50. shadowbox-0.3.0/tests/fixtures/bad-ref.yaml +13 -0
  51. shadowbox-0.3.0/tests/fixtures/cycle.yaml +23 -0
  52. shadowbox-0.3.0/tests/integration/test_api.py +94 -0
  53. shadowbox-0.3.0/tests/property/test_invariants.py +98 -0
  54. shadowbox-0.3.0/tests/unit/test_compare.py +132 -0
  55. shadowbox-0.3.0/tests/unit/test_import.py +58 -0
  56. shadowbox-0.3.0/tests/unit/test_init.py +43 -0
  57. shadowbox-0.3.0/tests/unit/test_validate.py +57 -0
  58. shadowbox-0.3.0/tests/unit/test_version.py +9 -0
  59. shadowbox-0.3.0/tests/unit/test_worker.py +17 -0
  60. shadowbox-0.3.0/uv.lock +893 -0
  61. shadowbox-0.3.0/wrangler.jsonc +20 -0
@@ -0,0 +1,45 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ m0:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v5
13
+ with:
14
+ python-version: "3.13"
15
+ - run: uv sync --group dev
16
+ - run: uv run ruff check .
17
+ - run: uv run mypy src
18
+ - run: uv run pytest
19
+ - run: uv run shadowbox init --out /tmp/sb
20
+ - run: uv run shadowbox validate /tmp/sb/model.yaml --scenario /tmp/sb/scenarios/db-failure.yaml
21
+ - run: uv run shadowbox import --from /tmp/sb/docker-compose.yaml --out /tmp/imported.yaml
22
+ - run: uv run shadowbox validate /tmp/imported.yaml --scenario /tmp/sb/cards/db-down.yaml
23
+ - run: |
24
+ for card in /tmp/sb/cards/*.yaml; do
25
+ uv run shadowbox validate /tmp/sb/model.yaml --scenario "$card"
26
+ done
27
+ - run: uv run shadowbox simulate /tmp/sb/model.yaml --scenario /tmp/sb/cards/queue-overflow.yaml --seed 42 --out /tmp/report.json
28
+ - run: uv run shadowbox simulate /tmp/sb/model.yaml --scenario /tmp/sb/scenarios/db-failure.yaml --seed 42 --out /tmp/base.json
29
+ - run: uv run shadowbox compare --a /tmp/base.json --b /tmp/base.json
30
+ - run: uv run shadowbox report /tmp/report.json --format text
31
+ - run: uv build
32
+ - run: uv run python -c "import zipfile,glob; w=sorted(glob.glob('dist/*.whl'))[-1]; n=zipfile.ZipFile(w).namelist(); assert sum(1 for x in n if '/data/' in x) == 11, n; assert any(x.endswith('example/model.yaml') for x in n)"
33
+
34
+ studio:
35
+ runs-on: ubuntu-latest
36
+ defaults:
37
+ run:
38
+ working-directory: apps/studio
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: actions/setup-node@v4
42
+ with:
43
+ node-version: "24"
44
+ - run: npm install
45
+ - run: npm run build
@@ -0,0 +1,33 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ with:
15
+ python-version: "3.13"
16
+ - run: uv build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,26 @@
1
+ # Ignore all Markdown except README.md (spec and agent docs stay local)
2
+ *.md
3
+ **/*.md
4
+ !README.md
5
+ !**/README.md
6
+
7
+ # Python runtimes (never commit)
8
+ .venv/
9
+ __pycache__/
10
+ *.pyc
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+
15
+ # Local server artifacts (never commit)
16
+ *.db
17
+ report.json
18
+ model.yaml
19
+ !src/shadowbox/data/example/model.yaml
20
+
21
+ # Build output (never commit)
22
+ dist/
23
+
24
+ # Node (never commit)
25
+ node_modules/
26
+ dist/
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: shadowbox
3
+ Version: 0.3.0
4
+ Summary: Executable architectural model for safe what-if experimentation (M0: contracts + validate)
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: fastapi>=0.115
8
+ Requires-Dist: jsonschema>=4.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: pyyaml>=6.0
11
+ Requires-Dist: rich>=13.0
12
+ Requires-Dist: typer>=0.9
13
+ Requires-Dist: uvicorn>=0.30
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ShadowBox
17
+
18
+ Executable architectural model for safe what-if experimentation. Model the system. Experiment safely.
19
+
20
+ > Scope: headless CLI (validate, simulate, compare, report, import, init, serve) plus local API, static demo, and React Studio. Simulation output is always labeled with assumptions, confidence, and seed — never presented as production measurement.
21
+
22
+ ## Requirements
23
+
24
+ - Python `>=3.13` (pinned via `.python-version`)
25
+ - `uv` for env and runs (no Docker needed)
26
+
27
+ ## Install
28
+
29
+ With a clone (development):
30
+
31
+ ```powershell
32
+ uv python pin 3.13
33
+ uv venv
34
+ uv sync --group dev
35
+ ```
36
+
37
+ Without a clone (use only):
38
+
39
+ ```powershell
40
+ uvx --from "shadowbox @ git+https://github.com/Pa004/shadowBox.git@v0.2.0" shadowbox init --out demo
41
+ ```
42
+
43
+ ## Run in development
44
+
45
+ ```powershell
46
+ uv run shadowbox init --out demo
47
+ uv run shadowbox import --from demo/docker-compose.yaml --out demo/model2.yaml
48
+ uv run shadowbox validate demo/model.yaml --scenario demo/scenarios/db-failure.yaml
49
+ uv run shadowbox simulate demo/model.yaml --scenario demo/scenarios/db-failure.yaml --seed 42 --out report.json
50
+ uv run shadowbox report report.json --format text
51
+ uv run shadowbox compare --a base.json --b report.json
52
+ ```
53
+
54
+ Exit codes: `0` valid/pass, `2` scenario regression (compare), `3` invalid input or existing files without `--force` (prints `E_*` code).
55
+
56
+ Import notes: every performance field is an estimated default (see warnings). Calibrate before trusting output.
57
+
58
+ Chaos cards ship in the package (`init` writes them to `cards/`): `db-down`, `cache-poison`, `latency-500ms`, `traffic-10x`, `zone-loss`, `slow-dependency`, `queue-overflow`.
59
+
60
+ ## API server (local)
61
+
62
+ ```powershell
63
+ uv run uvicorn shadowbox.api:app --port 8000
64
+ # or: uv run shadowbox serve --port 8000
65
+ ```
66
+
67
+ Endpoints: `POST /api/v1/models`, `GET /api/v1/models/{id}`, `POST /api/v1/simulations?model_id=...`, `GET /api/v1/simulations/{id}[/events|/metrics|/report]`. Events are paginated (`limit` 1..1000, `cursor` offset over the stored 500-request sample). State lives in `shadowbox.db` (git-ignored, created on first use). The API has no authentication: bind to localhost (`serve` defaults to `127.0.0.1`) and never expose it directly to the internet.
68
+
69
+ ## Deploy (Cloudflare free tier, no card)
70
+
71
+ Scaffold ready in `wrangler.jsonc` + `schema.sql` + `apps/api/worker.py` + `apps/web/` (static demo, no build step). Remaining steps need your Cloudflare account:
72
+
73
+ ```powershell
74
+ !npm install -g wrangler
75
+ !wrangler login
76
+ !wrangler d1 create shadowbox # paste database_id into wrangler.jsonc
77
+ !wrangler d1 execute shadowbox --file schema.sql
78
+ !uvx --from workers-py pywrangler dev # local Worker emulation, no account needed
79
+ !wrangler deploy
80
+ ```
81
+
82
+ Production note: the Worker serves the same FastAPI app; swapping the SQLite file store for the D1 binding is a follow-up task verified against a real account (M4b-full). The static demo deploys to Pages as-is and talks to any API base URL.
83
+
84
+ Open `apps/web/index.html` after `Run` to scrub virtual time: the SVG graph colors failed components red and shows active faults per second (first 500 sampled requests).
85
+
86
+ ## Studio (React + Cytoscape)
87
+
88
+ Full UI in `apps/studio/` (Vite, strict TS). Needs Node deps (run yourself):
89
+
90
+ ```powershell
91
+ cd apps/studio
92
+ npm install
93
+ npm run build # tsc plus vite
94
+ npm run dev # /api proxies to 127.0.0.1:8000
95
+ ```
96
+
97
+ Run any scenario vs baseline, inspect p99/error verdict, scrub the failure cascade on the Cytoscape graph.
98
+
99
+ ## Environment variables
100
+
101
+ None required. Server mode reads no env vars yet; Cloudflare D1 bindings arrive with the production Worker swap.
102
+
103
+ ## Project structure
104
+
105
+ ```text
106
+ src/shadowbox/ # model, dsl, cli, errors, engine, metrics, report, cards, api, store
107
+ src/shadowbox/data/ # canonical example + chaos cards (shipped in the wheel)
108
+ schemas/ # model-v1.json, scenario-v1.json
109
+ tests/ # unit, deterministic (golden seed 42), property, integration
110
+ tests/fixtures/ # broken models (E_CYCLE, E_REF)
111
+ apps/web/ # static demo with replay (no build)
112
+ apps/studio/ # React + Cytoscape UI (Vite, strict TS)
113
+ ```
114
+
115
+ ## Run tests
116
+
117
+ ```powershell
118
+ uv run ruff check .
119
+ uv run mypy src
120
+ uv run pytest
121
+ ```
122
+
123
+ Benchmarks (reference: i7-1255U, 16GB, Python 3.13; SLO: 50k events < 2s):
124
+
125
+ - checkout db-failure (6k reqs, 85k events): ~0.05s
126
+ - 60k reqs, 840k events: ~0.6s
127
+
128
+ ## Deploy notes
129
+
130
+ Local-first: CLI and `serve` need nothing but Python. Demo deploy: Cloudflare Pages (web) + Python Worker (FastAPI via `workers.asgi`) + D1 — see `Deploy (Cloudflare...)` above. No paid service, no credit card at any tier. See `ShadowBox.md` (local spec, git-ignored) for the full contract.
@@ -0,0 +1,115 @@
1
+ # ShadowBox
2
+
3
+ Executable architectural model for safe what-if experimentation. Model the system. Experiment safely.
4
+
5
+ > Scope: headless CLI (validate, simulate, compare, report, import, init, serve) plus local API, static demo, and React Studio. Simulation output is always labeled with assumptions, confidence, and seed — never presented as production measurement.
6
+
7
+ ## Requirements
8
+
9
+ - Python `>=3.13` (pinned via `.python-version`)
10
+ - `uv` for env and runs (no Docker needed)
11
+
12
+ ## Install
13
+
14
+ With a clone (development):
15
+
16
+ ```powershell
17
+ uv python pin 3.13
18
+ uv venv
19
+ uv sync --group dev
20
+ ```
21
+
22
+ Without a clone (use only):
23
+
24
+ ```powershell
25
+ uvx --from "shadowbox @ git+https://github.com/Pa004/shadowBox.git@v0.2.0" shadowbox init --out demo
26
+ ```
27
+
28
+ ## Run in development
29
+
30
+ ```powershell
31
+ uv run shadowbox init --out demo
32
+ uv run shadowbox import --from demo/docker-compose.yaml --out demo/model2.yaml
33
+ uv run shadowbox validate demo/model.yaml --scenario demo/scenarios/db-failure.yaml
34
+ uv run shadowbox simulate demo/model.yaml --scenario demo/scenarios/db-failure.yaml --seed 42 --out report.json
35
+ uv run shadowbox report report.json --format text
36
+ uv run shadowbox compare --a base.json --b report.json
37
+ ```
38
+
39
+ Exit codes: `0` valid/pass, `2` scenario regression (compare), `3` invalid input or existing files without `--force` (prints `E_*` code).
40
+
41
+ Import notes: every performance field is an estimated default (see warnings). Calibrate before trusting output.
42
+
43
+ Chaos cards ship in the package (`init` writes them to `cards/`): `db-down`, `cache-poison`, `latency-500ms`, `traffic-10x`, `zone-loss`, `slow-dependency`, `queue-overflow`.
44
+
45
+ ## API server (local)
46
+
47
+ ```powershell
48
+ uv run uvicorn shadowbox.api:app --port 8000
49
+ # or: uv run shadowbox serve --port 8000
50
+ ```
51
+
52
+ Endpoints: `POST /api/v1/models`, `GET /api/v1/models/{id}`, `POST /api/v1/simulations?model_id=...`, `GET /api/v1/simulations/{id}[/events|/metrics|/report]`. Events are paginated (`limit` 1..1000, `cursor` offset over the stored 500-request sample). State lives in `shadowbox.db` (git-ignored, created on first use). The API has no authentication: bind to localhost (`serve` defaults to `127.0.0.1`) and never expose it directly to the internet.
53
+
54
+ ## Deploy (Cloudflare free tier, no card)
55
+
56
+ Scaffold ready in `wrangler.jsonc` + `schema.sql` + `apps/api/worker.py` + `apps/web/` (static demo, no build step). Remaining steps need your Cloudflare account:
57
+
58
+ ```powershell
59
+ !npm install -g wrangler
60
+ !wrangler login
61
+ !wrangler d1 create shadowbox # paste database_id into wrangler.jsonc
62
+ !wrangler d1 execute shadowbox --file schema.sql
63
+ !uvx --from workers-py pywrangler dev # local Worker emulation, no account needed
64
+ !wrangler deploy
65
+ ```
66
+
67
+ Production note: the Worker serves the same FastAPI app; swapping the SQLite file store for the D1 binding is a follow-up task verified against a real account (M4b-full). The static demo deploys to Pages as-is and talks to any API base URL.
68
+
69
+ Open `apps/web/index.html` after `Run` to scrub virtual time: the SVG graph colors failed components red and shows active faults per second (first 500 sampled requests).
70
+
71
+ ## Studio (React + Cytoscape)
72
+
73
+ Full UI in `apps/studio/` (Vite, strict TS). Needs Node deps (run yourself):
74
+
75
+ ```powershell
76
+ cd apps/studio
77
+ npm install
78
+ npm run build # tsc plus vite
79
+ npm run dev # /api proxies to 127.0.0.1:8000
80
+ ```
81
+
82
+ Run any scenario vs baseline, inspect p99/error verdict, scrub the failure cascade on the Cytoscape graph.
83
+
84
+ ## Environment variables
85
+
86
+ None required. Server mode reads no env vars yet; Cloudflare D1 bindings arrive with the production Worker swap.
87
+
88
+ ## Project structure
89
+
90
+ ```text
91
+ src/shadowbox/ # model, dsl, cli, errors, engine, metrics, report, cards, api, store
92
+ src/shadowbox/data/ # canonical example + chaos cards (shipped in the wheel)
93
+ schemas/ # model-v1.json, scenario-v1.json
94
+ tests/ # unit, deterministic (golden seed 42), property, integration
95
+ tests/fixtures/ # broken models (E_CYCLE, E_REF)
96
+ apps/web/ # static demo with replay (no build)
97
+ apps/studio/ # React + Cytoscape UI (Vite, strict TS)
98
+ ```
99
+
100
+ ## Run tests
101
+
102
+ ```powershell
103
+ uv run ruff check .
104
+ uv run mypy src
105
+ uv run pytest
106
+ ```
107
+
108
+ Benchmarks (reference: i7-1255U, 16GB, Python 3.13; SLO: 50k events < 2s):
109
+
110
+ - checkout db-failure (6k reqs, 85k events): ~0.05s
111
+ - 60k reqs, 840k events: ~0.6s
112
+
113
+ ## Deploy notes
114
+
115
+ Local-first: CLI and `serve` need nothing but Python. Demo deploy: Cloudflare Pages (web) + Python Worker (FastAPI via `workers.asgi`) + D1 — see `Deploy (Cloudflare...)` above. No paid service, no credit card at any tier. See `ShadowBox.md` (local spec, git-ignored) for the full contract.
@@ -0,0 +1,22 @@
1
+ """Cloudflare Python Worker entry (M4b scaffold).
2
+
3
+ Local emulation (no account needed):
4
+ uvx --from workers-py pywrangler dev
5
+
6
+ Production additionally needs:
7
+ 1. `wrangler d1 create shadowbox` -> paste database_id into wrangler.jsonc
8
+ 2. `wrangler d1 execute shadowbox --file schema.sql`
9
+ 3. D1-backed store swap (same Store signatures, D1 binding instead of
10
+ sqlite3 file) — follow-up task, needs account access to verify.
11
+ """
12
+
13
+ try:
14
+ from workers import asgi
15
+ except ImportError as exc:
16
+ raise RuntimeError(
17
+ "workers runtime missing: run inside `pywrangler dev`, not plain python"
18
+ ) from exc
19
+
20
+ from shadowbox.api import app
21
+
22
+ Default = asgi.entrypoint(app)
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ShadowBox Studio</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "shadowbox-studio",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "cytoscape": "^3.30.0",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/cytoscape": "^3.14.0",
18
+ "@types/react": "^19.0.0",
19
+ "@types/react-dom": "^19.0.0",
20
+ "@vitejs/plugin-react": "^4.0.0",
21
+ "typescript": "^5.6.0",
22
+ "vite": "^5.0.0"
23
+ }
24
+ }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { createModel, runScenario, type Report, type SimEvent } from "./api";
3
+ import { MODEL, SCENARIOS } from "./fixtures";
4
+ import { activeFaults, failuresUpTo, renderGraph } from "./graph";
5
+ import type cytoscape from "cytoscape";
6
+
7
+ export default function App() {
8
+ const [base, setBase] = useState("http://127.0.0.1:8000");
9
+ const [scenarioName, setScenarioName] = useState("db-down");
10
+ const [baseline, setBaseline] = useState<Report | null>(null);
11
+ const [report, setReport] = useState<Report | null>(null);
12
+ const [events, setEvents] = useState<SimEvent[]>([]);
13
+ const [t, setT] = useState(0);
14
+ const [error, setError] = useState("");
15
+ const [busy, setBusy] = useState(false);
16
+ const cyRef = useRef<cytoscape.Core | null>(null);
17
+ const boxRef = useRef<HTMLDivElement>(null);
18
+
19
+ const scenario = SCENARIOS.find((s) => s.name === scenarioName) ?? SCENARIOS[0];
20
+
21
+ async function run() {
22
+ setBusy(true);
23
+ setError("");
24
+ try {
25
+ const modelId = await createModel(base, MODEL);
26
+ const baseRes = await runScenario(base, modelId, SCENARIOS[0], 42);
27
+ const cur = await runScenario(base, modelId, scenario, 42);
28
+ setBaseline(baseRes.report);
29
+ setReport(cur.report);
30
+ setEvents(cur.events);
31
+ setT(0);
32
+ } catch (e) {
33
+ setError(e instanceof Error ? e.message : String(e));
34
+ } finally {
35
+ setBusy(false);
36
+ }
37
+ }
38
+
39
+ useEffect(() => {
40
+ if (!boxRef.current || !report) return;
41
+ cyRef.current?.destroy();
42
+ const timeouts: Record<string, number> = {};
43
+ for (const [id, c] of Object.entries(report.metrics.components)) timeouts[id] = c.timeouts;
44
+ cyRef.current = renderGraph(boxRef.current, MODEL.components, MODEL.connections, failuresUpTo(events, t * 1000), timeouts);
45
+ return () => cyRef.current?.destroy();
46
+ }, [report, events, t]);
47
+
48
+ const m = report?.metrics;
49
+ const verdict = baseline && m ? (m.error_rate > baseline.metrics.error_rate ? "REGRESSION" : "pass") : "-";
50
+
51
+ return (
52
+ <main style={{ fontFamily: "system-ui", maxWidth: 900, margin: "2rem auto", padding: "0 1rem" }}>
53
+ <h1>ShadowBox Studio</h1>
54
+ <p>What-if simulation. Results are illustrative, never production measurements.</p>
55
+ <label>API base <input value={base} onChange={(e) => setBase(e.target.value)} size={30} /></label>{" "}
56
+ <label>Scenario{" "}
57
+ <select value={scenarioName} onChange={(e) => setScenarioName(e.target.value)}>
58
+ {SCENARIOS.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
59
+ </select>
60
+ </label>{" "}
61
+ <button onClick={run} disabled={busy}>{busy ? "Running..." : "Run vs baseline"}</button>
62
+ {error && <pre style={{ color: "red" }}>{error}</pre>}
63
+ {m && (
64
+ <>
65
+ <p>error_rate={m.error_rate.toFixed(3)} p99={m.latency_ms.p99}ms verdict={verdict} hash={report?.metrics_hash.slice(0, 12)}</p>
66
+ <div ref={boxRef} style={{ width: "100%", height: 320, border: "1px solid #ccc" }} />
67
+ <label>t={t}s <input type="range" min={0} max={scenario.duration_s} value={t} onChange={(e) => setT(Number(e.target.value))} /></label>
68
+ <pre>active_faults=[{activeFaults(scenario.faults, t).join(", ") || "none"}]</pre>
69
+ </>
70
+ )}
71
+ </main>
72
+ );
73
+ }
@@ -0,0 +1,86 @@
1
+ export interface Latency {
2
+ p50: number;
3
+ p90: number;
4
+ p95: number;
5
+ p99: number;
6
+ }
7
+
8
+ export interface ComponentMetrics {
9
+ utilization: number;
10
+ queue_depth: number;
11
+ timeouts: number;
12
+ }
13
+
14
+ export interface Metrics {
15
+ throughput_rps: number;
16
+ error_rate: number;
17
+ latency_ms: Latency;
18
+ timeouts: number;
19
+ retries: number;
20
+ cascade_depth: number;
21
+ components: Record<string, ComponentMetrics>;
22
+ }
23
+
24
+ export interface Report {
25
+ metrics: Metrics;
26
+ metrics_hash: string;
27
+ seed: number;
28
+ confidence: string;
29
+ calibration_source: string;
30
+ }
31
+
32
+ export interface SimEvent {
33
+ correlation_id: string;
34
+ ok: boolean;
35
+ latency_ms: number;
36
+ failed_at: string | null;
37
+ timed_out: boolean;
38
+ arrival_ms: number;
39
+ finish_ms: number;
40
+ }
41
+
42
+ export interface Fault {
43
+ target: string;
44
+ type: string;
45
+ start_s: number;
46
+ duration_s: number;
47
+ extra_ms?: number;
48
+ }
49
+
50
+ export interface Scenario {
51
+ name: string;
52
+ duration_s: number;
53
+ workload: { rate_rps: number };
54
+ faults: Fault[];
55
+ }
56
+
57
+ async function postJson(base: string, path: string, body: unknown, query = ""): Promise<unknown> {
58
+ const res = await fetch(`${base}${path}${query}`, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify(body)
62
+ });
63
+ if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
64
+ return res.json() as Promise<unknown>;
65
+ }
66
+
67
+ export async function createModel(base: string, model: unknown): Promise<string> {
68
+ const out = (await postJson(base, "/api/v1/models", model)) as { id: string };
69
+ return out.id;
70
+ }
71
+
72
+ export async function runScenario(
73
+ base: string,
74
+ modelId: string,
75
+ scenario: Scenario,
76
+ seed: number
77
+ ): Promise<{ simId: string; report: Report; events: SimEvent[] }> {
78
+ const sim = (await postJson(base, "/api/v1/simulations", { scenario, seed }, `?model_id=${modelId}`)) as {
79
+ id: string;
80
+ };
81
+ const report = (await (await fetch(`${base}/api/v1/simulations/${sim.id}/report`)).json()) as Report;
82
+ const page = (await (
83
+ await fetch(`${base}/api/v1/simulations/${sim.id}/events?limit=500`)
84
+ ).json()) as { events: SimEvent[] };
85
+ return { simId: sim.id, report, events: page.events };
86
+ }
@@ -0,0 +1,23 @@
1
+ export const MODEL = {
2
+ components: [
3
+ { id: "api", type: "service", capacity: 50, latency_ms: { base: 20, jitter_ms: 5 }, timeout_ms: 1000, queue_size: 200, queue_policy: "drop" },
4
+ { id: "cache", type: "cache", capacity: 100, latency_ms: { base: 2, jitter_ms: 1 }, timeout_ms: 200, queue_size: 500, queue_policy: "drop" },
5
+ { id: "database", type: "database", capacity: 20, latency_ms: { base: 5, jitter_ms: 1 }, timeout_ms: 500, queue_size: 100, queue_policy: "fifo" }
6
+ ],
7
+ connections: [
8
+ { from: "api", to: "cache" },
9
+ { from: "api", to: "database" },
10
+ { from: "cache", to: "database" }
11
+ ]
12
+ };
13
+
14
+ export const SCENARIOS = [
15
+ { name: "baseline", duration_s: 60, workload: { rate_rps: 100 }, faults: [] },
16
+ { name: "db-down", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "database", type: "unavailable", start_s: 20, duration_s: 10 }] },
17
+ { name: "cache-poison", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "cache", type: "unavailable", start_s: 20, duration_s: 10 }] },
18
+ { name: "latency-500ms", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "database", type: "latency", extra_ms: 500, start_s: 0, duration_s: 60 }] },
19
+ { name: "traffic-10x", duration_s: 60, workload: { rate_rps: 1000 }, faults: [] },
20
+ { name: "zone-loss", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "cache", type: "unavailable", start_s: 20, duration_s: 20 }, { target: "database", type: "unavailable", start_s: 20, duration_s: 20 }] },
21
+ { name: "slow-dependency", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "cache", type: "latency", extra_ms: 100, start_s: 0, duration_s: 60 }] },
22
+ { name: "queue-overflow", duration_s: 10, workload: { rate_rps: 3000 }, faults: [] }
23
+ ];
@@ -0,0 +1,45 @@
1
+ import cytoscape from "cytoscape";
2
+ import type { SimEvent } from "./api";
3
+
4
+ export type NodeStatus = "ok" | "failing" | "degraded";
5
+
6
+ export function renderGraph(
7
+ container: HTMLElement,
8
+ components: { id: string; type: string }[],
9
+ connections: { from: string; to: string }[],
10
+ failures: Record<string, number>,
11
+ timeouts: Record<string, number>
12
+ ): cytoscape.Core {
13
+ const nodes = components.map((c) => {
14
+ const fails = failures[c.id] ?? 0;
15
+ const status: NodeStatus = fails > 0 ? "failing" : (timeouts[c.id] ?? 0) > 0 ? "degraded" : "ok";
16
+ return { data: { id: c.id, label: `${c.id}\n${c.type} fails:${fails}`, status } };
17
+ });
18
+ const edges = connections.map((c, i) => ({ data: { id: `e${i}`, source: c.from, target: c.to } }));
19
+ return cytoscape({
20
+ container,
21
+ elements: [...nodes, ...edges],
22
+ style: [
23
+ { selector: "node", style: { label: "data(label)", color: "#fff", "text-valign": "center", "text-halign": "center", "font-size": 11 } },
24
+ { selector: 'node[status="ok"]', style: { "background-color": "#2e7d32" } },
25
+ { selector: 'node[status="failing"]', style: { "background-color": "#c62828" } },
26
+ { selector: 'node[status="degraded"]', style: { "background-color": "#ef6c00" } },
27
+ { selector: "edge", style: { width: 2, "line-color": "#888", "target-arrow-shape": "triangle", "target-arrow-color": "#888" } }
28
+ ],
29
+ layout: { name: "breadthfirst", directed: true, padding: 30 }
30
+ });
31
+ }
32
+
33
+ export function failuresUpTo(events: SimEvent[], tMs: number): Record<string, number> {
34
+ const out: Record<string, number> = {};
35
+ for (const e of events) {
36
+ if (e.finish_ms <= tMs && !e.ok && e.failed_at) out[e.failed_at] = (out[e.failed_at] ?? 0) + 1;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export function activeFaults(faults: { target: string; type: string; start_s: number; duration_s: number }[], tS: number): string[] {
42
+ return faults
43
+ .filter((f) => f.start_s <= tS && tS < f.start_s + f.duration_s)
44
+ .map((f) => `${f.type}@${f.target}`);
45
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App";
4
+
5
+ createRoot(document.getElementById("root") as HTMLElement).render(
6
+ <React.StrictMode>
7
+ <App />
8
+ </React.StrictMode>
9
+ );
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "noUnusedLocals": true,
10
+ "skipLibCheck": true,
11
+ "types": ["vite/client"]
12
+ },
13
+ "include": ["src", "vite.config.ts"]
14
+ }