diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5f2c6e..a973d7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,14 @@ jobs: working-directory: frontend run: npm ci + - name: Lint + working-directory: frontend + run: npm run lint + + - name: Format check + working-directory: frontend + run: npm run format:check + - name: Type-check and build working-directory: frontend run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6dffa..5b551fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [Unreleased] + +### Added +- `GET /history/export.json` — full history export in JSON format with metadata and optional filters (source, success, since, until, limit) +- `GET /history/export.csv` — full history export in CSV format for spreadsheet tooling +- Export JSON / Export CSV buttons in Historical Dashboard (EN-US / PT-BR) +- Grafana + Prometheus optional observability pack (`docker-compose.observability.yml`, `observability/`) +- Pre-built Grafana dashboard `nodescope-overview.json` with panels for all 30+ real NodeScope metrics +- `make observability-up` and `make observability-down` Makefile targets +- ESLint (v9 flat config) + Prettier for frontend — `npm run lint`, `npm run format:check` +- CI now runs `npm run lint` and `npm run format:check` in the frontend job + +### Changed +- Python unit test count updated to 80 (71 prior + 9 new export tests) +- README and documentation updated to reflect new endpoints and observability pack + +--- + ## [1.1.0] — 2026-05-07 **Professional Lab release.** Major expansion from the v1.0.x observability dashboard into a @@ -144,7 +162,7 @@ instrumentation, and a complete bilingual interface. - ROADMAP.md — restructured as Implemented / In Progress / Planned / Deferred - docs/api.md — complete rewrite documenting all 43 endpoints, authentication, and Prometheus metrics - PROJECT_STATUS.md — capabilities table and roadmap updated to reflect v1.1.0 delivery -- Tests: 54 unit tests (38 prior + 16 storage tests for `api/storage.py`) +- Tests: 71 unit tests as of v1.1.0 (38 prior + 16 storage + 17 fee_service) --- diff --git a/Makefile b/Makefile index d850555..9ed3c8a 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ COMPOSE_FILE ?= docker-compose.yml export COMPOSE_FILE COMPOSE ?= docker compose -.PHONY: help setup setup-local venv backend monitor frontend test test-local build build-local smoke smoke-local demo replay-demo screenshots clean public-clean docker-up docker-down docker-demo docker-full-demo docker-wait lint docker-config benchmark load-smoke +.PHONY: help setup setup-local venv backend monitor frontend test test-local build build-local smoke smoke-local demo replay-demo screenshots clean public-clean docker-up docker-down docker-demo docker-full-demo docker-wait lint docker-config benchmark load-smoke observability-up observability-down help: @echo "NodeScope Makefile - Available Targets" @@ -48,6 +48,10 @@ help: @echo "" @echo "Maintenance:" @echo " make clean Remove generated local artifacts" + @echo "" + @echo "Observability (optional):" + @echo " make observability-up Start Prometheus + Grafana" + @echo " make observability-down Stop Prometheus + Grafana" setup: setup-local @@ -147,6 +151,12 @@ benchmark: load-smoke: python3 scripts/load_smoke.py --concurrency 5 --requests 100 +observability-up: + docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d + +observability-down: + docker compose -f docker-compose.observability.yml down + clean: find . -type d -name __pycache__ -not -path "./.venv/*" -exec rm -rf {} + 2>/dev/null || true find . -name "*.pyc" -not -path "./.venv/*" -delete 2>/dev/null || true diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index fc5d5fc..ffd5739 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -7,7 +7,7 @@ Release: v1.1.0 Docker quickstart: validated Smoke test: passing (PASS=15 FAIL=0 WARN=0) Frontend build: passing (TypeScript + Vite) -Python tests: passing (54 unit tests — 38 prior + 16 storage) +Python tests: passing (80 unit tests — see CI for current breakdown) CI: passing (GitHub Actions — backend, frontend Node 18/20/24, public-clean check) ## Official Evaluator Flow @@ -123,6 +123,8 @@ Results vary by environment. Numbers should be validated locally, not assumed fr | GET | `/history/demo-runs` | Paginated demo run history | | GET | `/history/policy-runs` | Paginated policy run history (optional `scenario_id` filter) | | GET | `/history/reorg-runs` | Paginated reorg run history | +| GET | `/history/export.json` | Full history export as downloadable JSON (filters: `source`, `success`, `since`, `until`, `limit`) | +| GET | `/history/export.csv` | Full history export as downloadable CSV | Storage backend is selected via environment variables: @@ -164,11 +166,13 @@ If SQLite initialisation fails, the API transparently falls back to an in-memory | Feature | Status | |---|---| | Presentation Pack | Ready (PR #9) | +| History export CSV/JSON | Ready | +| Grafana + Prometheus observability pack | Ready | +| Frontend ESLint + Prettier | Ready | | Postgres / TimescaleDB for event persistence | Planned | | API keys for mutating endpoints (optional) | Ready (PR #8) | | OpenTelemetry traces (RPC, ZMQ, API) | Planned | | Multi-node support | Planned | | Kubernetes manifests / Helm chart | Planned | -| Grafana integration | Planned | | signet / mainnet read-only mode | Planned | | Cluster mempool visualization (Bitcoin Core 28+) | Planned | diff --git a/README.md b/README.md index 41b71b8..8bd145c 100644 --- a/README.md +++ b/README.md @@ -528,7 +528,9 @@ Output: latency table (min/mean/median/p95/max) per endpoint. Results vary by ho | API keys for mutating endpoints (optional) | Ready | | OpenTelemetry traces | Planned | | Kubernetes manifests / Helm chart | Planned | -| Grafana integration | Planned | +| Grafana + Prometheus observability pack | Ready (`docker-compose.observability.yml`) | +| History export CSV/JSON | Ready (`/history/export.json`, `/history/export.csv`) | +| Frontend lint + format (ESLint + Prettier) | Ready | | Fee Estimation Playground (`estimatesmartfee`) | Ready | --- diff --git a/README.pt-BR.md b/README.pt-BR.md index 7b02afc..fa7549c 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -527,7 +527,9 @@ Saída: tabela de latência (min/média/mediana/p95/max) por endpoint. Os result | API keys para endpoints mutantes (opcional) | Pronto | | OpenTelemetry traces | Planejado | | Kubernetes manifests / Helm chart | Planejado | -| Integração com Grafana | Planejado | +| Pack Grafana + Prometheus | Pronto (`docker-compose.observability.yml`) | +| Exportação de histórico CSV/JSON | Pronto (`/history/export.json`, `/history/export.csv`) | +| Lint + format frontend (ESLint + Prettier) | Pronto | | Playground de Estimativa de Taxa (`estimatesmartfee`) | Pronto | --- diff --git a/ROADMAP.md b/ROADMAP.md index 0434937..786dda5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,8 +38,11 @@ Everything below is shipped and functional in the current release. | Bitcoin Glossary | 27 terms with EN-US and PT-BR definitions | | Reproducible Benchmark | `scripts/benchmark_nodescope.py` — latency table per endpoint | | Load Smoke Test | `scripts/load_smoke.py` — concurrent load against all read-only endpoints | -| 54 unit tests + CI | Python 3.12 lint/test/audit; Node 18/20/24 build; public-clean check | +| 80 unit tests + CI | Python 3.12 lint/test/audit; Node 18/20/24 build, lint, format check; public-clean check | | Presentation Pack | 10 documents: pitches, demo scripts, evaluator checklist, FAQ, submission text | +| History export CSV/JSON | `GET /history/export.json`, `GET /history/export.csv`; export buttons in Historical Dashboard | +| Grafana + Prometheus observability pack | Optional `docker-compose.observability.yml`; pre-built dashboard with 30+ real metrics | +| Frontend lint + format | ESLint v9 flat config + Prettier; `npm run lint`, `npm run format:check` | --- diff --git a/api/app.py b/api/app.py index 9f02533..ca87721 100644 --- a/api/app.py +++ b/api/app.py @@ -1,6 +1,9 @@ from __future__ import annotations +import csv import datetime +import io +import json import os import time from contextlib import asynccontextmanager @@ -554,6 +557,100 @@ def history_reorg_runs( return {"items": items, "total_returned": len(items), "limit": limit, "offset": offset} +@app.get("/history/export.json") +def history_export_json( + source: str | None = Query(default=None), + success: bool | None = Query(default=None), + since: str | None = Query(default=None), + until: str | None = Query(default=None), + limit: int = Query(default=1000, ge=1, le=10000), +) -> Response: + payload = history_service.build_export_payload( + source=source, success=success, since=since, until=until, limit=limit + ) + content = json.dumps(payload, indent=2, default=str) + return Response( + content=content, + media_type="application/json", + headers={"Content-Disposition": 'attachment; filename="nodescope-history.json"'}, + ) + + +@app.get("/history/export.csv") +def history_export_csv( + source: str | None = Query(default=None), + success: bool | None = Query(default=None), + since: str | None = Query(default=None), + until: str | None = Query(default=None), + limit: int = Query(default=1000, ge=1, le=10000), +) -> Response: + payload = history_service.build_export_payload( + source=source, success=success, since=since, until=until, limit=limit + ) + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow( + ["table", "id", "source", "scenario_id", "status", "success", "txid", "created_at"] + ) + for row in payload.get("proof_reports", []): + writer.writerow( + [ + "proof_report", + row.get("id"), + row.get("source"), + "", + row.get("status"), + row.get("success"), + row.get("txid"), + row.get("created_at"), + ] + ) + for row in payload.get("demo_runs", []): + writer.writerow( + [ + "demo_run", + row.get("id"), + "demo", + "", + row.get("status"), + row.get("success"), + row.get("txid"), + row.get("created_at"), + ] + ) + for row in payload.get("policy_runs", []): + writer.writerow( + [ + "policy_run", + row.get("id"), + "policy", + row.get("scenario_id"), + row.get("status"), + row.get("success"), + "", + row.get("created_at"), + ] + ) + for row in payload.get("reorg_runs", []): + writer.writerow( + [ + "reorg_run", + row.get("id"), + "reorg", + "", + row.get("status"), + row.get("success"), + row.get("txid"), + row.get("created_at"), + ] + ) + return Response( + content=buf.getvalue(), + media_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="nodescope-history.csv"'}, + ) + + # --------------------------------------------------------------------------- # Fee Estimation Playground — read-only endpoints # --------------------------------------------------------------------------- diff --git a/api/history_service.py b/api/history_service.py index bd1ef38..c6271d9 100644 --- a/api/history_service.py +++ b/api/history_service.py @@ -7,6 +7,7 @@ from __future__ import annotations +import datetime import json from typing import Any @@ -146,3 +147,71 @@ def _format_reorg_run(row: dict[str, Any]) -> dict[str, Any]: "proof_report_id": row.get("proof_report_id"), "created_at": row.get("created_at"), } + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + + +def _apply_date_filter( + rows: list[dict[str, Any]], since: str | None, until: str | None +) -> list[dict[str, Any]]: + if not since and not until: + return rows + result = [] + for row in rows: + created = row.get("created_at") or "" + if since and created < since: + continue + if until and created > until: + continue + result.append(row) + return result + + +def build_export_payload( + *, + source: str | None = None, + success: bool | None = None, + since: str | None = None, + until: str | None = None, + limit: int = 1000, +) -> dict[str, Any]: + summary = get_history_summary() + + proofs = get_proof_reports(limit=limit, offset=0, source=source, success=success) + demos = get_demo_runs(limit=limit, offset=0) + policies = get_policy_runs(limit=limit, offset=0) + reorgs = get_reorg_runs(limit=limit, offset=0) + + proofs = _apply_date_filter(proofs, since, until) + demos = _apply_date_filter(demos, since, until) + policies = _apply_date_filter(policies, since, until) + reorgs = _apply_date_filter(reorgs, since, until) + + return { + "metadata": { + "generated_at": datetime.datetime.now(tz=datetime.UTC).isoformat(), + "project": "NodeScope", + "version": "1.1.x", + "storage_backend": summary.get("storage_backend", "unknown"), + "counts": { + "proof_reports": len(proofs), + "demo_runs": len(demos), + "policy_runs": len(policies), + "reorg_runs": len(reorgs), + }, + "filters": { + "source": source, + "success": success, + "since": since, + "until": until, + "limit": limit, + }, + }, + "proof_reports": proofs, + "demo_runs": demos, + "policy_runs": policies, + "reorg_runs": reorgs, + } diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml new file mode 100644 index 0000000..e27d75a --- /dev/null +++ b/docker-compose.observability.yml @@ -0,0 +1,37 @@ +name: nodescope + +services: + nodescope-prometheus: + image: prom/prometheus:latest + container_name: nodescope-prometheus + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - nodescope-prometheus-data:/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.console.libraries=/etc/prometheus/console_libraries + - --web.console.templates=/etc/prometheus/consoles + - --web.enable-lifecycle + + nodescope-grafana: + image: grafana/grafana:latest + container_name: nodescope-grafana + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro + - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro + - nodescope-grafana-data:/var/lib/grafana + environment: + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + GF_SECURITY_ALLOW_EMBEDDING: "true" + +volumes: + nodescope-prometheus-data: + nodescope-grafana-data: diff --git a/docs/api.md b/docs/api.md index 1aca3d9..5d5dd86 100644 --- a/docs/api.md +++ b/docs/api.md @@ -787,6 +787,34 @@ Paginated reorg run history. --- +### GET /history/export.json + +Full history export as a downloadable JSON file. Includes all proof reports, demo runs, policy runs, and reorg runs with metadata. + +**Query parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `source` | string | — | Filter by source (`demo`, `policy`, etc.) | +| `success` | boolean | — | Filter by outcome (`true` or `false`) | +| `since` | string | — | ISO date lower bound (`created_at >= since`) | +| `until` | string | — | ISO date upper bound (`created_at <= until`) | +| `limit` | integer | 1000 | Max rows per table (max 10000) | + +**Response — 200 OK:** `application/json` with `Content-Disposition: attachment; filename="nodescope-history.json"` + +--- + +### GET /history/export.csv + +Full history export as a downloadable CSV file. All tables (proof_report, demo_run, policy_run, reorg_run) are flattened into a single CSV with a `table` column. + +Accepts the same query parameters as `/history/export.json`. + +**Response — 200 OK:** `text/csv` with `Content-Disposition: attachment; filename="nodescope-history.csv"` + +--- + ## Fee Estimation ### GET /fees/estimate diff --git a/docs/observability-grafana.md b/docs/observability-grafana.md new file mode 100644 index 0000000..08f46bd --- /dev/null +++ b/docs/observability-grafana.md @@ -0,0 +1,118 @@ +# NodeScope — Grafana + Prometheus Observability Pack + +Optional monitoring stack for NodeScope using Prometheus and Grafana. + +--- + +## Overview + +The observability pack adds two services on top of the standard NodeScope stack: + +- **Prometheus** — scrapes `/metrics` from the NodeScope API every 15 seconds. +- **Grafana** — provides a pre-built dashboard with panels for all NodeScope Prometheus metrics. + +This is entirely optional. The standard demo works without it. + +--- + +## Requirements + +- Docker and Docker Compose installed. +- NodeScope main stack already running (or started together — see below). + +--- + +## Start Together + +```bash +docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d --build +``` + +## Start Observability Separately (after main stack is running) + +```bash +docker compose -f docker-compose.observability.yml up -d +``` + +## Validate + +```bash +curl http://127.0.0.1:9090/-/ready # Prometheus ready +curl http://127.0.0.1:3000/api/health # Grafana healthy +``` + +## Open Grafana + +``` +http://localhost:3000 +``` + +Default: anonymous viewer access. No login required. + +Navigate to **Dashboards → NodeScope Overview**. + +--- + +## Stop + +```bash +docker compose -f docker-compose.observability.yml down +``` + +## Stop and remove data volumes + +```bash +docker compose -f docker-compose.observability.yml down -v +``` + +--- + +## Makefile Targets + +```bash +make observability-up # Start Prometheus + Grafana +make observability-down # Stop Prometheus + Grafana +``` + +--- + +## Dashboard Panels + +The pre-built `nodescope-overview.json` dashboard includes panels for: + +| Panel | Metric(s) | +|---|---| +| Bitcoin Core RPC status | `nodescope_rpc_up` | +| Storage backend status | `nodescope_storage_up` | +| Chain height | `nodescope_chain_height` | +| Mempool transactions | `nodescope_mempool_tx_count` | +| Mempool vsize | `nodescope_mempool_vsize_bytes` | +| HTTP request rate | `nodescope_http_requests_total` | +| HTTP latency (p50/p95/p99) | `nodescope_http_request_duration_seconds` | +| ZMQ event rate | `nodescope_zmq_rawtx_events_total`, `nodescope_zmq_rawblock_events_total` | +| Demo runs | `nodescope_demo_runs_total` | +| Policy scenario runs | `nodescope_policy_scenarios_total` | +| Reorg lab runs | `nodescope_reorg_runs_total` | +| Proof reports generated | `nodescope_proof_reports_total` | +| Persisted records (SQLite) | `nodescope_history_*_total` | +| Fee estimation runs | `nodescope_fee_estimation_runs_total` | +| RPC request rate + latency | `nodescope_rpc_requests_total`, `nodescope_rpc_latency_seconds` | + +All panels use only metrics that are actually exported by the NodeScope API. + +--- + +## Prometheus Scrape Target + +Prometheus scrapes `nodescope-api:8000/metrics` using the internal Docker network. +When the stacks share the same `name: nodescope` compose project, the network is shared automatically. + +If you run the stacks separately, ensure both are on the same Docker network or adjust `prometheus.yml` accordingly. + +--- + +## Notes + +- Anonymous access is enabled in Grafana for evaluator convenience. For production use, disable `GF_AUTH_ANONYMOUS_ENABLED`. +- Data volumes (`nodescope-prometheus-data`, `nodescope-grafana-data`) persist metrics history across restarts. +- The NodeScope main stack is not modified by this pack — it is entirely additive. diff --git a/docs/presentation/README.md b/docs/presentation/README.md index 4400921..4234baf 100644 --- a/docs/presentation/README.md +++ b/docs/presentation/README.md @@ -46,7 +46,7 @@ Open `http://localhost:5173` → click "Run Full Demo" in Guided Demo. | Docker services | 4 (bitcoind, api, monitor, frontend) | | Guided Demo steps | 14 | | Smoke tests | PASS=15 FAIL=0 | -| Python unit tests | 54 | +| Python unit tests | 80 | | Prometheus metrics | 28+ | | i18n | PT-BR / EN-US | | Cluster mempool (BC28+ RPCs) | Unavailable on BC26 (documented) | diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..681d9b0 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +*.json diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..af88520 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..b75733b --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,40 @@ +import js from '@eslint/js' +import tsPlugin from '@typescript-eslint/eslint-plugin' +import tsParser from '@typescript-eslint/parser' +import prettier from 'eslint-config-prettier' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import globals from 'globals' + +export default [ + { ignores: ['dist', 'node_modules'] }, + js.configs.recommended, + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + globals: { + ...globals.browser, + ...globals.es2021, + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-undef': 'off', + }, + }, + prettier, +] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf7c008..e3de711 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,10 +12,19 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@eslint/js": "^9.17.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2", "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "prettier": "^3.4.2", "typescript": "^5.6.2", + "typescript-eslint": "^8.18.2", "vite": "^6.0.5" } }, @@ -698,6 +707,254 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1121,6 +1378,12 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1146,141 +1409,523 @@ "@types/react": "^18.0.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" }, "engines": { - "node": ">=6.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "dependencies": { - "ms": "^2.1.3" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=6.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.349", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", - "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", - "dev": true + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", @@ -1316,6 +1961,276 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1333,6 +2248,53 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1356,11 +2318,117 @@ "node": ">=6.9.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1373,6 +2441,24 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1385,6 +2471,49 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1405,6 +2534,21 @@ "yallist": "^3.0.2" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1429,12 +2573,95 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1481,6 +2708,39 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1513,6 +2773,15 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -1574,6 +2843,27 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1583,6 +2873,30 @@ "node": ">=0.10.0" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1599,6 +2913,30 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1612,6 +2950,29 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1642,6 +3003,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -1716,11 +3086,47 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/package.json b/frontend/package.json index cacabca..85fd99e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,17 +7,29 @@ "dev": "vite", "typecheck": "tsc -b", "build": "npm run typecheck && vite build", - "preview": "vite preview" + "preview": "vite preview", + "lint": "eslint src --ext .ts,.tsx", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@eslint/js": "^9.17.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2", "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "prettier": "^3.4.2", "typescript": "^5.6.2", + "typescript-eslint": "^8.18.2", "vite": "^6.0.5" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e83189..e44cee0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,7 +58,11 @@ export default function App() { const [activeView, setActiveView] = useState('dashboard') const [inspectorTxid, setInspectorTxid] = useState('') const [guidedDemoSteps, setGuidedDemoSteps] = useState([]) - const { events: sseEvents, connected: sseConnected, clearEvents: clearSseEvents } = useSSE('/events/stream') + const { + events: sseEvents, + connected: sseConnected, + clearEvents: clearSseEvents, + } = useSSE('/events/stream') // i18n state const [lang, setLangState] = useState(getStoredLang) @@ -91,10 +95,14 @@ export default function App() { if (b.status === 'fulfilled') setLatestBlock(b.value) if (tx.status === 'fulfilled') setLatestTx(tx.value) if (intel.status === 'fulfilled') setIntelligence(intel.value) - } catch { /* ignore */ } + } catch { + /* ignore */ + } } - useEffect(() => { void fetchAll() }, []) + useEffect(() => { + void fetchAll() + }, []) useInterval(fetchAll, 5000) const network = health?.chain ?? health?.network ?? 'regtest' @@ -110,7 +118,9 @@ export default function App() { if (!window.confirm(t.header.newSessionConfirm)) return try { await api.sessionReset() - } catch { /* ignore */ } + } catch { + /* ignore */ + } clearSseEvents() setEvents([]) setClassifications([]) @@ -129,10 +139,14 @@ export default function App() { apiOk={apiOk} rpcOk={rpcOk} sseConnected={sseConnected} - onRefresh={() => { void fetchAll() }} + onRefresh={() => { + void fetchAll() + }} activeView={activeView} onSetView={setActiveView} - onNewSession={() => { void handleNewSession() }} + onNewSession={() => { + void handleNewSession() + }} /> ) @@ -141,29 +155,33 @@ export default function App() { {activeView === 'guided-demo' ? (
{header} -
+
{/* Left: scrollable steps list */}
{/* Right: fixed sidebar — lifecycle + explain */} -
+
- setActiveView('dashboard')} /> + setActiveView('dashboard')} + />
@@ -247,11 +268,7 @@ export default function App() {
- +
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 38280b4..e775ced 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,5 @@ async function get(path: string): Promise { - const res = await fetch(path) // path is relative; Vite proxy handles routing + const res = await fetch(path) // path is relative; Vite proxy handles routing if (!res.ok) throw new Error(`${path}: ${res.status}`) return res.json() as Promise } @@ -14,8 +14,10 @@ export const api = { health: () => get('/health'), summary: () => get('/summary'), mempool: () => get('/mempool/summary'), - recentEvents: (limit = 20) => get(`/events/recent?limit=${limit}`), - classifications: (limit = 20) => get(`/events/classifications?limit=${limit}`), + recentEvents: (limit = 20) => + get(`/events/recent?limit=${limit}`), + classifications: (limit = 20) => + get(`/events/classifications?limit=${limit}`), latestBlock: () => get('/blocks/latest'), latestTx: () => get('/tx/latest'), txById: (txid: string) => get(`/tx/${txid}`), @@ -24,7 +26,9 @@ export const api = { txInspect: (txid: string) => get(`/tx/inspect/${txid}`), // ZMQ Event Tape eventTape: (limit = 50, topic?: string) => - get(`/events/tape?limit=${limit}${topic ? `&topic=${topic}` : ''}`), + get( + `/events/tape?limit=${limit}${topic ? `&topic=${topic}` : ''}` + ), eventTapeByTxid: (txid: string) => get(`/events/tape/${txid}`), // Guided Demo @@ -46,15 +50,19 @@ export const api = { reorgReset: () => post('/reorg/reset'), reorgProof: () => get<{ proof: import('../types/api').ReorgProof | null }>('/reorg/proof'), // Cluster Mempool Compatibility - clusterCompatibility: () => get('/mempool/cluster/compatibility'), + clusterCompatibility: () => + get('/mempool/cluster/compatibility'), // Simulation simulationStatus: () => get('/simulation/status'), simulationStart: () => post('/simulation/start'), simulationStop: () => post('/simulation/stop'), simulationConfig: (blockInterval?: number, txInterval?: number) => { const body = JSON.stringify({ block_interval: blockInterval, tx_interval: txInterval }) - return fetch('/simulation/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body }) - .then(r => r.json() as Promise) + return fetch('/simulation/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body, + }).then((r) => r.json() as Promise) }, // Session sessionReset: () => post<{ ok: boolean; truncated: boolean; file: string }>('/session/reset'), @@ -64,17 +72,25 @@ export const api = { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }) if (source) params.set('source', source) if (success !== undefined) params.set('success', String(success)) - return get>(`/history/proofs?${params}`) + return get< + import('../types/api').HistoryListResponse + >(`/history/proofs?${params}`) }, historyDemoRuns: (limit = 20, offset = 0) => - get>(`/history/demo-runs?limit=${limit}&offset=${offset}`), + get>( + `/history/demo-runs?limit=${limit}&offset=${offset}` + ), historyPolicyRuns: (limit = 20, offset = 0, scenario?: string) => { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }) if (scenario) params.set('scenario', scenario) - return get>(`/history/policy-runs?${params}`) + return get< + import('../types/api').HistoryListResponse + >(`/history/policy-runs?${params}`) }, historyReorgRuns: (limit = 20, offset = 0) => - get>(`/history/reorg-runs?limit=${limit}&offset=${offset}`), + get>( + `/history/reorg-runs?limit=${limit}&offset=${offset}` + ), // Fee Estimation Playground feesEstimate: (mode?: string) => get(`/fees/estimate${mode ? `?mode=${mode}` : ''}`), diff --git a/frontend/src/components/AlertingPanel.tsx b/frontend/src/components/AlertingPanel.tsx index f43c307..8f0eaca 100644 --- a/frontend/src/components/AlertingPanel.tsx +++ b/frontend/src/components/AlertingPanel.tsx @@ -63,7 +63,7 @@ export default function AlertingPanel() { // --- Simulation errors --- try { - const sim = await api.simulationStatus() as SimulationData + const sim = (await api.simulationStatus()) as SimulationData if (sim.errors > 0) { newAlerts.push({ id: 'sim_errors', @@ -123,19 +123,26 @@ export default function AlertingPanel() { marginBottom: 18, }} > -
+
{t.alerts.title.toUpperCase()} {lastCheck && ( - - {lastCheck.toLocaleTimeString()} - + {lastCheck.toLocaleTimeString()} )}
{allGood ? ( -
+
{t.alerts.allGood}
@@ -166,7 +173,9 @@ export default function AlertingPanel() { {SEVERITY_ICON[alert.severity]}
-
+
- {t.dashboard.latestBlock} + + {t.dashboard.latestBlock} +
{!block ? ( @@ -43,13 +45,17 @@ export function BlocksPanel({ block }: Props) { ) : ( <>
- {t.generic.height} + + {t.generic.height} + {block.height ?? '—'}
- {t.generic.hash} + + {t.generic.hash} + {t.dashboard.copied}}
- {t.generic.time} - - {block.ts ? relTime(block.ts) : '—'} + + {t.generic.time} + {block.ts ? relTime(block.ts) : '—'}
)} diff --git a/frontend/src/components/ClassificationsTable.tsx b/frontend/src/components/ClassificationsTable.tsx index a605491..2ffde82 100644 --- a/frontend/src/components/ClassificationsTable.tsx +++ b/frontend/src/components/ClassificationsTable.tsx @@ -29,7 +29,9 @@ export function ClassificationsTable({ classifications }: Props) { return (
- {t.dashboard.classifications} + + {t.dashboard.classifications} + {classifications.length} {t.dashboard.items} @@ -77,7 +79,9 @@ export function ClassificationsTable({ classifications }: Props) { > {identifier}… - {copiedKey === identifierKey && {t.dashboard.copied}} + {copiedKey === identifierKey && ( + {t.dashboard.copied} + )} {reason ?? ''} - {copiedKey === reasonKey && {t.dashboard.copied}} + {copiedKey === reasonKey && ( + {t.dashboard.copied} + )}
) }) diff --git a/frontend/src/components/ClusterMempoolPanel.tsx b/frontend/src/components/ClusterMempoolPanel.tsx index 9b60066..1c79e3c 100644 --- a/frontend/src/components/ClusterMempoolPanel.tsx +++ b/frontend/src/components/ClusterMempoolPanel.tsx @@ -23,16 +23,40 @@ export function ClusterMempoolPanel() { setLoading(false) } - useEffect(() => { void fetchData() }, []) + useEffect(() => { + void fetchData() + }, []) return ( -
-
+
+
-
+
{t.cluster.title}
@@ -40,11 +64,18 @@ export function ClusterMempoolPanel() {
{error && ( -
+
{error}
)} @@ -251,32 +307,50 @@ export function FeeEstimationPlayground() { {/* Regtest warning */} {data?.network === 'regtest' && ( -
+
⚠ {t.fees.regtestWarning}
)} {/* General warnings */} - {data?.warnings && data.warnings.filter(w => w !== data.warnings[0] || data.network !== 'regtest').map((w, i) => ( - data.network !== 'regtest' && ( -
- ⚠ {w} -
- ) - ))} + {data?.warnings && + data.warnings + .filter((w) => w !== data.warnings[0] || data.network !== 'regtest') + .map( + (w, i) => + data.network !== 'regtest' && ( +
+ ⚠ {w} +
+ ) + )} {/* Estimates table */} {data && (
- estimatesmartfee - {' '}— {data.estimate_mode} + estimatesmartfee — {data.estimate_mode}
{data.estimates.length === 0 ? ( @@ -310,16 +384,25 @@ export function FeeEstimationPlayground() {
)} -

+

{t.fees.conversionNote}

)} {/* Visual bars */} - {data && data.estimates.some(e => e.feerate_sat_vb != null) && ( + {data && data.estimates.some((e) => e.feerate_sat_vb != null) && (
-
{t.fees.feerateSatVb} — {data.estimate_mode}
+
+ {t.fees.feerateSatVb} — {data.estimate_mode} +
{data.estimates.map((item, i) => ( ))} - {withComparison && data.compared_fee_rates.map((item, i) => ( - - ))} + {withComparison && + data.compared_fee_rates.map((item, i) => ( + + ))}
)} @@ -356,8 +440,12 @@ export function FeeEstimationPlayground() { {t.fees.comparisonSource} - {t.fees.feerateSatVb} - {t.fees.feerateBtcKvb} + + {t.fees.feerateSatVb} + + + {t.fees.feerateBtcKvb} + {t.fees.errors} @@ -376,12 +464,18 @@ export function FeeEstimationPlayground() { {/* Unavailable features */} {data && data.unavailable_features.length > 0 && ( -
- ℹ Unavailable:{' '} - {data.unavailable_features.join(' · ')} +
+ ℹ Unavailable: {data.unavailable_features.join(' · ')}
)} @@ -392,10 +486,16 @@ export function FeeEstimationPlayground() { {/* Metadata footer */} {data && ( -
+
{data.bitcoin_core_version && node: {data.bitcoin_core_version} · } network: {data.network ?? 'unknown'} · generated: {data.generated_at} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 87d394f..f6540b8 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -12,10 +12,26 @@ export function Footer() { { label: f.github, href: GITHUB_OWNER, external: true }, { label: f.repository, href: REPO, external: true }, { label: f.docs, href: 'https://github.com/btcneves/NodeScope/tree/main/docs', external: true }, - { label: f.projectStatus, href: 'https://github.com/btcneves/NodeScope/blob/main/PROJECT_STATUS.md', external: true }, - { label: f.roadmap, href: 'https://github.com/btcneves/NodeScope/blob/main/ROADMAP.md', external: true }, - { label: f.security, href: 'https://github.com/btcneves/NodeScope/blob/main/SECURITY.md', external: true }, - { label: f.license, href: 'https://github.com/btcneves/NodeScope/blob/main/LICENSE', external: true }, + { + label: f.projectStatus, + href: 'https://github.com/btcneves/NodeScope/blob/main/PROJECT_STATUS.md', + external: true, + }, + { + label: f.roadmap, + href: 'https://github.com/btcneves/NodeScope/blob/main/ROADMAP.md', + external: true, + }, + { + label: f.security, + href: 'https://github.com/btcneves/NodeScope/blob/main/SECURITY.md', + external: true, + }, + { + label: f.license, + href: 'https://github.com/btcneves/NodeScope/blob/main/LICENSE', + external: true, + }, { label: f.contact, href: CONTACT }, ] @@ -29,9 +45,7 @@ export function Footer() { key={link.label} href={link.href} style={styles.link} - {...(link.external - ? { target: '_blank', rel: 'noreferrer' } - : {})} + {...(link.external ? { target: '_blank', rel: 'noreferrer' } : {})} > {link.label} diff --git a/frontend/src/components/GuidedDemo.tsx b/frontend/src/components/GuidedDemo.tsx index d8b7b13..1f22b14 100644 --- a/frontend/src/components/GuidedDemo.tsx +++ b/frontend/src/components/GuidedDemo.tsx @@ -12,27 +12,29 @@ import { LearnMore } from './ui/LearnMore' function StatusBadge({ status }: { status: StepStatus }) { const { t } = useI18n() const map: Record = { - pending: { label: t.status.pending, color: '#6b7280' }, - running: { label: t.status.running, color: '#f59e0b' }, - success: { label: t.status.success, color: '#22c55e' }, - error: { label: t.status.error, color: '#ef4444' }, - unavailable: { label: t.status.unavailable, color: '#9ca3af' }, + pending: { label: t.status.pending, color: '#6b7280' }, + running: { label: t.status.running, color: '#f59e0b' }, + success: { label: t.status.success, color: '#22c55e' }, + error: { label: t.status.error, color: '#ef4444' }, + unavailable: { label: t.status.unavailable, color: '#9ca3af' }, experimental: { label: t.status.experimental, color: '#a78bfa' }, } const { label, color } = map[status] ?? { label: status, color: '#6b7280' } return ( - + {label} ) @@ -59,28 +61,47 @@ function StepRow({ const desc = (t.demo.stepDesc as Record)[step.id] return ( -
-
setExpanded(v => !v)}> +
+
setExpanded((v) => !v)} + > {index + 1}. - + {step.title} {desc && } - {expanded ? t.actions.collapse : t.actions.expand} + + {expanded ? t.actions.collapse : t.actions.expand} +
{expanded && ( @@ -115,16 +138,18 @@ function StepRow({
)} {step.technical_output !== null && step.technical_output !== undefined && ( -
+            
               {JSON.stringify(step.technical_output, null, 2)}
             
)} @@ -144,19 +169,22 @@ function ProofPanel({ proof }: { proof: DemoProof }) { const json = JSON.stringify(proof, null, 2) const copy = () => { - navigator.clipboard.writeText(json).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }).catch(() => { - const el = document.createElement('textarea') - el.value = json - document.body.appendChild(el) - el.select() - document.execCommand('copy') - document.body.removeChild(el) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) + navigator.clipboard + .writeText(json) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + .catch(() => { + const el = document.createElement('textarea') + el.value = json + document.body.appendChild(el) + el.select() + document.execCommand('copy') + document.body.removeChild(el) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) } const downloadJson = () => { @@ -170,9 +198,33 @@ function ProofPanel({ proof }: { proof: DemoProof }) { } return ( -
-
-
+
+
+
{t.demo.proofReport} {proof.success ? '✓' : '⚠'}
@@ -186,44 +238,108 @@ function ProofPanel({ proof }: { proof: DemoProof }) {
-
+
- - {t.proof.rpc}} value={proof.rpc_ok ? t.status.ok : t.status.fail} ok={proof.rpc_ok} /> - {t.proof.zmqRawtx}} value={proof.zmq_rawtx_ok ? t.status.ok : t.status.fail} ok={proof.zmq_rawtx_ok} /> - {t.proof.zmqRawblock}} value={proof.zmq_rawblock_ok ? t.status.ok : t.status.fail} ok={proof.zmq_rawblock_ok} /> + + {t.proof.rpc}} + value={proof.rpc_ok ? t.status.ok : t.status.fail} + ok={proof.rpc_ok} + /> + {t.proof.zmqRawtx}} + value={proof.zmq_rawtx_ok ? t.status.ok : t.status.fail} + ok={proof.zmq_rawtx_ok} + /> + {t.proof.zmqRawblock}} + value={proof.zmq_rawblock_ok ? t.status.ok : t.status.fail} + ok={proof.zmq_rawblock_ok} + /> {t.proof.wallet}} value={proof.wallet} /> - {t.proof.txid}} value={proof.txid ? proof.txid.slice(0, 16) + '…' : t.demo.na} /> - - {t.proof.fee}} value={proof.fee_btc !== null ? `${proof.fee_btc} BTC` : String(proof.fee_btc ?? t.demo.na)} /> - {t.proof.vsize}} value={String(proof.vsize_vbytes)} /> - {t.proof.weight}} value={String(proof.weight_wu)} /> - {t.proof.feeRate}} value={String(proof.fee_rate_sat_vb)} /> - {t.proof.blockHeight}} value={proof.block_height !== null ? String(proof.block_height) : t.demo.na} /> - {t.proof.confirmations}} value={String(proof.confirmations)} /> - {t.proof.mempoolSeen}} value={proof.mempool_seen ? t.demo.yes : t.demo.no} ok={proof.mempool_seen} /> - {t.proof.rawtxEvent}} value={proof.rawtx_event_seen ? t.demo.yes : t.status.pending} ok={proof.rawtx_event_seen} /> - {t.proof.rawblockEvent}} value={proof.rawblock_event_seen ? t.demo.yes : t.status.pending} ok={proof.rawblock_event_seen} /> + {t.proof.txid}} + value={proof.txid ? proof.txid.slice(0, 16) + '…' : t.demo.na} + /> + + {t.proof.fee}} + value={ + proof.fee_btc !== null ? `${proof.fee_btc} BTC` : String(proof.fee_btc ?? t.demo.na) + } + /> + {t.proof.vsize}} + value={String(proof.vsize_vbytes)} + /> + {t.proof.weight}} + value={String(proof.weight_wu)} + /> + {t.proof.feeRate}} + value={String(proof.fee_rate_sat_vb)} + /> + {t.proof.blockHeight}} + value={proof.block_height !== null ? String(proof.block_height) : t.demo.na} + /> + {t.proof.confirmations}} + value={String(proof.confirmations)} + /> + {t.proof.mempoolSeen}} + value={proof.mempool_seen ? t.demo.yes : t.demo.no} + ok={proof.mempool_seen} + /> + {t.proof.rawtxEvent}} + value={proof.rawtx_event_seen ? t.demo.yes : t.status.pending} + ok={proof.rawtx_event_seen} + /> + {t.proof.rawblockEvent}} + value={proof.rawblock_event_seen ? t.demo.yes : t.status.pending} + ok={proof.rawblock_event_seen} + />
{proof.warnings.length > 0 && (
{proof.warnings.map((w, i) => ( -
⚠ {w}
+
+ ⚠ {w} +
))}
)} {proof.unavailable_features.length > 0 && (
{proof.unavailable_features.map((f, i) => ( -
— {t.proof.unavailable} {f}
+
+ — {t.proof.unavailable} {f} +
))}
)} - - {t.learn.proof} - + {t.learn.proof}
) } @@ -277,7 +393,9 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep pollingRef.current = setInterval(() => { if (demoState?.running) void fetchStatus() }, POLL_INTERVAL_MS) - return () => { if (pollingRef.current) clearInterval(pollingRef.current) } + return () => { + if (pollingRef.current) clearInterval(pollingRef.current) + } }, [fetchStatus, demoState?.running]) const handleRunFull = async () => { @@ -307,9 +425,9 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep const handleRunStep = async (stepId: string) => { try { const step = await api.demoStep(stepId) - setDemoState(prev => { + setDemoState((prev) => { if (!prev) return prev - const updated = { ...prev, steps: prev.steps.map(s => s.id === stepId ? step : s) } + const updated = { ...prev, steps: prev.steps.map((s) => (s.id === stepId ? step : s)) } onStepsChange?.(updated.steps) return updated }) @@ -323,8 +441,8 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep const proof = demoState?.proof ?? null const running = demoState?.running ?? false - const successCount = steps.filter(s => s.status === 'success').length - const errorCount = steps.filter(s => s.status === 'error').length + const successCount = steps.filter((s) => s.status === 'success').length + const errorCount = steps.filter((s) => s.status === 'error').length return (
@@ -333,26 +451,45 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep
{t.demo.title}
-
- {t.demo.subtitle} -
+
{t.demo.subtitle}
{/* Controls */} -
+
- {successCount}/{steps.length} {t.demo.stepsComplete} - {errorCount > 0 && · {errorCount} {t.demo.errors}} + {errorCount > 0 && ( + + {' '} + · {errorCount} {t.demo.errors} + + )} {running && ( {t.demo.demoRunning} @@ -360,7 +497,16 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep
{error && ( -
+
{t.demo.apiError} {error}
)} @@ -374,7 +520,9 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep key={step.id} step={step} index={i} - onRunStep={(id) => { void handleRunStep(id) }} + onRunStep={(id) => { + void handleRunStep(id) + }} running={running} /> )) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 605509e..c5a1d40 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,7 +1,15 @@ import { useI18n } from '../i18n' import type { Lang } from '../i18n' -export type ActiveView = 'dashboard' | 'guided-demo' | 'inspector' | 'zmq-tape' | 'policy-arena' | 'reorg-lab' | 'history' | 'fee-estimation' +export type ActiveView = + | 'dashboard' + | 'guided-demo' + | 'inspector' + | 'zmq-tape' + | 'policy-arena' + | 'reorg-lab' + | 'history' + | 'fee-estimation' interface Props { network: string @@ -19,17 +27,26 @@ const LANG_OPTIONS: { value: Lang; label: string }[] = [ { value: 'pt-BR', label: 'PT' }, ] -export function Header({ network, apiOk, rpcOk, sseConnected, onRefresh, activeView, onSetView, onNewSession }: Props) { +export function Header({ + network, + apiOk, + rpcOk, + sseConnected, + onRefresh, + activeView, + onSetView, + onNewSession, +}: Props) { const { t, lang, setLang } = useI18n() const NAV: { id: ActiveView; label: string }[] = [ - { id: 'dashboard', label: t.nav.dashboard }, - { id: 'guided-demo', label: t.nav.guidedDemo }, - { id: 'inspector', label: t.nav.txInspector }, - { id: 'zmq-tape', label: t.nav.zmqTape }, + { id: 'dashboard', label: t.nav.dashboard }, + { id: 'guided-demo', label: t.nav.guidedDemo }, + { id: 'inspector', label: t.nav.txInspector }, + { id: 'zmq-tape', label: t.nav.zmqTape }, { id: 'policy-arena', label: t.nav.policyArena }, - { id: 'reorg-lab', label: t.nav.reorgLab }, - { id: 'history', label: t.history.title }, + { id: 'reorg-lab', label: t.nav.reorgLab }, + { id: 'history', label: t.history.title }, { id: 'fee-estimation', label: t.fees.navLabel }, ] @@ -103,7 +120,9 @@ export function Header({ network, apiOk, rpcOk, sseConnected, onRefresh, activeV {t.header.sseStatus} - + {onNewSession && (
)} - {/* Refresh + error */} -
+ {/* Refresh + export + error */} +
- {error && ( - {error} - )} + + + {exportMsg && {exportMsg}} + {error && {error}}
{/* Tabs */}
- {tabs.map(tab => ( + {tabs.map((tab) => (
-
+      
         {json}
       
@@ -181,7 +238,13 @@ interface ScenarioCardProps { onGoToDashboard: () => void } -function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: ScenarioCardProps) { +function ScenarioCard({ + summary, + accentColor, + onRun, + onReset, + onGoToDashboard, +}: ScenarioCardProps) { const { t } = useI18n() const [detail, setDetail] = useState(null) const [polling, setPolling] = useState(false) @@ -214,7 +277,9 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: // Poll while running useEffect(() => { if (!polling) return - const interval = setInterval(() => { void fetchDetail() }, POLL_INTERVAL_MS) + const interval = setInterval(() => { + void fetchDetail() + }, POLL_INTERVAL_MS) return () => clearInterval(interval) }, [polling, fetchDetail]) @@ -226,7 +291,9 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: const handleRun = () => { setPolling(true) onRun(summary.id) - setTimeout(() => { void fetchDetail() }, 400) + setTimeout(() => { + void fetchDetail() + }, 400) } const handleReset = () => { @@ -240,18 +307,32 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: const accentBorder = accentColor + '44' return ( -
+
{/* Header */}
-
+
- {summary.title} + + {summary.title} +
@@ -263,10 +344,13 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: onClick={handleRun} disabled={running} style={{ - padding: '4px 14px', fontSize: '11px', fontWeight: 600, + padding: '4px 14px', + fontSize: '11px', + fontWeight: 600, background: running ? '#1f2937' : accentColor + 'cc', color: running ? '#6b7280' : '#fff', - border: 'none', borderRadius: '4px', + border: 'none', + borderRadius: '4px', cursor: running ? 'not-allowed' : 'pointer', }} > @@ -277,9 +361,12 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: onClick={handleReset} disabled={running} style={{ - padding: '4px 10px', fontSize: '11px', - background: '#1f2937', color: '#9ca3af', - border: '1px solid #374151', borderRadius: '4px', + padding: '4px 10px', + fontSize: '11px', + background: '#1f2937', + color: '#9ca3af', + border: '1px solid #374151', + borderRadius: '4px', cursor: running ? 'not-allowed' : 'pointer', }} > @@ -295,7 +382,7 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: {/* Steps */} {detail && detail.steps.length > 0 && (
- {detail.steps.map(step => ( + {detail.steps.map((step) => ( ))}
@@ -315,7 +402,7 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: )} {/* Idle placeholder */} - {(!detail || detail.steps.every(s => s.status === 'pending')) && status === 'idle' && ( + {(!detail || detail.steps.every((s) => s.status === 'pending')) && status === 'idle' && (
{t.policy.noSteps}
@@ -324,17 +411,20 @@ function ScenarioCard({ summary, accentColor, onRun, onReset, onGoToDashboard }: {/* Learn more + navigation bridge */}
- - {SCENARIO_LEARN[summary.id] ?? ''} - + {SCENARIO_LEARN[summary.id] ?? ''} {status !== 'idle' && !running && (
{error && ( -
+
{error}
)} {/* Scenario legend */} -
- {(['normal_transaction', 'low_fee_transaction', 'rbf_replacement', 'cpfp_package'] as const).map(id => ( +
+ {( + ['normal_transaction', 'low_fee_transaction', 'rbf_replacement', 'cpfp_package'] as const + ).map((id) => ( - + - {id === 'normal_transaction' ? {t.policy.normal} - : id === 'low_fee_transaction' ? {t.policy.lowFee} - : id === 'rbf_replacement' ? {t.policy.rbf} - : {t.policy.cpfp}} + {id === 'normal_transaction' ? ( + {t.policy.normal} + ) : id === 'low_fee_transaction' ? ( + {t.policy.lowFee} + ) : id === 'rbf_replacement' ? ( + {t.policy.rbf} + ) : ( + {t.policy.cpfp} + )} ))} - {t.policy.statusLegend}: {['idle', 'running', 'success', 'error', 'experimental'].map(s => ( + {t.policy.statusLegend}:{' '} + {['idle', 'running', 'success', 'error', 'experimental'].map((s) => ( - {statusDot(s)} + {statusDot(s)} + {s in t.status ? t.status[s as keyof typeof t.status] : s} @@ -478,34 +631,65 @@ export function MempoolPolicyArena({ onGoToDashboard }: { onGoToDashboard?: () = {/* Scenario grid — 2 columns */}
- {scenarios.map(s => ( + {scenarios.map((s) => ( { void handleRun(id) }} - onReset={id => { void handleReset(id) }} + onRun={(id) => { + void handleRun(id) + }} + onReset={(id) => { + void handleReset(id) + }} onGoToDashboard={() => onGoToDashboard?.()} /> ))}
{scenarios.length === 0 && !loading && !error && ( -
+
{t.policy.noSteps}
)} {/* Notes */} -
-
{t.policy.notesTitle}
-
{t.policy.noteRegtest}
-
{t.policy.noteRbf}
-
{t.policy.noteCpfp}
-
/ — {t.policy.noteStatus}
+
+
+ {t.policy.notesTitle} +
+
+ • {t.policy.noteRegtest} +
+
+ • {t.policy.noteRbf} +
+
+ • {t.policy.noteCpfp} +
+
+ • / —{' '} + {t.policy.noteStatus} +
) diff --git a/frontend/src/components/NodeHealthScore.tsx b/frontend/src/components/NodeHealthScore.tsx index 5e42438..ef152fb 100644 --- a/frontend/src/components/NodeHealthScore.tsx +++ b/frontend/src/components/NodeHealthScore.tsx @@ -19,13 +19,11 @@ function ArcGauge({ score, label }: { score: number; label: string }) { const R = 52 const CX = 70 const CY = 74 - const C = 2 * Math.PI * R // full circumference ~326.7 - const ARC = C * 0.75 // 270° arc ~245 + const C = 2 * Math.PI * R // full circumference ~326.7 + const ARC = C * 0.75 // 270° arc ~245 const FILL = (score / 100) * ARC - const color = - label === 'healthy' ? '#3fb886' : - label === 'degraded' ? '#e3b341' : '#f85149' + const color = label === 'healthy' ? '#3fb886' : label === 'degraded' ? '#e3b341' : '#f85149' // Both arcs rotate so the 90° gap is at the bottom const rotation = `rotate(135, ${CX}, ${CY})` @@ -35,13 +33,18 @@ function ArcGauge({ score, label }: { score: number; label: string }) { - + + + + {/* Background track */} {/* /100 */} - + / 100 {/* Status label */} {/* Status dot */} - + {/* Label */} - + {label} {/* Bar track */} -
-
+
+
{/* Points */} - + +{pts}
@@ -161,24 +195,30 @@ function ComponentRow({ // Main component // --------------------------------------------------------------------------- -export function NodeHealthScore({ health, mempool, latestBlock, sseConnected, intelligence }: NodeHealthScoreProps) { +export function NodeHealthScore({ + health, + mempool, + latestBlock, + sseConnected, + intelligence, +}: NodeHealthScoreProps) { const { t } = useI18n() // Use backend-computed values when available — they reflect real ZMQ/SSE status // instead of client-side proxies (sseConnected ≠ zmq events flowing) - const score = intelligence?.node_health_score - ?? computeHealthScore(health, mempool, latestBlock, sseConnected).score - const label = intelligence?.node_health_label - ?? computeHealthScore(health, mempool, latestBlock, sseConnected).label - - const rpcOk = intelligence ? intelligence.rpc_status === 'online' - : (health?.rpc_ok ?? false) - const zmqOk = intelligence ? intelligence.zmq_status === 'subscribed' - : sseConnected - const mempoolOk = intelligence ? intelligence.mempool_pressure !== 'unknown' - : (mempool?.rpc_ok ?? false) - const blocksOk = intelligence ? score >= 80 - : score >= 90 + const score = + intelligence?.node_health_score ?? + computeHealthScore(health, mempool, latestBlock, sseConnected).score + const label = + intelligence?.node_health_label ?? + computeHealthScore(health, mempool, latestBlock, sseConnected).label + + const rpcOk = intelligence ? intelligence.rpc_status === 'online' : (health?.rpc_ok ?? false) + const zmqOk = intelligence ? intelligence.zmq_status === 'subscribed' : sseConnected + const mempoolOk = intelligence + ? intelligence.mempool_pressure !== 'unknown' + : (mempool?.rpc_ok ?? false) + const blocksOk = intelligence ? score >= 80 : score >= 90 return (
@@ -200,10 +240,20 @@ export function NodeHealthScore({ health, mempool, latestBlock, sseConnected, in {/* Component breakdown */}
- - - - + + + +
diff --git a/frontend/src/components/ReorgLab.tsx b/frontend/src/components/ReorgLab.tsx index 70395a6..c56c1a9 100644 --- a/frontend/src/components/ReorgLab.tsx +++ b/frontend/src/components/ReorgLab.tsx @@ -6,26 +6,27 @@ import { Term } from './ui/InfoTooltip' import { LearnMore } from './ui/LearnMore' const STATUS_COLORS: Record = { - idle: '#6b7280', - running: '#f59e0b', - success: '#22c55e', - error: '#ef4444', - unavailable: '#9ca3af', + idle: '#6b7280', + running: '#f59e0b', + success: '#22c55e', + error: '#ef4444', + unavailable: '#9ca3af', experimental: '#a78bfa', - pending: '#374151', + pending: '#374151', } const STEP_ICONS: Record = { - success: '✓', - error: '✗', - running: '…', - unavailable: '—', + success: '✓', + error: '✗', + running: '…', + unavailable: '—', experimental: '⚠', - pending: '○', + pending: '○', } const REORG_WARNING_KEYS: Record = { - 'This scenario is marked experimental. Regtest reorgs are controlled and safe.': 'warningExperimental', + 'This scenario is marked experimental. Regtest reorgs are controlled and safe.': + 'warningExperimental', 'Chain was restored via a new block after invalidation.': 'warningRestored', } @@ -49,7 +50,9 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { clearInterval(pollingRef.current) pollingRef.current = null } - } catch { /* ignore */ } + } catch { + /* ignore */ + } } useEffect(() => { @@ -61,7 +64,9 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { const startPolling = () => { if (pollingRef.current) clearInterval(pollingRef.current) - pollingRef.current = setInterval(() => { void fetchStatus() }, 1500) + pollingRef.current = setInterval(() => { + void fetchStatus() + }, 1500) } const handleRun = async () => { @@ -70,7 +75,9 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { const s = await api.reorgRun() setData(s) startPolling() - } catch { /* ignore */ } + } catch { + /* ignore */ + } setLoading(false) } @@ -78,7 +85,9 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { try { const s = await api.reorgReset() setData(s) - } catch { /* ignore */ } + } catch { + /* ignore */ + } } const handleCopyProof = () => { @@ -94,24 +103,47 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { const steps = data?.steps ?? [] const proof = data?.proof - const txid = proof?.txid - ?? steps.find(s => s.data?.txid)?.data?.txid as string | undefined + const txid = proof?.txid ?? (steps.find((s) => s.data?.txid)?.data?.txid as string | undefined) return (
{/* Header */} -
+
-

+

{t.reorg.title} - {t.reorg.experimentalBadge} + + {t.reorg.experimentalBadge} +

{t.reorg.subtitle} @@ -119,23 +151,36 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) {

{/* Status bar */} -
- +
+ {status} {data?.network && ( @@ -169,24 +233,32 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { {t.reorg.networkLabel}: {data.network} )} - {data?.error && ( - ⚠ {data.error} - )} + {data?.error && ⚠ {data.error}}
{/* Timeline */} -
-
+
+
{t.reorg.phase}
{steps.length === 0 ? ( -
- {t.reorg.noResults} -
+
{t.reorg.noResults}
) : ( steps.map((step: ReorgStep, idx: number) => ( @@ -200,33 +272,53 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { {/* Proof JSON */} {proof && ( -
-
+
+
{t.demo.proofReport} (JSON)
-
+          
             {JSON.stringify(proof, null, 2)}
           
@@ -234,10 +326,17 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { {/* Warnings */} {proof?.warnings && proof.warnings.length > 0 && ( -
+
{proof.warnings.map((w: string, i: number) => { const key = REORG_WARNING_KEYS[w] return
⚠ {key ? t.reorg[key] : w}
@@ -245,36 +344,43 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) {
)} - - {t.learn.reorg} - + {t.learn.reorg}
) } function StepRow({ - step, onInspect, txid, -}: { step: ReorgStep; onInspect?: (txid: string) => void; txid?: string }) { + step, + onInspect, + txid, +}: { + step: ReorgStep + onInspect?: (txid: string) => void + txid?: string +}) { const { t } = useI18n() const icon = STEP_ICONS[step.status] ?? '○' const color = STATUS_COLORS[step.status] ?? '#6b7280' - const label = { - check_network: t.reorg.steps.checkNetwork, - ensure_wallet: t.reorg.steps.ensureWallet, - broadcast_tx: t.reorg.steps.broadcastTx, - mine_block: t.reorg.steps.mineBlock, - invalidate_block: t.reorg.steps.invalidateBlock, - check_tx_after_invalidation: t.reorg.steps.checkMempool, - mine_recovery_block: t.reorg.steps.mineRecovery, - verify_reconfirmation: t.reorg.steps.verifyReconfirmation, - reconsider_block: t.reorg.steps.reconsiderBlock, - build_proof: t.reorg.steps.buildProof, - }[step.name] ?? step.name + const label = + { + check_network: t.reorg.steps.checkNetwork, + ensure_wallet: t.reorg.steps.ensureWallet, + broadcast_tx: t.reorg.steps.broadcastTx, + mine_block: t.reorg.steps.mineBlock, + invalidate_block: t.reorg.steps.invalidateBlock, + check_tx_after_invalidation: t.reorg.steps.checkMempool, + mine_recovery_block: t.reorg.steps.mineRecovery, + verify_reconfirmation: t.reorg.steps.verifyReconfirmation, + reconsider_block: t.reorg.steps.reconsiderBlock, + build_proof: t.reorg.steps.buildProof, + }[step.name] ?? step.name const stepTxid = (step.data?.txid ?? txid) as string | undefined return (
- {icon} + + {icon} +
{label}
{step.message}
@@ -288,8 +394,13 @@ function StepRow({ @@ -255,7 +436,17 @@ export function TransactionInspector({ initialTxid = '' }: Props) { {/* Error */} {error && ( -
+
{error}
)} @@ -271,26 +462,37 @@ export function TransactionInspector({ initialTxid = '' }: Props) { {result.warnings.length > 0 && (
{result.warnings.map((w, i) => ( -
⚠ {w}
+
+ ⚠ {w} +
))}
)} {result.unavailable_features.length > 0 && (
{result.unavailable_features.map((f, i) => ( -
— {t.proof.unavailable} {f}
+
+ — {t.proof.unavailable} {f} +
))}
)} - - {t.learn.zmq} - + {t.learn.zmq} )} {!loading && !result && !error && ( -
+
{t.inspector.placeholder}
)} diff --git a/frontend/src/components/TransactionLifecycle.tsx b/frontend/src/components/TransactionLifecycle.tsx index 480c688..f1cb9db 100644 --- a/frontend/src/components/TransactionLifecycle.tsx +++ b/frontend/src/components/TransactionLifecycle.tsx @@ -29,10 +29,10 @@ interface TransactionLifecycleProps { } function StageLabel({ id, label }: { id: string; label: string }) { - if (id === 'mempool') return {label} - if (id === 'zmq-rawtx') return {label} - if (id === 'zmq-rawblock') return {label} - if (id === 'mined') return {label} + if (id === 'mempool') return {label} + if (id === 'zmq-rawtx') return {label} + if (id === 'zmq-rawblock') return {label} + if (id === 'mined') return {label} return <>{label} } @@ -60,8 +60,10 @@ export function TransactionLifecycle({ // Collect events newer than the last we processed, then sort oldest-first const newEvents = sseEvents - .filter(ev => { - const ts = (ev.payload?.event as Record | undefined)?.ts as string | undefined + .filter((ev) => { + const ts = (ev.payload?.event as Record | undefined)?.ts as + | string + | undefined return ts !== undefined && ts > lastProcessedTs.current }) .reverse() @@ -80,7 +82,7 @@ export function TransactionLifecycle({ if (origin === 'rawtx' && data?.coinbase_input_present) continue if (origin === 'rawtx' && data?.txid) { - setTracked(prev => { + setTracked((prev) => { if (prev && !prev.confirmed) return prev return { txid: data.txid as string, @@ -94,7 +96,7 @@ export function TransactionLifecycle({ } if (origin === 'rawblock') { - setTracked(prev => { + setTracked((prev) => { if (!prev || prev.rawblockSeen) return prev return { ...prev, @@ -111,7 +113,7 @@ export function TransactionLifecycle({ useEffect(() => { if (!tracked?.rawblockSeen || tracked.confirmed) return const t = setTimeout(() => { - setTracked(prev => prev ? { ...prev, confirmed: true } : prev) + setTracked((prev) => (prev ? { ...prev, confirmed: true } : prev)) }, 1200) return () => clearTimeout(t) }, [tracked?.rawblockSeen, tracked?.confirmed]) @@ -131,21 +133,31 @@ export function TransactionLifecycle({ } }, [tracked?.confirmed]) - const fmt = (iso: string | null) => - iso ? new Date(iso).toLocaleTimeString() : '—' + const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleTimeString() : '—') // When demo steps are provided, derive stage activation from step statuses - const stepOk = (id: string) => demoSteps?.find(s => s.id === id)?.status === 'success' + const stepOk = (id: string) => demoSteps?.find((s) => s.id === id)?.status === 'success' const demoActive = demoSteps !== undefined - const demoTracked: TrackedTx | null = demoActive && stepOk('send_demo_transaction') ? { - txid: (demoSteps!.find(s => s.id === 'send_demo_transaction')?.data?.txid as string | undefined) ?? '…', - rawtxTs: demoSteps!.find(s => s.id === 'detect_zmq_rawtx')?.timestamp ?? new Date().toISOString(), - rawblockSeen: stepOk('mine_confirmation_block'), - rawblockTs: demoSteps!.find(s => s.id === 'detect_zmq_rawblock')?.timestamp ?? null, - blockHeight: (demoSteps!.find(s => s.id === 'mine_confirmation_block')?.data?.height as number | null) ?? null, - confirmed: stepOk('confirm_transaction'), - } : null + const demoTracked: TrackedTx | null = + demoActive && stepOk('send_demo_transaction') + ? { + txid: + (demoSteps!.find((s) => s.id === 'send_demo_transaction')?.data?.txid as + | string + | undefined) ?? '…', + rawtxTs: + demoSteps!.find((s) => s.id === 'detect_zmq_rawtx')?.timestamp ?? + new Date().toISOString(), + rawblockSeen: stepOk('mine_confirmation_block'), + rawblockTs: demoSteps!.find((s) => s.id === 'detect_zmq_rawblock')?.timestamp ?? null, + blockHeight: + (demoSteps!.find((s) => s.id === 'mine_confirmation_block')?.data?.height as + | number + | null) ?? null, + confirmed: stepOk('confirm_transaction'), + } + : null const effectiveTracked = demoActive ? demoTracked : tracked @@ -172,16 +184,19 @@ export function TransactionLifecycle({ { id: 'mined', label: t.dashboard.lifecycleBlockMined, - sub: effectiveTracked.blockHeight != null - ? `${t.generic.height} ${effectiveTracked.blockHeight}` - : '…', + sub: + effectiveTracked.blockHeight != null + ? `${t.generic.height} ${effectiveTracked.blockHeight}` + : '…', active: demoActive ? stepOk('mine_confirmation_block') : effectiveTracked.rawblockSeen, }, { id: 'zmq-rawblock', label: 'ZMQ rawblock', sub: effectiveTracked.rawblockTs ? fmt(effectiveTracked.rawblockTs) : '…', - active: demoActive ? stepOk('detect_zmq_rawblock') : (effectiveTracked.rawblockSeen && zmqConnected), + active: demoActive + ? stepOk('detect_zmq_rawblock') + : effectiveTracked.rawblockSeen && zmqConnected, }, { id: 'confirmed', @@ -191,12 +206,12 @@ export function TransactionLifecycle({ }, ] : [ - { id: 'broadcast', label: t.dashboard.lifecycleBroadcast, sub: '—', active: false }, - { id: 'mempool', label: 'Mempool', sub: '—', active: false }, - { id: 'zmq-rawtx', label: 'ZMQ rawtx', sub: '—', active: false }, - { id: 'mined', label: t.dashboard.lifecycleBlockMined, sub: '—', active: false }, - { id: 'zmq-rawblock', label: 'ZMQ rawblock', sub: '—', active: false }, - { id: 'confirmed', label: t.dashboard.lifecycleConfirmed, sub: '—', active: false }, + { id: 'broadcast', label: t.dashboard.lifecycleBroadcast, sub: '—', active: false }, + { id: 'mempool', label: 'Mempool', sub: '—', active: false }, + { id: 'zmq-rawtx', label: 'ZMQ rawtx', sub: '—', active: false }, + { id: 'mined', label: t.dashboard.lifecycleBlockMined, sub: '—', active: false }, + { id: 'zmq-rawblock', label: 'ZMQ rawblock', sub: '—', active: false }, + { id: 'confirmed', label: t.dashboard.lifecycleConfirmed, sub: '—', active: false }, ] const idleUnavailable = !rpcOk || !zmqConnected @@ -237,23 +252,36 @@ export function TransactionLifecycle({
{stage.sub !== '—' && ( -
{stage.sub}
+
+ {stage.sub} +
)}
{stage.active && ( - + + ✓ + )}
{i < stages.length - 1 && ( -
+
)}
))} @@ -271,7 +299,9 @@ export function TransactionLifecycle({
{stage.sub}
{i < stages.length - 1 && ( -
+
)}
))} @@ -279,18 +309,23 @@ export function TransactionLifecycle({ )} {/* Status bar: idle waiting / confirmed */} -
+
{effectiveTracked?.confirmed ? ( <> diff --git a/frontend/src/components/TxPanel.tsx b/frontend/src/components/TxPanel.tsx index 7b503e7..8ac937c 100644 --- a/frontend/src/components/TxPanel.tsx +++ b/frontend/src/components/TxPanel.tsx @@ -31,7 +31,9 @@ export function TxPanel({ tx }: Props) { return (
- {t.dashboard.latestTx} + + {t.dashboard.latestTx} +
{!tx ? ( @@ -39,7 +41,9 @@ export function TxPanel({ tx }: Props) { ) : ( <>
- TXID + + TXID + {t.dashboard.copied}}
- Kind + + Kind + {tx.kind}
- {t.inspector.inputs} + + {t.inspector.inputs} + {tx.inputs}
- {t.inspector.outputs} + + {t.inspector.outputs} + {tx.outputs}
- {t.inspector.totalOutput} + + {t.inspector.totalOutput} + {tx.total_out.toFixed(8)} BTC
diff --git a/frontend/src/components/ZmqEventTape.tsx b/frontend/src/components/ZmqEventTape.tsx index 231c57b..b1eaf2c 100644 --- a/frontend/src/components/ZmqEventTape.tsx +++ b/frontend/src/components/ZmqEventTape.tsx @@ -9,42 +9,40 @@ import { LearnMore } from './ui/LearnMore' // Single event row // --------------------------------------------------------------------------- -function EventRow({ - ev, - onInspect, -}: { - ev: TapeEvent - onInspect: (txid: string) => void -}) { +function EventRow({ ev, onInspect }: { ev: TapeEvent; onInspect: (txid: string) => void }) { const { t } = useI18n() const isRawtx = ev.topic === 'rawtx' const topicColor = isRawtx ? '#60a5fa' : '#a78bfa' const topicLabel = isRawtx ? t.zmq.rawTx : t.zmq.rawBlock return ( -
- +
+ {topicLabel} @@ -54,21 +52,23 @@ function EventRow({ {isRawtx ? ( - ev.txid - ? ev.txid && onInspect(ev.txid)} - > - {ev.short_id ?? ev.txid.slice(0, 16) + '…'} - - : '—' + ev.txid ? ( + ev.txid && onInspect(ev.txid)} + > + {ev.short_id ?? ev.txid.slice(0, 16) + '…'} + + ) : ( + '—' + ) ) : ( - {ev.blockhash ? ev.short_id ?? ev.blockhash.slice(0, 16) + '…' : '—'} - {ev.height !== null && ev.height !== undefined - ? h={ev.height} - : null} + {ev.blockhash ? (ev.short_id ?? ev.blockhash.slice(0, 16) + '…') : '—'} + {ev.height !== null && ev.height !== undefined ? ( + h={ev.height} + ) : null} )} @@ -76,9 +76,7 @@ function EventRow({ {isRawtx && ev.vsize != null && ( {ev.vsize} vbytes )} - {isRawtx && ev.has_op_return && ( - OP_RETURN - )} + {isRawtx && ev.has_op_return && OP_RETURN} {isRawtx && ev.script_types.length > 0 && ( {ev.script_types.slice(0, 2).join(', ')} )} @@ -132,11 +130,13 @@ export function ZmqEventTape({ onInspectTxid }: Props) { } }, [topicFilter]) - useEffect(() => { void fetchData() }, [fetchData]) + useEffect(() => { + void fetchData() + }, [fetchData]) const items = data?.items ?? [] - const rawtxCount = items.filter(e => e.topic === 'rawtx').length - const rawblockCount = items.filter(e => e.topic === 'rawblock').length + const rawtxCount = items.filter((e) => e.topic === 'rawtx').length + const rawblockCount = items.filter((e) => e.topic === 'rawblock').length const btnStyle = (active: boolean): React.CSSProperties => ({ padding: '3px 10px', @@ -152,27 +152,58 @@ export function ZmqEventTape({ onInspectTxid }: Props) {
{/* Header */}
-
+
{t.zmq.title}
-
- {t.zmq.subtitle} -
+
{t.zmq.subtitle}
{/* Controls */} -
- +
+ - @@ -185,14 +216,33 @@ export function ZmqEventTape({ onInspectTxid }: Props) { {/* Error */} {error && ( -
+
{error}
)} {/* Column headers */} {items.length > 0 && ( -
+
topic timestamp (UTC) id @@ -201,7 +251,16 @@ export function ZmqEventTape({ onInspectTxid }: Props) { {/* Events */} {items.length === 0 && !loading && !error && ( -
+
{data ? t.zmq.noEvents : t.status.loading}
)} @@ -209,9 +268,7 @@ export function ZmqEventTape({ onInspectTxid }: Props) { ))} - - {t.learn.zmq} - + {t.learn.zmq}
) } diff --git a/frontend/src/components/ui/ExplainBox.tsx b/frontend/src/components/ui/ExplainBox.tsx index c86c2b9..69987ac 100644 --- a/frontend/src/components/ui/ExplainBox.tsx +++ b/frontend/src/components/ui/ExplainBox.tsx @@ -5,17 +5,19 @@ interface Props { export function ExplainBox({ text, icon = '●' }: Props) { return ( -
+
{icon} {text} diff --git a/frontend/src/components/ui/InfoTooltip.tsx b/frontend/src/components/ui/InfoTooltip.tsx index 35afd8f..2b405c6 100644 --- a/frontend/src/components/ui/InfoTooltip.tsx +++ b/frontend/src/components/ui/InfoTooltip.tsx @@ -39,32 +39,36 @@ export function InfoTooltip({ term, text, size = 13 }: Props) { } const hide = () => setVisible(false) - const tooltip = visible ? createPortal( - - {tip} - , - document.body - ) : null + const tooltip = visible + ? createPortal( + + {tip} + , + document.body + ) + : null return ( - + cursor: 'help', + outline: 'none', + }} + > + i {tooltip} diff --git a/frontend/src/components/ui/LearnMore.tsx b/frontend/src/components/ui/LearnMore.tsx index 381c398..853b8a8 100644 --- a/frontend/src/components/ui/LearnMore.tsx +++ b/frontend/src/components/ui/LearnMore.tsx @@ -10,38 +10,44 @@ export function LearnMore({ children, title }: Props) { const label = title ?? t.learn.whyMatters return ( -
- +
+ {label} -
+
{children}
diff --git a/frontend/src/hooks/useInterval.ts b/frontend/src/hooks/useInterval.ts index afe0c8e..42b1907 100644 --- a/frontend/src/hooks/useInterval.ts +++ b/frontend/src/hooks/useInterval.ts @@ -2,7 +2,9 @@ import { useEffect, useRef } from 'react' export function useInterval(callback: () => void, delay: number | null) { const savedCallback = useRef(callback) - useEffect(() => { savedCallback.current = callback }) + useEffect(() => { + savedCallback.current = callback + }) useEffect(() => { if (delay === null) return const id = setInterval(() => savedCallback.current(), delay) diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 3392c36..48e1e8c 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -35,8 +35,10 @@ export function useSSE(url: string) { if (!active) return try { const payload = JSON.parse(e.data as string) - setEvents(prev => [{ type: 'nodescope_event', payload }, ...prev].slice(0, 50)) - } catch { /* ignore malformed */ } + setEvents((prev) => [{ type: 'nodescope_event', payload }, ...prev].slice(0, 50)) + } catch { + /* ignore malformed */ + } }) es.onerror = () => { diff --git a/frontend/src/i18n/enUS.ts b/frontend/src/i18n/enUS.ts index ab0e2f4..54063cf 100644 --- a/frontend/src/i18n/enUS.ts +++ b/frontend/src/i18n/enUS.ts @@ -53,7 +53,8 @@ export const enUS: Translations = { demo: { title: 'Guided Demo — Evaluate in 1 Minute', - subtitle: 'End-to-end Bitcoin Core lab: RPC → wallet → mine → send → mempool → ZMQ → confirm → proof', + subtitle: + 'End-to-end Bitcoin Core lab: RPC → wallet → mine → send → mempool → ZMQ → confirm → proof', stepsComplete: 'steps complete', errors: 'error(s)', demoRunning: '● Demo running…', @@ -64,20 +65,34 @@ export const enUS: Translations = { no: 'no', na: 'n/a', stepDesc: { - check_rpc: 'Verifies the Bitcoin Core RPC connection is reachable and responds to getblockchaininfo.', - check_zmq: 'Queries getzmqnotifications to confirm ZMQ push sockets are configured for rawtx and rawblock channels.', - create_or_load_wallet: 'Creates or loads the nodescope_demo descriptor wallet used for sending and receiving in the demo.', - generate_mining_address: 'Derives a new bech32 (P2WPKH) address from the wallet to receive block subsidy rewards.', - mine_initial_blocks: 'Mines 101 blocks so the coinbase outputs are mature (≥100 confirmations) and spendable.', - create_destination_address: 'Derives a second wallet address to act as the recipient of the demo transaction.', - send_demo_transaction: 'Broadcasts a 0.001 BTC transaction from the wallet to the destination address via sendtoaddress RPC.', - detect_mempool_entry: 'Calls getmempoolentry to confirm the transaction is in the node\'s unconfirmed transaction pool.', - detect_zmq_rawtx: 'Captures the ZMQ rawtx push event that Bitcoin Core emits when a transaction enters the mempool.', - decode_transaction: 'Calls getrawtransaction + decoderawtransaction to extract inputs, outputs, fee, vbytes, weight and script types.', - mine_confirmation_block: 'Mines 1 block via generatetoaddress to include the transaction and produce its first confirmation.', - detect_zmq_rawblock: 'Captures the ZMQ rawblock push event that Bitcoin Core emits when a new block is connected to the chain.', - confirm_transaction: 'Calls gettransaction to verify the transaction has ≥1 confirmation and is now permanently on-chain.', - generate_proof_report: 'Assembles all collected data (RPC calls, ZMQ events, TXID, fees, block height, confirmations) into an exportable proof.', + check_rpc: + 'Verifies the Bitcoin Core RPC connection is reachable and responds to getblockchaininfo.', + check_zmq: + 'Queries getzmqnotifications to confirm ZMQ push sockets are configured for rawtx and rawblock channels.', + create_or_load_wallet: + 'Creates or loads the nodescope_demo descriptor wallet used for sending and receiving in the demo.', + generate_mining_address: + 'Derives a new bech32 (P2WPKH) address from the wallet to receive block subsidy rewards.', + mine_initial_blocks: + 'Mines 101 blocks so the coinbase outputs are mature (≥100 confirmations) and spendable.', + create_destination_address: + 'Derives a second wallet address to act as the recipient of the demo transaction.', + send_demo_transaction: + 'Broadcasts a 0.001 BTC transaction from the wallet to the destination address via sendtoaddress RPC.', + detect_mempool_entry: + "Calls getmempoolentry to confirm the transaction is in the node's unconfirmed transaction pool.", + detect_zmq_rawtx: + 'Captures the ZMQ rawtx push event that Bitcoin Core emits when a transaction enters the mempool.', + decode_transaction: + 'Calls getrawtransaction + decoderawtransaction to extract inputs, outputs, fee, vbytes, weight and script types.', + mine_confirmation_block: + 'Mines 1 block via generatetoaddress to include the transaction and produce its first confirmation.', + detect_zmq_rawblock: + 'Captures the ZMQ rawblock push event that Bitcoin Core emits when a new block is connected to the chain.', + confirm_transaction: + 'Calls gettransaction to verify the transaction has ≥1 confirmation and is now permanently on-chain.', + generate_proof_report: + 'Assembles all collected data (RPC calls, ZMQ events, TXID, fees, block height, confirmations) into an exportable proof.', }, }, @@ -147,7 +162,8 @@ export const enUS: Translations = { policy: { title: 'Mempool Policy Arena', - subtitle: 'Observe how Bitcoin Core handles transactions with different fee policies, RBF, and CPFP', + subtitle: + 'Observe how Bitcoin Core handles transactions with different fee policies, RBF, and CPFP', scenarios: 'Scenarios', normal: 'Normal Transaction', lowFee: 'Low Fee Transaction', @@ -164,18 +180,24 @@ export const enUS: Translations = { notesTitle: 'Notes', noteRegtest: 'All scenarios run on regtest — no real funds, no mainnet.', noteRbf: 'RBF requires walletrbf=1 in bitcoind config; otherwise it is marked experimental.', - noteCpfp: 'CPFP child construction uses the raw transaction pipeline and may fall back if the parent UTXO is not wallet-tracked.', - noteStatus: 'Experimental means the scenario was attempted with a graceful fallback. Error means the scenario stopped.', + noteCpfp: + 'CPFP child construction uses the raw transaction pipeline and may fall back if the parent UTXO is not wallet-tracked.', + noteStatus: + 'Experimental means the scenario was attempted with a graceful fallback. Error means the scenario stopped.', descNormal: 'Send a standard transaction, observe mempool entry, mine a block and confirm.', - descLowFee: 'Send with fee_rate=1 sat/vbyte, compare fee data vs a normal tx. Uses sendtoaddress fee_rate param (BC 26+).', - descRbf: 'Send a replaceable transaction, then use bumpfee to replace it with a higher-fee version before it confirms.', - descCpfp: 'Send a low-fee parent, then construct a child that spends the unconfirmed output with a higher fee to boost the package rate.', + descLowFee: + 'Send with fee_rate=1 sat/vbyte, compare fee data vs a normal tx. Uses sendtoaddress fee_rate param (BC 26+).', + descRbf: + 'Send a replaceable transaction, then use bumpfee to replace it with a higher-fee version before it confirms.', + descCpfp: + 'Send a low-fee parent, then construct a child that spends the unconfirmed output with a higher fee to boost the package rate.', viewOnDashboard: '↗ View on Dashboard', }, reorg: { title: 'Reorg Lab', - subtitle: 'Simulate a controlled chain reorganization in regtest and observe the impact on transaction confirmations', + subtitle: + 'Simulate a controlled chain reorganization in regtest and observe the impact on transaction confirmations', runReorg: '▶ Run Reorg', running: 'Running…', reset: 'Reset', @@ -200,7 +222,8 @@ export const enUS: Translations = { finalConfirmations: 'Final confirmations', chainRecovery: 'Chain recovery', reconsiderBlockCalled: 'Reconsider block called', - warningExperimental: 'This scenario is marked experimental. Regtest reorgs are controlled and safe.', + warningExperimental: + 'This scenario is marked experimental. Regtest reorgs are controlled and safe.', warningRestored: 'Chain was restored via a new block after invalidation.', yes: 'yes', no: 'no', @@ -325,42 +348,64 @@ export const enUS: Translations = { }, panelDesc: { - intelligence: 'Aggregated view of node health, connectivity status, mempool pressure and recent event classification. Derived from live RPC and ZMQ data.', - replayEngine: 'Reads the NDJSON event log written by the monitor. Shows how many ZMQ events have been captured and are available for replay or analysis.', - nodeHealth: 'Composite health score (0–100) computed from RPC connectivity, ZMQ subscription, mempool access and recent block activity.', - liveFeed: 'Real-time stream of ZMQ events as they arrive from Bitcoin Core — rawtx (new transactions) and rawblock (new blocks).', - events: 'Recent events captured from the ZMQ stream and stored in the event log. Includes transaction and block events.', - classifications: 'Each captured event is classified by type (coinbase, complex, simple, etc.) using heuristics applied to the decoded transaction.', - rpcZmqSync: 'Compares the latest block height seen via RPC with the latest rawblock event from ZMQ to detect drift between the two data sources.', + intelligence: + 'Aggregated view of node health, connectivity status, mempool pressure and recent event classification. Derived from live RPC and ZMQ data.', + replayEngine: + 'Reads the NDJSON event log written by the monitor. Shows how many ZMQ events have been captured and are available for replay or analysis.', + nodeHealth: + 'Composite health score (0–100) computed from RPC connectivity, ZMQ subscription, mempool access and recent block activity.', + liveFeed: + 'Real-time stream of ZMQ events as they arrive from Bitcoin Core — rawtx (new transactions) and rawblock (new blocks).', + events: + 'Recent events captured from the ZMQ stream and stored in the event log. Includes transaction and block events.', + classifications: + 'Each captured event is classified by type (coinbase, complex, simple, etc.) using heuristics applied to the decoded transaction.', + rpcZmqSync: + 'Compares the latest block height seen via RPC with the latest rawblock event from ZMQ to detect drift between the two data sources.', }, healthScore: { rpc: 'RPC connection to Bitcoin Core is active. Contributes +40 to the health score. Without RPC the node cannot be queried.', zmq: 'ZMQ event stream is connected and receiving events. Contributes +30. Without ZMQ, live transaction and block events are unavailable.', - mempool: 'Mempool data is accessible via RPC. Contributes +20. Indicates the node is processing and accepting transactions.', - blocks: 'Recent block activity detected (score ≥ 90). Contributes +10. Confirms the chain is actively progressing.', + mempool: + 'Mempool data is accessible via RPC. Contributes +20. Indicates the node is processing and accepting transactions.', + blocks: + 'Recent block activity detected (score ≥ 90). Contributes +10. Confirms the chain is actively progressing.', }, explain: { - dashboard: 'Real-time overview of the Bitcoin Core node. Shows connectivity status (RPC, ZMQ, SSE), mempool state, latest blocks and transactions, and live event feed.', - guidedDemo: 'Runs the complete Bitcoin Core transaction lifecycle: wallet creation → RPC call → mempool entry → ZMQ event detection → block confirmation → proof generation. Each step is individually verifiable.', - inspector: 'Decodes any transaction using Bitcoin Core RPC (getrawtransaction + decoderawtransaction). Shows inputs, outputs, script types, fees, vbytes, weight, and confirmation status.', - zmqTape: 'Shows raw events published by Bitcoin Core over ZMQ (rawtx and rawblock channels). Each event is correlated with RPC data to verify authenticity and extract on-chain details.', - policyArena: 'Demonstrates how Bitcoin Core applies mempool policies: normal fee transaction, below-minimum fee rejection, RBF (Replace-By-Fee) replacement, and CPFP (Child-Pays-For-Parent) package evaluation.', - reorgLab: 'Simulates a controlled chain reorganization in regtest: mines blocks on one chain, then mines a longer competing chain to trigger a reorg. Observe how confirmed transactions return to the mempool.', - clusterMempool: 'Detects whether the connected Bitcoin Core node supports cluster mempool RPCs (introduced in v28+). Falls back to standard mempool data with honest disclosure when unavailable.', - proofReport: 'Cryptographically-auditable summary of the demo execution: RPC calls, ZMQ events, TXID, block height, confirmations. Can be exported as JSON for independent verification.', + dashboard: + 'Real-time overview of the Bitcoin Core node. Shows connectivity status (RPC, ZMQ, SSE), mempool state, latest blocks and transactions, and live event feed.', + guidedDemo: + 'Runs the complete Bitcoin Core transaction lifecycle: wallet creation → RPC call → mempool entry → ZMQ event detection → block confirmation → proof generation. Each step is individually verifiable.', + inspector: + 'Decodes any transaction using Bitcoin Core RPC (getrawtransaction + decoderawtransaction). Shows inputs, outputs, script types, fees, vbytes, weight, and confirmation status.', + zmqTape: + 'Shows raw events published by Bitcoin Core over ZMQ (rawtx and rawblock channels). Each event is correlated with RPC data to verify authenticity and extract on-chain details.', + policyArena: + 'Demonstrates how Bitcoin Core applies mempool policies: normal fee transaction, below-minimum fee rejection, RBF (Replace-By-Fee) replacement, and CPFP (Child-Pays-For-Parent) package evaluation.', + reorgLab: + 'Simulates a controlled chain reorganization in regtest: mines blocks on one chain, then mines a longer competing chain to trigger a reorg. Observe how confirmed transactions return to the mempool.', + clusterMempool: + 'Detects whether the connected Bitcoin Core node supports cluster mempool RPCs (introduced in v28+). Falls back to standard mempool data with honest disclosure when unavailable.', + proofReport: + 'Cryptographically-auditable summary of the demo execution: RPC calls, ZMQ events, TXID, block height, confirmations. Can be exported as JSON for independent verification.', }, learn: { - normalTx: 'A standard Bitcoin transaction has one or more inputs (spending UTXOs) and outputs. Its fee rate (sat/vbyte) determines mempool priority. Miners select transactions in descending fee-rate order. The default minimum relay fee is 1 sat/vbyte — any transaction meeting this threshold enters the mempool and waits for inclusion in a block.', - lowFee: 'Transactions below the minimum relay fee are rejected outright by Bitcoin Core. Transactions just above the minimum may linger in the mempool when blocks are full, or be evicted when the mempool size limit (default 300 MB) is reached, dropping the lowest fee-rate entries first. Use RBF (bump fee) or CPFP (child transaction) to accelerate a stuck low-fee transaction.', + normalTx: + 'A standard Bitcoin transaction has one or more inputs (spending UTXOs) and outputs. Its fee rate (sat/vbyte) determines mempool priority. Miners select transactions in descending fee-rate order. The default minimum relay fee is 1 sat/vbyte — any transaction meeting this threshold enters the mempool and waits for inclusion in a block.', + lowFee: + 'Transactions below the minimum relay fee are rejected outright by Bitcoin Core. Transactions just above the minimum may linger in the mempool when blocks are full, or be evicted when the mempool size limit (default 300 MB) is reached, dropping the lowest fee-rate entries first. Use RBF (bump fee) or CPFP (child transaction) to accelerate a stuck low-fee transaction.', rbf: 'Replace-By-Fee (BIP125): a sender can broadcast a new version of an unconfirmed transaction with a higher fee. Bitcoin Core replaces the original in the mempool if the new transaction pays at least the incremental relay fee. Miners prioritize higher-fee transactions, so RBF helps stuck transactions get confirmed faster.', cpfp: 'Child-Pays-For-Parent: when a parent transaction carries a low fee, a child transaction spending one of its outputs can include a high enough fee to make the combined fee rate attractive for miners. Miners evaluate the package together, so the child "pulls" the parent into the next block.', - reorg: 'A chain reorganization occurs when a competing chain with more cumulative proof-of-work becomes the canonical chain. Bitcoin Core switches to the longer chain, reverting any blocks that are no longer in the main chain. Transactions from reverted blocks return to the mempool and await re-confirmation.', - cluster: 'Cluster mempool (Bitcoin Core v28+) groups related transactions into clusters and evaluates their combined fee rate for eviction and mining decisions. This improves the accuracy of fee estimation and mempool management. Earlier versions use per-transaction ancestor/descendant limits instead.', + reorg: + 'A chain reorganization occurs when a competing chain with more cumulative proof-of-work becomes the canonical chain. Bitcoin Core switches to the longer chain, reverting any blocks that are no longer in the main chain. Transactions from reverted blocks return to the mempool and await re-confirmation.', + cluster: + 'Cluster mempool (Bitcoin Core v28+) groups related transactions into clusters and evaluates their combined fee rate for eviction and mining decisions. This improves the accuracy of fee estimation and mempool management. Earlier versions use per-transaction ancestor/descendant limits instead.', zmq: 'Bitcoin Core publishes internal events over ZMQ (ZeroMQ) push sockets. NodeScope subscribes to rawtx (new transactions entering the mempool) and rawblock (new blocks connected to the chain). Each event is cross-validated with RPC to confirm on-chain data.', - proof: 'The Proof Report captures all verifiable data points from a demo run: RPC responses, ZMQ event timestamps, TXID, block hash, fee details, and confirmation count. It can be exported as JSON and verified independently against a Bitcoin Core node.', + proof: + 'The Proof Report captures all verifiable data points from a demo run: RPC responses, ZMQ event timestamps, TXID, block hash, fee details, and confirmation count. It can be exported as JSON and verified independently against a Bitcoin Core node.', whyMatters: 'Why this matters', }, @@ -368,19 +413,24 @@ export const enUS: Translations = { title: 'Operational Alerts', allGood: 'All systems operational', rpcOffline: 'Bitcoin Core RPC offline', - rpcOfflineDesc: 'The API cannot reach Bitcoin Core via RPC. Check that bitcoind is running and reachable.', + rpcOfflineDesc: + 'The API cannot reach Bitcoin Core via RPC. Check that bitcoind is running and reachable.', zmqStale: 'ZMQ event stream stale', - zmqStaleDesc: 'No ZMQ events received recently. The monitor may have disconnected from the ZMQ socket.', + zmqStaleDesc: + 'No ZMQ events received recently. The monitor may have disconnected from the ZMQ socket.', demoFailure: 'Guided Demo reported failures', demoFailureDesc: 'One or more Guided Demo steps ended in error. Run Reset and try again.', simulationError: 'Live simulation errors detected', simulationErrorDesc: 'The auto-mining simulation encountered errors. Check logs for details.', clusterUnavailable: 'Cluster mempool RPCs unavailable', - clusterUnavailableDesc: 'Bitcoin Core v28+ is required for cluster mempool RPCs. Current environment uses an earlier version.', + clusterUnavailableDesc: + 'Bitcoin Core v28+ is required for cluster mempool RPCs. Current environment uses an earlier version.', reorgExperimental: 'Reorg Lab is experimental', - reorgExperimentalDesc: 'Reorg Lab runs only on regtest. Results may vary. Not suitable for production use.', + reorgExperimentalDesc: + 'Reorg Lab runs only on regtest. Results may vary. Not suitable for production use.', metricsUnavailable: 'Prometheus metrics unavailable', - metricsUnavailableDesc: 'prometheus-client is not installed. Install it to enable the /metrics endpoint.', + metricsUnavailableDesc: + 'prometheus-client is not installed. Install it to enable the /metrics endpoint.', severity: { critical: 'Critical', warning: 'Warning', @@ -427,6 +477,10 @@ export const enUS: Translations = { storageInfo: 'Storage is active. Runs are persisted across restarts.', sqlite: 'SQLite (local)', memory: 'Memory (ephemeral)', + exportJson: 'Export JSON', + exportCsv: 'Export CSV', + exportDownloaded: 'Export downloaded', + exportFailed: 'Export failed', }, fees: { @@ -442,13 +496,14 @@ export const enUS: Translations = { errors: 'Errors / Notes', refresh: '↻ Refresh', compareWithRealFees: 'Compare with scenario fee rates', - noHistoryAvailable: 'No scenario runs available for comparison. Run a demo or policy scenario first.', + noHistoryAvailable: + 'No scenario runs available for comparison. Run a demo or policy scenario first.', regtestWarning: 'Running in regtest: estimatesmartfee has no real fee market. ' + 'Results may be unavailable. This does not represent mainnet fee conditions.', learnMoreTitle: 'How does fee estimation work?', learnMoreBody: - 'Bitcoin Core\'s estimatesmartfee predicts the minimum fee rate (sat/vB) needed for ' + + "Bitcoin Core's estimatesmartfee predicts the minimum fee rate (sat/vB) needed for " + 'a transaction to confirm within a target number of blocks. ' + 'It analyses past block data — in regtest there is no real market, so results are often unavailable. ' + 'On mainnet or signet, this is a critical tool for wallets to avoid overpaying or getting stuck.', @@ -461,7 +516,7 @@ export const enUS: Translations = { conversionNote: 'Conversion: BTC/kvB × 100,000 = sat/vB', navLabel: 'Fee Estimation', explainBox: - 'The Fee Estimation Playground calls Bitcoin Core\'s estimatesmartfee RPC for different ' + + "The Fee Estimation Playground calls Bitcoin Core's estimatesmartfee RPC for different " + 'confirmation targets and shows the results side-by-side. In regtest there is no real fee market, ' + 'so results are honestly marked as unavailable or limited.', }, diff --git a/frontend/src/i18n/glossary.ts b/frontend/src/i18n/glossary.ts index 89918ae..703bf47 100644 --- a/frontend/src/i18n/glossary.ts +++ b/frontend/src/i18n/glossary.ts @@ -9,38 +9,52 @@ export interface GlossaryEntry { export const glossary: GlossaryEntry[] = [ { term: 'Bitcoin Core', - 'pt-BR': 'Implementação de referência do protocolo Bitcoin. Executa validação completa de blocos e transações.', - 'en-US': 'The reference implementation of the Bitcoin protocol. Performs full block and transaction validation.', + 'pt-BR': + 'Implementação de referência do protocolo Bitcoin. Executa validação completa de blocos e transações.', + 'en-US': + 'The reference implementation of the Bitcoin protocol. Performs full block and transaction validation.', }, { term: 'RPC', - 'pt-BR': 'Interface usada pelo NodeScope para consultar e executar comandos no Bitcoin Core via JSON-RPC.', - 'en-US': 'Interface used by NodeScope to query and execute commands on Bitcoin Core via JSON-RPC.', + 'pt-BR': + 'Interface usada pelo NodeScope para consultar e executar comandos no Bitcoin Core via JSON-RPC.', + 'en-US': + 'Interface used by NodeScope to query and execute commands on Bitcoin Core via JSON-RPC.', }, { term: 'ZMQ', - 'pt-BR': 'Canal de eventos em tempo real usado pelo Bitcoin Core para publicar transações e blocos via ZeroMQ.', - 'en-US': 'Real-time event channel used by Bitcoin Core to publish transactions and blocks via ZeroMQ.', + 'pt-BR': + 'Canal de eventos em tempo real usado pelo Bitcoin Core para publicar transações e blocos via ZeroMQ.', + 'en-US': + 'Real-time event channel used by Bitcoin Core to publish transactions and blocks via ZeroMQ.', }, { term: 'Regtest', - 'pt-BR': 'Rede local de teste do Bitcoin Core. Permite minerar blocos instantaneamente sem dinheiro real.', - 'en-US': 'Bitcoin Core local test network. Allows instant block mining with no real money involved.', + 'pt-BR': + 'Rede local de teste do Bitcoin Core. Permite minerar blocos instantaneamente sem dinheiro real.', + 'en-US': + 'Bitcoin Core local test network. Allows instant block mining with no real money involved.', }, { term: 'Mempool', - 'pt-BR': 'Área temporária onde transações válidas aguardam confirmação em bloco. Cada nó mantém sua própria mempool.', - 'en-US': 'Temporary holding area for valid unconfirmed transactions awaiting block inclusion. Each node maintains its own mempool.', + 'pt-BR': + 'Área temporária onde transações válidas aguardam confirmação em bloco. Cada nó mantém sua própria mempool.', + 'en-US': + 'Temporary holding area for valid unconfirmed transactions awaiting block inclusion. Each node maintains its own mempool.', }, { term: 'TXID', - 'pt-BR': 'Identificador único de uma transação Bitcoin. É o hash SHA256d da transação serializada.', - 'en-US': 'Unique identifier for a Bitcoin transaction. It is the SHA256d hash of the serialized transaction.', + 'pt-BR': + 'Identificador único de uma transação Bitcoin. É o hash SHA256d da transação serializada.', + 'en-US': + 'Unique identifier for a Bitcoin transaction. It is the SHA256d hash of the serialized transaction.', }, { term: 'WTXID', - 'pt-BR': 'Transaction ID que inclui dados de witness (SegWit). Diferente do TXID quando há inputs SegWit.', - 'en-US': 'Transaction ID that includes witness data (SegWit). Differs from TXID when SegWit inputs are present.', + 'pt-BR': + 'Transaction ID que inclui dados de witness (SegWit). Diferente do TXID quando há inputs SegWit.', + 'en-US': + 'Transaction ID that includes witness data (SegWit). Differs from TXID when SegWit inputs are present.', }, { term: 'Input', @@ -49,13 +63,16 @@ export const glossary: GlossaryEntry[] = [ }, { term: 'Output', - 'pt-BR': 'Novo UTXO criado por esta transação. Define valor e condições de gasto (scriptPubKey).', - 'en-US': 'New UTXO created by this transaction. Defines the value and spending conditions (scriptPubKey).', + 'pt-BR': + 'Novo UTXO criado por esta transação. Define valor e condições de gasto (scriptPubKey).', + 'en-US': + 'New UTXO created by this transaction. Defines the value and spending conditions (scriptPubKey).', }, { term: 'Change', 'pt-BR': 'Output que devolve o troco ao remetente após subtrair o valor enviado e a taxa.', - 'en-US': 'Output that returns excess funds to the sender after the sent amount and fee are deducted.', + 'en-US': + 'Output that returns excess funds to the sender after the sent amount and fee are deducted.', }, { term: 'Fee', @@ -69,13 +86,17 @@ export const glossary: GlossaryEntry[] = [ }, { term: 'vbytes', - 'pt-BR': 'Tamanho virtual da transação em bytes. Transações SegWit têm vbytes menores que o tamanho bruto.', - 'en-US': 'Virtual size of the transaction in bytes. SegWit transactions have smaller vbytes than raw byte size.', + 'pt-BR': + 'Tamanho virtual da transação em bytes. Transações SegWit têm vbytes menores que o tamanho bruto.', + 'en-US': + 'Virtual size of the transaction in bytes. SegWit transactions have smaller vbytes than raw byte size.', }, { term: 'Weight', - 'pt-BR': 'Medida interna de tamanho pós-SegWit. 1 vbyte = 4 weight units (WU). Limita o tamanho do bloco.', - 'en-US': 'Internal post-SegWit size measure. 1 vbyte = 4 weight units (WU). Constrains block size.', + 'pt-BR': + 'Medida interna de tamanho pós-SegWit. 1 vbyte = 4 weight units (WU). Limita o tamanho do bloco.', + 'en-US': + 'Internal post-SegWit size measure. 1 vbyte = 4 weight units (WU). Constrains block size.', }, { term: 'Block', @@ -94,97 +115,132 @@ export const glossary: GlossaryEntry[] = [ }, { term: 'Confirmation', - 'pt-BR': 'Número de blocos minerados após o bloco que inclui a transação. Mais confirmações = mais segurança.', - 'en-US': 'Number of blocks mined after the block that includes the transaction. More confirmations = more security.', + 'pt-BR': + 'Número de blocos minerados após o bloco que inclui a transação. Mais confirmações = mais segurança.', + 'en-US': + 'Number of blocks mined after the block that includes the transaction. More confirmations = more security.', }, { term: 'rawtx', - 'pt-BR': 'Evento ZMQ publicado quando uma nova transação entra na mempool. Contém os bytes brutos da transação.', - 'en-US': 'ZMQ event published when a new transaction enters the mempool. Contains raw transaction bytes.', + 'pt-BR': + 'Evento ZMQ publicado quando uma nova transação entra na mempool. Contém os bytes brutos da transação.', + 'en-US': + 'ZMQ event published when a new transaction enters the mempool. Contains raw transaction bytes.', }, { term: 'rawblock', - 'pt-BR': 'Evento ZMQ publicado quando um novo bloco é conectado à cadeia. Contém os bytes brutos do bloco.', - 'en-US': 'ZMQ event published when a new block is connected to the chain. Contains raw block bytes.', + 'pt-BR': + 'Evento ZMQ publicado quando um novo bloco é conectado à cadeia. Contém os bytes brutos do bloco.', + 'en-US': + 'ZMQ event published when a new block is connected to the chain. Contains raw block bytes.', }, { term: 'Normal transaction', - 'pt-BR': 'Transação padrão que atende à taxa mínima de relay. Entra na mempool imediatamente e é confirmada no próximo bloco minerado.', - 'en-US': 'Standard transaction meeting the minimum relay fee. Enters the mempool immediately and confirms in the next mined block.', + 'pt-BR': + 'Transação padrão que atende à taxa mínima de relay. Entra na mempool imediatamente e é confirmada no próximo bloco minerado.', + 'en-US': + 'Standard transaction meeting the minimum relay fee. Enters the mempool immediately and confirms in the next mined block.', }, { term: 'Low fee', - 'pt-BR': 'Transação com taxa próxima do mínimo (1 sat/vbyte). Pode demorar para confirmar ou ser despejada se a mempool encher.', - 'en-US': 'Transaction with fee rate near the minimum threshold (1 sat/vbyte). May be slow to confirm or evicted if the mempool fills.', + 'pt-BR': + 'Transação com taxa próxima do mínimo (1 sat/vbyte). Pode demorar para confirmar ou ser despejada se a mempool encher.', + 'en-US': + 'Transaction with fee rate near the minimum threshold (1 sat/vbyte). May be slow to confirm or evicted if the mempool fills.', }, { term: 'RBF', - 'pt-BR': 'Replace-By-Fee (BIP125): permite substituir uma transação não confirmada por outra com taxa maior.', - 'en-US': 'Replace-By-Fee (BIP125): allows replacing an unconfirmed transaction with a higher-fee version.', + 'pt-BR': + 'Replace-By-Fee (BIP125): permite substituir uma transação não confirmada por outra com taxa maior.', + 'en-US': + 'Replace-By-Fee (BIP125): allows replacing an unconfirmed transaction with a higher-fee version.', }, { term: 'CPFP', - 'pt-BR': 'Child-Pays-For-Parent: uma transação filha com taxa alta ajuda a confirmar a transação pai.', - 'en-US': 'Child-Pays-For-Parent: a high-fee child transaction helps confirm a stuck parent transaction.', + 'pt-BR': + 'Child-Pays-For-Parent: uma transação filha com taxa alta ajuda a confirmar a transação pai.', + 'en-US': + 'Child-Pays-For-Parent: a high-fee child transaction helps confirm a stuck parent transaction.', }, { term: 'Reorg', - 'pt-BR': 'Reorganização da cadeia quando uma cadeia alternativa com mais trabalho passa a ser considerada válida.', - 'en-US': 'Chain reorganization when an alternative chain with more proof-of-work becomes canonical.', + 'pt-BR': + 'Reorganização da cadeia quando uma cadeia alternativa com mais trabalho passa a ser considerada válida.', + 'en-US': + 'Chain reorganization when an alternative chain with more proof-of-work becomes canonical.', }, { term: 'Cluster mempool', - 'pt-BR': 'Recurso do Bitcoin Core v28+ que agrupa transações relacionadas para melhorar decisões de fee.', - 'en-US': 'Bitcoin Core v28+ feature that groups related transactions to improve fee-based decisions.', + 'pt-BR': + 'Recurso do Bitcoin Core v28+ que agrupa transações relacionadas para melhorar decisões de fee.', + 'en-US': + 'Bitcoin Core v28+ feature that groups related transactions to improve fee-based decisions.', }, { term: 'Proof Report', - 'pt-BR': 'Resumo auditável gerado pelo NodeScope com evidências verificáveis da execução da demo.', - 'en-US': 'Auditable summary generated by NodeScope with verifiable evidence of the demo execution.', + 'pt-BR': + 'Resumo auditável gerado pelo NodeScope com evidências verificáveis da execução da demo.', + 'en-US': + 'Auditable summary generated by NodeScope with verifiable evidence of the demo execution.', }, { term: 'Wallet', - 'pt-BR': 'Carteira gerenciada pelo Bitcoin Core. Armazena chaves e UTXOs. No regtest, usada apenas para teste.', - 'en-US': 'Wallet managed by Bitcoin Core. Stores keys and UTXOs. In regtest, used for testing only.', + 'pt-BR': + 'Carteira gerenciada pelo Bitcoin Core. Armazena chaves e UTXOs. No regtest, usada apenas para teste.', + 'en-US': + 'Wallet managed by Bitcoin Core. Stores keys and UTXOs. In regtest, used for testing only.', }, { term: 'replaceable', - 'pt-BR': 'Indica se uma transação sinaliza RBF (sequência < 0xFFFFFFFE). Pode ser substituída por taxa maior.', - 'en-US': 'Indicates whether a transaction signals RBF (sequence < 0xFFFFFFFE). Can be replaced with a higher fee.', + 'pt-BR': + 'Indica se uma transação sinaliza RBF (sequência < 0xFFFFFFFE). Pode ser substituída por taxa maior.', + 'en-US': + 'Indicates whether a transaction signals RBF (sequence < 0xFFFFFFFE). Can be replaced with a higher fee.', }, { term: 'estimatesmartfee', - 'pt-BR': 'RPC do Bitcoin Core que estima a taxa mínima (BTC/kvB) para confirmação em N blocos, com base no histórico de blocos.', - 'en-US': 'Bitcoin Core RPC that estimates the minimum fee rate (BTC/kvB) for confirmation within N blocks, based on block history.', + 'pt-BR': + 'RPC do Bitcoin Core que estima a taxa mínima (BTC/kvB) para confirmação em N blocos, com base no histórico de blocos.', + 'en-US': + 'Bitcoin Core RPC that estimates the minimum fee rate (BTC/kvB) for confirmation within N blocks, based on block history.', }, { term: 'confirmation target', - 'pt-BR': 'Número de blocos dentro do qual a transação deve ser confirmada. Menor alvo = maior taxa estimada.', - 'en-US': 'Number of blocks within which the transaction should confirm. Lower target = higher estimated fee.', + 'pt-BR': + 'Número de blocos dentro do qual a transação deve ser confirmada. Menor alvo = maior taxa estimada.', + 'en-US': + 'Number of blocks within which the transaction should confirm. Lower target = higher estimated fee.', }, { term: 'sat/vB', 'pt-BR': 'Satoshis por vbyte — unidade padrão de taxa em Bitcoin. 1 sat/vB = 100.000 BTC/kvB.', - 'en-US': 'Satoshis per virtual byte — the standard Bitcoin fee rate unit. 1 sat/vB = 100,000 BTC/kvB.', + 'en-US': + 'Satoshis per virtual byte — the standard Bitcoin fee rate unit. 1 sat/vB = 100,000 BTC/kvB.', }, { term: 'BTC/kvB', - 'pt-BR': 'Bitcoin por kilovbyte — unidade retornada pelo RPC estimatesmartfee. Converta para sat/vB multiplicando por 100.000.', - 'en-US': 'Bitcoin per kilovirtual-byte — the unit returned by estimatesmartfee. Convert to sat/vB by multiplying by 100,000.', + 'pt-BR': + 'Bitcoin por kilovbyte — unidade retornada pelo RPC estimatesmartfee. Converta para sat/vB multiplicando por 100.000.', + 'en-US': + 'Bitcoin per kilovirtual-byte — the unit returned by estimatesmartfee. Convert to sat/vB by multiplying by 100,000.', }, { term: 'economical mode', - 'pt-BR': 'Modo de estimativa que prioriza taxa mais baixa, aceitando confirmação potencialmente mais lenta.', - 'en-US': 'Fee estimation mode that prioritises a lower fee, accepting potentially slower confirmation.', + 'pt-BR': + 'Modo de estimativa que prioriza taxa mais baixa, aceitando confirmação potencialmente mais lenta.', + 'en-US': + 'Fee estimation mode that prioritises a lower fee, accepting potentially slower confirmation.', }, { term: 'conservative mode', - 'pt-BR': 'Modo de estimativa mais cauteloso que sugere taxa mais alta para maior probabilidade de confirmação rápida.', - 'en-US': 'More cautious estimation mode that suggests a higher fee for a better chance of fast confirmation.', + 'pt-BR': + 'Modo de estimativa mais cauteloso que sugere taxa mais alta para maior probabilidade de confirmação rápida.', + 'en-US': + 'More cautious estimation mode that suggests a higher fee for a better chance of fast confirmation.', }, ] export function getGlossaryEntry(term: string, lang: Lang): string | undefined { - const entry = glossary.find(g => g.term.toLowerCase() === term.toLowerCase()) + const entry = glossary.find((g) => g.term.toLowerCase() === term.toLowerCase()) return entry ? entry[lang] : undefined } diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 009ab95..ac8a2d8 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -13,14 +13,18 @@ export function getStoredLang(): Lang { try { const v = localStorage.getItem(STORAGE_KEY) if (v === 'pt-BR' || v === 'en-US') return v - } catch { /* ignore */ } + } catch { + /* ignore */ + } return 'en-US' } export function setStoredLang(lang: Lang): void { try { localStorage.setItem(STORAGE_KEY, lang) - } catch { /* ignore */ } + } catch { + /* ignore */ + } } export function getTranslations(lang: Lang): Translations { diff --git a/frontend/src/i18n/ptBR.ts b/frontend/src/i18n/ptBR.ts index bf3690a..9dbb786 100644 --- a/frontend/src/i18n/ptBR.ts +++ b/frontend/src/i18n/ptBR.ts @@ -45,7 +45,8 @@ export const ptBR: Translations = { header: { title: 'NodeScope', newSession: '↺ Nova Sessão', - newSessionConfirm: 'Limpar todos os eventos capturados e iniciar uma nova sessão de observação?', + newSessionConfirm: + 'Limpar todos os eventos capturados e iniciar uma nova sessão de observação?', apiStatus: 'API', rpcStatus: 'RPC', sseStatus: 'SSE', @@ -53,7 +54,8 @@ export const ptBR: Translations = { demo: { title: 'Demo Guiada — Avalie em 1 Minuto', - subtitle: 'Ciclo completo do Bitcoin Core: RPC → carteira → mineração → envio → mempool → ZMQ → confirmação → prova', + subtitle: + 'Ciclo completo do Bitcoin Core: RPC → carteira → mineração → envio → mempool → ZMQ → confirmação → prova', stepsComplete: 'etapas concluídas', errors: 'erro(s)', demoRunning: '● Demo em execução…', @@ -64,20 +66,34 @@ export const ptBR: Translations = { no: 'não', na: 'n/d', stepDesc: { - check_rpc: 'Verifica se a conexão RPC com o Bitcoin Core está acessível e responde ao getblockchaininfo.', - check_zmq: 'Consulta getzmqnotifications para confirmar que os sockets ZMQ estão configurados para os canais rawtx e rawblock.', - create_or_load_wallet: 'Cria ou carrega a carteira descriptor nodescope_demo usada para envio e recebimento na demo.', - generate_mining_address: 'Gera um novo endereço bech32 (P2WPKH) da carteira para receber as recompensas de bloco.', - mine_initial_blocks: 'Minera 101 blocos para que os outputs coinbase fiquem maduros (≥100 confirmações) e gastos.', - create_destination_address: 'Gera um segundo endereço da carteira para ser usado como destinatário da transação de demonstração.', - send_demo_transaction: 'Transmite uma transação de 0,001 BTC da carteira para o endereço de destino via RPC sendtoaddress.', - detect_mempool_entry: 'Chama getmempoolentry para confirmar que a transação está no pool de transações não confirmadas do nó.', - detect_zmq_rawtx: 'Captura o evento ZMQ rawtx que o Bitcoin Core emite quando uma transação entra na mempool.', - decode_transaction: 'Chama getrawtransaction + decoderawtransaction para extrair entradas, saídas, taxa, vbytes, peso e tipos de script.', - mine_confirmation_block: 'Minera 1 bloco via generatetoaddress para incluir a transação e gerar sua primeira confirmação.', - detect_zmq_rawblock: 'Captura o evento ZMQ rawblock que o Bitcoin Core emite quando um novo bloco é conectado à cadeia.', - confirm_transaction: 'Chama gettransaction para verificar que a transação tem ≥1 confirmação e está permanentemente on-chain.', - generate_proof_report: 'Reúne todos os dados coletados (chamadas RPC, eventos ZMQ, TXID, taxas, altura do bloco, confirmações) em uma prova exportável.', + check_rpc: + 'Verifica se a conexão RPC com o Bitcoin Core está acessível e responde ao getblockchaininfo.', + check_zmq: + 'Consulta getzmqnotifications para confirmar que os sockets ZMQ estão configurados para os canais rawtx e rawblock.', + create_or_load_wallet: + 'Cria ou carrega a carteira descriptor nodescope_demo usada para envio e recebimento na demo.', + generate_mining_address: + 'Gera um novo endereço bech32 (P2WPKH) da carteira para receber as recompensas de bloco.', + mine_initial_blocks: + 'Minera 101 blocos para que os outputs coinbase fiquem maduros (≥100 confirmações) e gastos.', + create_destination_address: + 'Gera um segundo endereço da carteira para ser usado como destinatário da transação de demonstração.', + send_demo_transaction: + 'Transmite uma transação de 0,001 BTC da carteira para o endereço de destino via RPC sendtoaddress.', + detect_mempool_entry: + 'Chama getmempoolentry para confirmar que a transação está no pool de transações não confirmadas do nó.', + detect_zmq_rawtx: + 'Captura o evento ZMQ rawtx que o Bitcoin Core emite quando uma transação entra na mempool.', + decode_transaction: + 'Chama getrawtransaction + decoderawtransaction para extrair entradas, saídas, taxa, vbytes, peso e tipos de script.', + mine_confirmation_block: + 'Minera 1 bloco via generatetoaddress para incluir a transação e gerar sua primeira confirmação.', + detect_zmq_rawblock: + 'Captura o evento ZMQ rawblock que o Bitcoin Core emite quando um novo bloco é conectado à cadeia.', + confirm_transaction: + 'Chama gettransaction para verificar que a transação tem ≥1 confirmação e está permanentemente on-chain.', + generate_proof_report: + 'Reúne todos os dados coletados (chamadas RPC, eventos ZMQ, TXID, taxas, altura do bloco, confirmações) em uma prova exportável.', }, }, @@ -129,7 +145,8 @@ export const ptBR: Translations = { zmq: { title: 'Fita de Eventos ZMQ', - subtitle: 'Eventos em tempo real do Bitcoin Core: streams rawtx e rawblock correlacionados com dados RPC', + subtitle: + 'Eventos em tempo real do Bitcoin Core: streams rawtx e rawblock correlacionados com dados RPC', events: 'eventos', noEvents: 'Nenhum evento ainda. Aguardando o Bitcoin Core…', connecting: 'Conectando…', @@ -147,7 +164,8 @@ export const ptBR: Translations = { policy: { title: 'Arena de Políticas de Mempool', - subtitle: 'Observe como o Bitcoin Core trata transações com diferentes políticas de taxa, RBF e CPFP', + subtitle: + 'Observe como o Bitcoin Core trata transações com diferentes políticas de taxa, RBF e CPFP', scenarios: 'Cenários', normal: 'Transação Normal', lowFee: 'Taxa Baixa', @@ -163,19 +181,27 @@ export const ptBR: Translations = { statusLegend: 'Status', notesTitle: 'Notas', noteRegtest: 'Todos os cenários executam em regtest — sem fundos reais e sem mainnet.', - noteRbf: 'RBF exige walletrbf=1 na configuração do bitcoind; caso contrário é marcado como experimental.', - noteCpfp: 'A construção CPFP usa o pipeline de transação bruta e pode usar fallback se o UTXO pai não for rastreado pela carteira.', - noteStatus: 'Experimental significa que o cenário foi tentado com fallback seguro. Erro significa que o cenário parou.', - descNormal: 'Envia uma transação padrão, observa a entrada na mempool, minera um bloco e confirma.', - descLowFee: 'Envia com fee_rate=1 sat/vbyte e compara os dados de taxa com uma tx normal. Usa o parâmetro fee_rate do sendtoaddress (BC 26+).', - descRbf: 'Envia uma transação substituível e usa bumpfee para substituí-la por uma versão com taxa maior antes da confirmação.', - descCpfp: 'Envia um pai com taxa baixa e constrói um filho que gasta a saída não confirmada com taxa maior para elevar a taxa combinada do pacote.', + noteRbf: + 'RBF exige walletrbf=1 na configuração do bitcoind; caso contrário é marcado como experimental.', + noteCpfp: + 'A construção CPFP usa o pipeline de transação bruta e pode usar fallback se o UTXO pai não for rastreado pela carteira.', + noteStatus: + 'Experimental significa que o cenário foi tentado com fallback seguro. Erro significa que o cenário parou.', + descNormal: + 'Envia uma transação padrão, observa a entrada na mempool, minera um bloco e confirma.', + descLowFee: + 'Envia com fee_rate=1 sat/vbyte e compara os dados de taxa com uma tx normal. Usa o parâmetro fee_rate do sendtoaddress (BC 26+).', + descRbf: + 'Envia uma transação substituível e usa bumpfee para substituí-la por uma versão com taxa maior antes da confirmação.', + descCpfp: + 'Envia um pai com taxa baixa e constrói um filho que gasta a saída não confirmada com taxa maior para elevar a taxa combinada do pacote.', viewOnDashboard: '↗ Ver no Dashboard', }, reorg: { title: 'Laboratório de Reorg', - subtitle: 'Simula uma reorganização controlada em regtest e observa o impacto nas confirmações de transações', + subtitle: + 'Simula uma reorganização controlada em regtest e observa o impacto nas confirmações de transações', runReorg: '▶ Executar Reorg', running: 'Executando…', reset: 'Reiniciar', @@ -200,7 +226,8 @@ export const ptBR: Translations = { finalConfirmations: 'Confirmações finais', chainRecovery: 'Recuperação da cadeia', reconsiderBlockCalled: 'reconsiderblock executado', - warningExperimental: 'Este cenário está marcado como experimental. Reorgs em regtest são controladas e seguras.', + warningExperimental: + 'Este cenário está marcado como experimental. Reorgs em regtest são controladas e seguras.', warningRestored: 'A cadeia foi restaurada por meio de um novo bloco após a invalidação.', yes: 'sim', no: 'não', @@ -221,7 +248,8 @@ export const ptBR: Translations = { cluster: { title: 'Cluster Mempool', - subtitle: 'Detecta suporte aos RPCs de cluster mempool e inspeciona relações de pacotes na mempool', + subtitle: + 'Detecta suporte aos RPCs de cluster mempool e inspeciona relações de pacotes na mempool', supported: 'RPCs de cluster mempool detectados', notSupported: 'RPCs de cluster mempool não disponíveis nesta versão do Bitcoin Core', fallback: 'Exibindo fallback: dados padrão da mempool', @@ -325,42 +353,64 @@ export const ptBR: Translations = { }, panelDesc: { - intelligence: 'Visão consolidada da saúde do nó: status de conectividade, pressão da mempool e classificação de eventos recentes. Derivado de dados RPC e ZMQ ao vivo.', - replayEngine: 'Lê o log NDJSON gravado pelo monitor. Mostra quantos eventos ZMQ foram capturados e estão disponíveis para replay ou análise.', - nodeHealth: 'Pontuação de saúde composta (0–100) calculada a partir de conectividade RPC, subscrição ZMQ, acesso à mempool e atividade recente de blocos.', - liveFeed: 'Stream em tempo real de eventos ZMQ conforme chegam do Bitcoin Core — rawtx (novas transações) e rawblock (novos blocos).', - events: 'Eventos recentes capturados do stream ZMQ e armazenados no log. Inclui eventos de transação e bloco.', - classifications: 'Cada evento capturado é classificado por tipo (coinbase, complexo, simples, etc.) usando heurísticas aplicadas à transação decodificada.', - rpcZmqSync: 'Compara a altura do bloco mais recente via RPC com o último evento rawblock do ZMQ para detectar divergência entre as duas fontes de dados.', + intelligence: + 'Visão consolidada da saúde do nó: status de conectividade, pressão da mempool e classificação de eventos recentes. Derivado de dados RPC e ZMQ ao vivo.', + replayEngine: + 'Lê o log NDJSON gravado pelo monitor. Mostra quantos eventos ZMQ foram capturados e estão disponíveis para replay ou análise.', + nodeHealth: + 'Pontuação de saúde composta (0–100) calculada a partir de conectividade RPC, subscrição ZMQ, acesso à mempool e atividade recente de blocos.', + liveFeed: + 'Stream em tempo real de eventos ZMQ conforme chegam do Bitcoin Core — rawtx (novas transações) e rawblock (novos blocos).', + events: + 'Eventos recentes capturados do stream ZMQ e armazenados no log. Inclui eventos de transação e bloco.', + classifications: + 'Cada evento capturado é classificado por tipo (coinbase, complexo, simples, etc.) usando heurísticas aplicadas à transação decodificada.', + rpcZmqSync: + 'Compara a altura do bloco mais recente via RPC com o último evento rawblock do ZMQ para detectar divergência entre as duas fontes de dados.', }, healthScore: { rpc: 'Conexão RPC com o Bitcoin Core está ativa. Contribui +40 à pontuação. Sem RPC o nó não pode ser consultado.', zmq: 'Stream ZMQ está conectado e recebendo eventos. Contribui +30. Sem ZMQ, eventos ao vivo de transações e blocos ficam indisponíveis.', - mempool: 'Dados da mempool acessíveis via RPC. Contribui +20. Indica que o nó está processando e aceitando transações.', - blocks: 'Atividade recente de blocos detectada (pontuação ≥ 90). Contribui +10. Confirma que a cadeia está progredindo ativamente.', + mempool: + 'Dados da mempool acessíveis via RPC. Contribui +20. Indica que o nó está processando e aceitando transações.', + blocks: + 'Atividade recente de blocos detectada (pontuação ≥ 90). Contribui +10. Confirma que a cadeia está progredindo ativamente.', }, explain: { - dashboard: 'Visão geral em tempo real do nó Bitcoin Core. Exibe status de conectividade (RPC, ZMQ, SSE), estado da mempool, últimos blocos e transações, e feed de eventos ao vivo.', - guidedDemo: 'Executa o ciclo completo de uma transação Bitcoin Core: criação de carteira → chamada RPC → entrada na mempool → detecção de evento ZMQ → confirmação em bloco → geração de prova. Cada etapa é individualmente verificável.', - inspector: 'Decodifica qualquer transação usando o RPC do Bitcoin Core (getrawtransaction + decoderawtransaction). Exibe entradas, saídas, tipos de script, taxas, vbytes, peso e status de confirmação.', - zmqTape: 'Exibe eventos brutos publicados pelo Bitcoin Core via ZMQ (canais rawtx e rawblock). Cada evento é correlacionado com dados RPC para verificar autenticidade e extrair detalhes on-chain.', - policyArena: 'Demonstra como o Bitcoin Core aplica políticas de mempool: transação com taxa normal, rejeição por taxa abaixo do mínimo, substituição RBF (Replace-By-Fee) e avaliação de pacote CPFP (Child-Pays-For-Parent).', - reorgLab: 'Simula uma reorganização controlada em regtest: minera blocos em uma cadeia e depois minera uma cadeia concorrente mais longa para disparar a reorg. Observe como transações confirmadas retornam à mempool.', - clusterMempool: 'Detecta se o nó Bitcoin Core conectado suporta RPCs de cluster mempool (introduzidos na v28+). Usa dados padrão da mempool com divulgação honesta quando indisponível.', - proofReport: 'Resumo auditável da execução da demo: chamadas RPC, eventos ZMQ, TXID, altura do bloco, confirmações. Pode ser exportado como JSON para verificação independente.', + dashboard: + 'Visão geral em tempo real do nó Bitcoin Core. Exibe status de conectividade (RPC, ZMQ, SSE), estado da mempool, últimos blocos e transações, e feed de eventos ao vivo.', + guidedDemo: + 'Executa o ciclo completo de uma transação Bitcoin Core: criação de carteira → chamada RPC → entrada na mempool → detecção de evento ZMQ → confirmação em bloco → geração de prova. Cada etapa é individualmente verificável.', + inspector: + 'Decodifica qualquer transação usando o RPC do Bitcoin Core (getrawtransaction + decoderawtransaction). Exibe entradas, saídas, tipos de script, taxas, vbytes, peso e status de confirmação.', + zmqTape: + 'Exibe eventos brutos publicados pelo Bitcoin Core via ZMQ (canais rawtx e rawblock). Cada evento é correlacionado com dados RPC para verificar autenticidade e extrair detalhes on-chain.', + policyArena: + 'Demonstra como o Bitcoin Core aplica políticas de mempool: transação com taxa normal, rejeição por taxa abaixo do mínimo, substituição RBF (Replace-By-Fee) e avaliação de pacote CPFP (Child-Pays-For-Parent).', + reorgLab: + 'Simula uma reorganização controlada em regtest: minera blocos em uma cadeia e depois minera uma cadeia concorrente mais longa para disparar a reorg. Observe como transações confirmadas retornam à mempool.', + clusterMempool: + 'Detecta se o nó Bitcoin Core conectado suporta RPCs de cluster mempool (introduzidos na v28+). Usa dados padrão da mempool com divulgação honesta quando indisponível.', + proofReport: + 'Resumo auditável da execução da demo: chamadas RPC, eventos ZMQ, TXID, altura do bloco, confirmações. Pode ser exportado como JSON para verificação independente.', }, learn: { - normalTx: 'Uma transação Bitcoin padrão tem uma ou mais entradas (gastando UTXOs) e saídas. Sua taxa por vbyte determina a prioridade na mempool. Mineradores selecionam transações em ordem decrescente de taxa. A taxa mínima de relay padrão é 1 sat/vbyte — qualquer transação que atenda a esse limite entra na mempool e aguarda inclusão em um bloco.', - lowFee: 'Transações abaixo da taxa mínima de relay são rejeitadas pelo Bitcoin Core. Transações logo acima do mínimo podem ficar presas na mempool quando os blocos estão cheios, ou ser despejadas quando o limite de tamanho da mempool (padrão 300 MB) é atingido, removendo primeiro as de menor taxa. Use RBF (bumpfee) ou CPFP (transação filha) para acelerar uma transação travada.', + normalTx: + 'Uma transação Bitcoin padrão tem uma ou mais entradas (gastando UTXOs) e saídas. Sua taxa por vbyte determina a prioridade na mempool. Mineradores selecionam transações em ordem decrescente de taxa. A taxa mínima de relay padrão é 1 sat/vbyte — qualquer transação que atenda a esse limite entra na mempool e aguarda inclusão em um bloco.', + lowFee: + 'Transações abaixo da taxa mínima de relay são rejeitadas pelo Bitcoin Core. Transações logo acima do mínimo podem ficar presas na mempool quando os blocos estão cheios, ou ser despejadas quando o limite de tamanho da mempool (padrão 300 MB) é atingido, removendo primeiro as de menor taxa. Use RBF (bumpfee) ou CPFP (transação filha) para acelerar uma transação travada.', rbf: 'Replace-By-Fee (BIP125): o remetente pode transmitir uma nova versão de uma transação não confirmada com taxa maior. O Bitcoin Core substitui a original na mempool se a nova transação paga pelo menos a taxa de relay incremental. Mineradores priorizam transações com taxa mais alta, então o RBF ajuda transações travadas a serem confirmadas mais rapidamente.', cpfp: 'Child-Pays-For-Parent (filho paga pelo pai): quando uma transação pai tem taxa baixa, uma transação filha que gasta um de seus outputs pode incluir uma taxa suficientemente alta para tornar a taxa combinada do pacote atraente para mineradores. Os mineradores avaliam o pacote em conjunto, então o filho "puxa" o pai para o próximo bloco.', - reorg: 'Uma reorganização de cadeia ocorre quando uma cadeia concorrente com mais prova de trabalho acumulada passa a ser a cadeia canônica. O Bitcoin Core muda para a cadeia mais longa, revertendo quaisquer blocos que não estão mais na cadeia principal. Transações de blocos revertidos retornam à mempool e aguardam reconfirmação.', - cluster: 'Cluster mempool (Bitcoin Core v28+) agrupa transações relacionadas em clusters e avalia sua taxa combinada para decisões de evicção e mineração. Isso melhora a precisão da estimativa de taxa e o gerenciamento da mempool. Versões anteriores usam limites por transação de ancestrais/descendentes.', + reorg: + 'Uma reorganização de cadeia ocorre quando uma cadeia concorrente com mais prova de trabalho acumulada passa a ser a cadeia canônica. O Bitcoin Core muda para a cadeia mais longa, revertendo quaisquer blocos que não estão mais na cadeia principal. Transações de blocos revertidos retornam à mempool e aguardam reconfirmação.', + cluster: + 'Cluster mempool (Bitcoin Core v28+) agrupa transações relacionadas em clusters e avalia sua taxa combinada para decisões de evicção e mineração. Isso melhora a precisão da estimativa de taxa e o gerenciamento da mempool. Versões anteriores usam limites por transação de ancestrais/descendentes.', zmq: 'O Bitcoin Core publica eventos internos via ZMQ (ZeroMQ) em sockets push. O NodeScope assina rawtx (novas transações entrando na mempool) e rawblock (novos blocos conectados à cadeia). Cada evento é validado cruzado com RPC para confirmar dados on-chain.', - proof: 'O Relatório de Prova captura todos os dados verificáveis de uma execução da demo: respostas RPC, timestamps de eventos ZMQ, TXID, hash do bloco, detalhes de taxa e contagem de confirmações. Pode ser exportado como JSON e verificado de forma independente contra um nó Bitcoin Core.', + proof: + 'O Relatório de Prova captura todos os dados verificáveis de uma execução da demo: respostas RPC, timestamps de eventos ZMQ, TXID, hash do bloco, detalhes de taxa e contagem de confirmações. Pode ser exportado como JSON e verificado de forma independente contra um nó Bitcoin Core.', whyMatters: 'Por que isso importa', }, @@ -368,19 +418,26 @@ export const ptBR: Translations = { title: 'Alertas Operacionais', allGood: 'Todos os sistemas operacionais', rpcOffline: 'Bitcoin Core RPC offline', - rpcOfflineDesc: 'A API não consegue se conectar ao Bitcoin Core via RPC. Verifique se o bitcoind está em execução e acessível.', + rpcOfflineDesc: + 'A API não consegue se conectar ao Bitcoin Core via RPC. Verifique se o bitcoind está em execução e acessível.', zmqStale: 'Stream ZMQ parado', - zmqStaleDesc: 'Nenhum evento ZMQ recebido recentemente. O monitor pode ter desconectado do socket ZMQ.', + zmqStaleDesc: + 'Nenhum evento ZMQ recebido recentemente. O monitor pode ter desconectado do socket ZMQ.', demoFailure: 'Guided Demo reportou falhas', - demoFailureDesc: 'Uma ou mais etapas da Guided Demo terminaram com erro. Execute Reset e tente novamente.', + demoFailureDesc: + 'Uma ou mais etapas da Guided Demo terminaram com erro. Execute Reset e tente novamente.', simulationError: 'Erros na simulação ao vivo', - simulationErrorDesc: 'A simulação de mineração automática encontrou erros. Verifique os logs para detalhes.', + simulationErrorDesc: + 'A simulação de mineração automática encontrou erros. Verifique os logs para detalhes.', clusterUnavailable: 'RPCs de cluster mempool indisponíveis', - clusterUnavailableDesc: 'Bitcoin Core v28+ é necessário para RPCs de cluster mempool. O ambiente atual usa uma versão anterior.', + clusterUnavailableDesc: + 'Bitcoin Core v28+ é necessário para RPCs de cluster mempool. O ambiente atual usa uma versão anterior.', reorgExperimental: 'Reorg Lab é experimental', - reorgExperimentalDesc: 'O Reorg Lab funciona apenas em regtest. Os resultados podem variar. Não adequado para uso em produção.', + reorgExperimentalDesc: + 'O Reorg Lab funciona apenas em regtest. Os resultados podem variar. Não adequado para uso em produção.', metricsUnavailable: 'Métricas Prometheus indisponíveis', - metricsUnavailableDesc: 'prometheus-client não está instalado. Instale-o para habilitar o endpoint /metrics.', + metricsUnavailableDesc: + 'prometheus-client não está instalado. Instale-o para habilitar o endpoint /metrics.', severity: { critical: 'Crítico', warning: 'Aviso', @@ -413,7 +470,8 @@ export const ptBR: Translations = { storageStatus: 'Status do Armazenamento', storageUp: 'Online', storageDown: 'Indisponível', - empty: 'Nenhum registro ainda. Execute uma demo, cenário de política ou reorg lab para popular o histórico.', + empty: + 'Nenhum registro ainda. Execute uma demo, cenário de política ou reorg lab para popular o histórico.', refresh: 'Atualizar', copyProof: 'Copiar Proof JSON', openInspector: 'Abrir no Inspector', @@ -427,6 +485,10 @@ export const ptBR: Translations = { storageInfo: 'Armazenamento ativo. Execuções são persistidas entre reinicializações.', sqlite: 'SQLite (local)', memory: 'Memória (efêmero)', + exportJson: 'Exportar JSON', + exportCsv: 'Exportar CSV', + exportDownloaded: 'Exportação baixada', + exportFailed: 'Falha na exportação', }, fees: { diff --git a/frontend/src/i18n/types.ts b/frontend/src/i18n/types.ts index 7b2afbe..b168312 100644 --- a/frontend/src/i18n/types.ts +++ b/frontend/src/i18n/types.ts @@ -447,6 +447,10 @@ export interface Translations { storageInfo: string sqlite: string memory: string + exportJson: string + exportCsv: string + exportDownloaded: string + exportFailed: string } // Fee Estimation Playground diff --git a/frontend/src/index.css b/frontend/src/index.css index 134fac2..731751f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -11,21 +11,38 @@ --blue: #388bfd; } -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; } /* Thin dark scrollbar — webkit */ -::-webkit-scrollbar { width: 4px; height: 4px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { background: #4b5563; } -::-webkit-scrollbar-corner { background: transparent; } +::-webkit-scrollbar { + width: 4px; + height: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #30363d; + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: #4b5563; +} +::-webkit-scrollbar-corner { + background: transparent; +} /* Thin dark scrollbar — Firefox */ -* { scrollbar-width: thin; scrollbar-color: #30363d transparent; } +* { + scrollbar-width: thin; + scrollbar-color: #30363d transparent; +} input[type='number']::-webkit-inner-spin-button, input[type='number']::-webkit-outer-spin-button { @@ -40,7 +57,10 @@ input[type='number'] { body { background: var(--bg); color: var(--text); - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; min-height: 100vh; } @@ -142,8 +162,13 @@ body { } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } } .main { @@ -373,7 +398,7 @@ body { border-right: 1px solid var(--border); } -.mempool-item:nth-last-child(-n+2) { +.mempool-item:nth-last-child(-n + 2) { border-bottom: none; } @@ -406,7 +431,9 @@ body { border-radius: 6px; font-size: 11px; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; } .refresh-btn:hover { @@ -429,9 +456,18 @@ body { justify-content: center; } -.badge-health-healthy { background: rgba(63, 184, 134, 0.15); color: var(--accent-bright); } -.badge-health-degraded { background: rgba(227, 179, 65, 0.15); color: var(--warn); } -.badge-health-critical { background: rgba(248, 81, 73, 0.15); color: var(--error); } +.badge-health-healthy { + background: rgba(63, 184, 134, 0.15); + color: var(--accent-bright); +} +.badge-health-degraded { + background: rgba(227, 179, 65, 0.15); + color: var(--warn); +} +.badge-health-critical { + background: rgba(248, 81, 73, 0.15); + color: var(--error); +} /* ── Transaction Lifecycle ───────────────────────────────────────── */ @@ -469,7 +505,10 @@ body { border-radius: 50%; border: 2px solid var(--border); background: var(--bg); - transition: background 0.3s, border-color 0.3s, box-shadow 0.3s; + transition: + background 0.3s, + border-color 0.3s, + box-shadow 0.3s; } .lifecycle-step--active .lifecycle-dot, @@ -524,8 +563,12 @@ body { .lifecycle-step { min-width: 60px; } - .lifecycle-label { font-size: 10px; } - .lifecycle-sub { display: none; } + .lifecycle-label { + font-size: 10px; + } + .lifecycle-sub { + display: none; + } } /* ── ReplayEnginePanel ───────────────────────────────────────────── */ @@ -593,7 +636,10 @@ body { .sync-header .sync-rpc, .sync-header .sync-zmq { - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; font-size: 11px; font-weight: 600; color: var(--muted); @@ -660,7 +706,9 @@ body { font-size: 12px; cursor: pointer; z-index: 1001; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; } .pres-close:hover { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..2caec89 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,5 +6,5 @@ import App from './App.tsx' createRoot(document.getElementById('root')!).render( - , + ) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a7ff30b..4ba23b5 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -238,7 +238,13 @@ export interface HistoryListResponse { // --- Guided Demo types --- -export type StepStatus = 'pending' | 'running' | 'success' | 'error' | 'unavailable' | 'experimental' +export type StepStatus = + | 'pending' + | 'running' + | 'success' + | 'error' + | 'unavailable' + | 'experimental' export interface DemoStep { id: string diff --git a/frontend/src/utils/healthScore.ts b/frontend/src/utils/healthScore.ts index 3ff9adb..4d54b2b 100644 --- a/frontend/src/utils/healthScore.ts +++ b/frontend/src/utils/healthScore.ts @@ -13,7 +13,7 @@ export function computeHealthScore( health: HealthData | null, mempool: MempoolData | null, latestBlock: BlockData | null, - sseConnected: boolean, + sseConnected: boolean ): HealthScore { const rpcPoints = health?.rpc_ok ? 40 : 0 const zmqPoints = sseConnected ? 30 : 0 diff --git a/observability/grafana/dashboards/nodescope-overview.json b/observability/grafana/dashboards/nodescope-overview.json new file mode 100644 index 0000000..ec7b22f --- /dev/null +++ b/observability/grafana/dashboards/nodescope-overview.json @@ -0,0 +1,270 @@ +{ + "__inputs": [], + "__requires": [], + "annotations": { "list": [] }, + "description": "NodeScope — Bitcoin Core observability overview", + "editable": true, + "graphTooltip": 1, + "id": null, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "title": "Node Health", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [ + { "options": { "0": { "color": "red", "text": "DOWN" }, "1": { "color": "green", "text": "UP" } }, "type": "value" } + ], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 2, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_rpc_up", "instant": true, "legendFormat": "RPC" }], + "title": "Bitcoin Core RPC", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 3, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_storage_up", "instant": true, "legendFormat": "Storage" }], + "title": "Storage Backend", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 4, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_chain_height", "instant": true, "legendFormat": "Height" }], + "title": "Chain Height", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 5, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_mempool_tx_count", "instant": true, "legendFormat": "Txs" }], + "title": "Mempool Transactions", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "bytes" } }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 6, + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_mempool_vsize_bytes", "instant": true, "legendFormat": "vsize" }], + "title": "Mempool vsize", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 10, + "title": "HTTP Traffic", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "reqps" } }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 11, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [{ "datasource": "Prometheus", "expr": "rate(nodescope_http_requests_total[1m])", "legendFormat": "{{method}} {{endpoint}}" }], + "title": "HTTP Request Rate (1m)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "s" } }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 12, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { "datasource": "Prometheus", "expr": "histogram_quantile(0.50, rate(nodescope_http_request_duration_seconds_bucket[5m]))", "legendFormat": "p50" }, + { "datasource": "Prometheus", "expr": "histogram_quantile(0.95, rate(nodescope_http_request_duration_seconds_bucket[5m]))", "legendFormat": "p95" }, + { "datasource": "Prometheus", "expr": "histogram_quantile(0.99, rate(nodescope_http_request_duration_seconds_bucket[5m]))", "legendFormat": "p99" } + ], + "title": "HTTP Latency (p50/p95/p99)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 20, + "title": "ZMQ Events", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 21, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { "datasource": "Prometheus", "expr": "rate(nodescope_zmq_rawtx_events_total[1m])", "legendFormat": "rawtx/min" }, + { "datasource": "Prometheus", "expr": "rate(nodescope_zmq_rawblock_events_total[1m])", "legendFormat": "rawblock/min" } + ], + "title": "ZMQ Event Rate (1m)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 22, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { "datasource": "Prometheus", "expr": "nodescope_zmq_rawtx_events_total", "legendFormat": "rawtx total" }, + { "datasource": "Prometheus", "expr": "nodescope_zmq_rawblock_events_total", "legendFormat": "rawblock total" } + ], + "title": "ZMQ Events (cumulative)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 30, + "title": "Lab Runs", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 24 }, + "id": 31, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_demo_runs_total", "instant": false, "legendFormat": "Demo Runs" }], + "title": "Demo Runs", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 24 }, + "id": 32, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_policy_scenarios_total", "instant": false, "legendFormat": "Policy Runs" }], + "title": "Policy Scenario Runs", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 24 }, + "id": 33, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_reorg_runs_total", "instant": false, "legendFormat": "Reorg Runs" }], + "title": "Reorg Lab Runs", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 24 }, + "id": 34, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "targets": [{ "datasource": "Prometheus", "expr": "nodescope_proof_reports_total", "instant": false, "legendFormat": "Proofs" }], + "title": "Proof Reports Generated", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 28 }, + "id": 40, + "title": "Persisted History", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "id": 41, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { "datasource": "Prometheus", "expr": "nodescope_history_proof_reports_total", "legendFormat": "Proof Reports" }, + { "datasource": "Prometheus", "expr": "nodescope_history_demo_runs_total", "legendFormat": "Demo Runs" }, + { "datasource": "Prometheus", "expr": "nodescope_history_policy_runs_total", "legendFormat": "Policy Runs" }, + { "datasource": "Prometheus", "expr": "nodescope_history_reorg_runs_total", "legendFormat": "Reorg Runs" } + ], + "title": "Persisted Records (SQLite)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "id": 42, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { "datasource": "Prometheus", "expr": "nodescope_fee_estimation_runs_total", "legendFormat": "Fee Runs" }, + { "datasource": "Prometheus", "expr": "nodescope_fee_estimation_failures_total", "legendFormat": "Fee Failures" } + ], + "title": "Fee Estimation Runs", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 37 }, + "id": 50, + "title": "RPC", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "short" } }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 38 }, + "id": 51, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { "datasource": "Prometheus", "expr": "rate(nodescope_rpc_requests_total[1m])", "legendFormat": "RPC req/min" }, + { "datasource": "Prometheus", "expr": "rate(nodescope_rpc_errors_total[1m])", "legendFormat": "RPC errors/min" } + ], + "title": "RPC Request Rate (1m)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { "defaults": { "unit": "s" } }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 38 }, + "id": 52, + "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { "datasource": "Prometheus", "expr": "histogram_quantile(0.50, rate(nodescope_rpc_latency_seconds_bucket[5m]))", "legendFormat": "p50" }, + { "datasource": "Prometheus", "expr": "histogram_quantile(0.95, rate(nodescope_rpc_latency_seconds_bucket[5m]))", "legendFormat": "p95" } + ], + "title": "RPC Latency (p50/p95)", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 36, + "style": "dark", + "tags": ["nodescope", "bitcoin"], + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "NodeScope Overview", + "uid": "nodescope-overview", + "version": 1 +} diff --git a/observability/grafana/provisioning/dashboards/dashboard.yml b/observability/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000..70f624a --- /dev/null +++ b/observability/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: NodeScope + type: file + disableDeletion: true + editable: false + options: + path: /var/lib/grafana/dashboards diff --git a/observability/grafana/provisioning/datasources/datasource.yml b/observability/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..a5f4d08 --- /dev/null +++ b/observability/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://nodescope-prometheus:9090 + isDefault: true + editable: false diff --git a/observability/prometheus/prometheus.yml b/observability/prometheus/prometheus.yml new file mode 100644 index 0000000..07c46e3 --- /dev/null +++ b/observability/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: nodescope + static_configs: + - targets: + - nodescope-api:8000 + metrics_path: /metrics diff --git a/tests/test_api.py b/tests/test_api.py index 6a70d75..7b74399 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import tempfile import threading import time @@ -13,6 +14,8 @@ demo, demo_step, health, + history_export_csv, + history_export_json, latest_block, latest_tx, mempool_summary, @@ -227,5 +230,66 @@ def append_event() -> None: self.assertIn('"kind": "simple_payment_like"', event_chunk) +class HistoryExportTests(unittest.TestCase): + def test_export_json_returns_response_with_correct_media_type(self) -> None: + response = history_export_json() + self.assertEqual(response.media_type, "application/json") + self.assertIn("nodescope-history.json", response.headers.get("content-disposition", "")) + + def test_export_json_body_is_valid_json(self) -> None: + response = history_export_json() + payload = json.loads(response.body) + self.assertIn("metadata", payload) + self.assertIn("proof_reports", payload) + self.assertIn("demo_runs", payload) + self.assertIn("policy_runs", payload) + self.assertIn("reorg_runs", payload) + + def test_export_json_metadata_has_required_keys(self) -> None: + response = history_export_json() + meta = json.loads(response.body)["metadata"] + self.assertIn("generated_at", meta) + self.assertIn("project", meta) + self.assertIn("counts", meta) + self.assertIn("filters", meta) + self.assertEqual(meta["project"], "NodeScope") + + def test_export_json_empty_store_returns_empty_lists(self) -> None: + response = history_export_json() + payload = json.loads(response.body) + self.assertIsInstance(payload["proof_reports"], list) + self.assertIsInstance(payload["demo_runs"], list) + self.assertIsInstance(payload["policy_runs"], list) + self.assertIsInstance(payload["reorg_runs"], list) + + def test_export_csv_returns_response_with_correct_media_type(self) -> None: + response = history_export_csv() + self.assertEqual(response.media_type, "text/csv") + self.assertIn("nodescope-history.csv", response.headers.get("content-disposition", "")) + + def test_export_csv_body_has_header_row(self) -> None: + response = history_export_csv() + first_line = response.body.decode().splitlines()[0] + self.assertIn("table", first_line) + self.assertIn("id", first_line) + self.assertIn("status", first_line) + self.assertIn("created_at", first_line) + + def test_export_csv_empty_store_returns_only_header(self) -> None: + response = history_export_csv() + lines = [line for line in response.body.decode().splitlines() if line.strip()] + self.assertEqual(len(lines), 1) + + def test_export_json_source_filter_is_reflected_in_metadata(self) -> None: + response = history_export_json(source="demo") + meta = json.loads(response.body)["metadata"] + self.assertEqual(meta["filters"]["source"], "demo") + + def test_export_json_success_filter_is_reflected_in_metadata(self) -> None: + response = history_export_json(success=True) + meta = json.loads(response.body)["metadata"] + self.assertEqual(meta["filters"]["success"], True) + + if __name__ == "__main__": unittest.main()