diff --git a/.env.example b/.env.example index dd1bab5..71d8197 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,16 @@ VITE_API_PROXY_TARGET=http://127.0.0.1:8000 NODESCOPE_REQUIRE_API_KEY=false NODESCOPE_API_KEY= +# --------------------------------------------------------------------------- +# Rate limiting +# --------------------------------------------------------------------------- +# Sliding-window protection for the API. Defaults are demo-friendly: read-heavy +# dashboard polling should not trip the limiter during normal evaluation, while +# mutating endpoints still have a tighter write budget. +RATE_LIMIT_ENABLED=true +RATE_LIMIT_RPM=600 +RATE_LIMIT_WRITE_RPM=30 + # --------------------------------------------------------------------------- # Docker host ports # --------------------------------------------------------------------------- diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index ffd5739..40715f6 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -4,10 +4,11 @@ Status: Hackathon-ready Release: v1.1.0 +Local v1.2 implementation: validated Docker quickstart: validated Smoke test: passing (PASS=15 FAIL=0 WARN=0) Frontend build: passing (TypeScript + Vite) -Python tests: passing (80 unit tests — see CI for current breakdown) +Python tests: passing (85 unit tests — see CI for current breakdown) CI: passing (GitHub Actions — backend, frontend Node 18/20/24, public-clean check) ## Official Evaluator Flow @@ -57,6 +58,11 @@ http://localhost:5173 | Live Simulation Engine | Ready | Auto-mines blocks and sends transactions in regtest at configurable intervals. | | Prometheus /metrics | Ready | Prometheus-compatible metrics endpoint at GET /metrics. | | Operational Alerting | Ready | Dashboard AlertingPanel shows RPC status, simulation errors, and environment notes. | +| Configurable Alert Rules | Ready | Alert thresholds can be listed, created, updated, deleted and evaluated through the API. | +| Historical Charts | Ready | Mempool size and minimum fee time-series endpoints feed the dashboard charts. | +| Network Read-only Guard | Ready | Non-regtest networks are detected and mutating lab endpoints are blocked unless explicitly allowed. | +| API Rate Limiting | Ready | Sliding-window middleware limits burst traffic while exempting long-lived streams and lab polling endpoints. | +| Visual Mempool Clusters | Ready | Shows package relationships when cluster RPCs are available and honest fallback groups when unavailable. | | Reproducible Benchmark | Ready | `scripts/benchmark_nodescope.py` measures API latency for all key endpoints. | | Optional API Key Auth | Ready | State-changing endpoints protected via `X-NodeScope-API-Key` header when `NODESCOPE_REQUIRE_API_KEY=true`. Read-only endpoints remain open. | | Load Smoke Test | Ready | `scripts/load_smoke.py` runs concurrent requests against all read-only endpoints, reporting per-endpoint and aggregate latency and success rate. | @@ -72,6 +78,7 @@ The `/metrics` endpoint exposes Prometheus-compatible metrics when `prometheus-c - `nodescope_http_requests_total` — HTTP requests by method/endpoint/status - `nodescope_http_request_duration_seconds` — request latency histogram - `nodescope_http_errors_total` — 4xx/5xx responses +- `nodescope_rate_limited_total` — requests rejected by API rate limiting - `nodescope_rpc_requests_total` — RPC calls to Bitcoin Core - `nodescope_rpc_errors_total` — RPC errors - `nodescope_rpc_latency_seconds` — RPC call latency @@ -135,6 +142,20 @@ NODESCOPE_SQLITE_PATH=.nodescope/history.db If SQLite initialisation fails, the API transparently falls back to an in-memory store and records the error in `/history/summary`. All history endpoints remain functional in either backend. +## v1.2 Operational Endpoints + +| Method | Path | Description | +|---|---|---| +| GET | `/network/mode` | Detects current Bitcoin network and read-only state | +| GET | `/charts/mempool` | Mempool size time-series for historical charts | +| GET | `/charts/fees` | Minimum mempool fee time-series for historical charts | +| GET | `/alerts/config` | Lists alert rules and current threshold configuration | +| POST | `/alerts/config` | Creates a custom alert rule | +| PUT | `/alerts/config/{id}` | Updates an alert rule | +| DELETE | `/alerts/config/{id}` | Deletes an alert rule | +| GET | `/alerts/active` | Evaluates current active operational alerts | +| GET | `/mempool/clusters` | Detects cluster mempool support and returns package visualization data | + ## Known Limitations - The official demo uses Bitcoin Core regtest only; signet/mainnet operation is intentionally out of scope. @@ -174,5 +195,5 @@ If SQLite initialisation fails, the API transparently falls back to an in-memory | OpenTelemetry traces (RPC, ZMQ, API) | Planned | | Multi-node support | Planned | | Kubernetes manifests / Helm chart | Planned | -| signet / mainnet read-only mode | Planned | -| Cluster mempool visualization (Bitcoin Core 28+) | Planned | +| signet / mainnet read-only guard | Ready (mutating lab endpoints blocked outside regtest) | +| Cluster mempool visualization | Ready (fallback visual groups; BC28+ RPCs detected when available) | diff --git a/README.md b/README.md index 8bd145c..fb85c96 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ cluster mempool RPCs: If supported, they are used and results are displayed. If unavailable (Bitcoin Core 26 and earlier), NodeScope returns an honest `unavailable` status with a clear explanation — never a false positive. -Available via `GET /mempool/cluster/compatibility` and in the **Policy Arena** tab. +Available via `GET /mempool/cluster/compatibility`, `GET /mempool/clusters`, and the **Cluster Mempool** tab. > Cluster mempool RPCs are expected in Bitcoin Core 28+. This build uses Bitcoin Core 26. @@ -520,11 +520,15 @@ Output: latency table (min/mean/median/p95/max) per endpoint. Results vary by ho | Feature | Status | |---|---| | Signet/testnet support | Planned | -| Cluster mempool visualization (Bitcoin Core 28+) | Planned | +| Public-network read-only mode | Ready (network guard blocks lab mutations outside regtest) | +| Cluster mempool visualization | Ready (fallback visual groups; BC28+ RPCs detected when available) | | Mempool eviction scenario | Planned | | Multi-node topology | Planned | | Postgres / TimescaleDB for event persistence | Planned | | Historical dashboards | Ready (SQLite-backed) | +| Historical charts | Ready | +| Configurable alert thresholds | Ready | +| API rate limiting | Ready | | API keys for mutating endpoints (optional) | Ready | | OpenTelemetry traces | Planned | | Kubernetes manifests / Helm chart | Planned | diff --git a/README.pt-BR.md b/README.pt-BR.md index fa7549c..25e84ac 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -281,7 +281,7 @@ O NodeScope verifica automaticamente se o nó Bitcoin Core conectado suporta RPC Se suportados, são usados e os resultados são exibidos. Se indisponíveis (Bitcoin Core 26 e anteriores), o NodeScope retorna um status `unavailable` honesto com explicação clara — nunca um falso positivo. -Disponível via `GET /mempool/cluster/compatibility` e na aba **Policy Arena**. +Disponível via `GET /mempool/cluster/compatibility`, `GET /mempool/clusters` e na aba **Cluster Mempool**. > RPCs de cluster mempool são esperados no Bitcoin Core 28+. Esta build usa Bitcoin Core 26. @@ -519,11 +519,15 @@ Saída: tabela de latência (min/média/mediana/p95/max) por endpoint. Os result | Funcionalidade | Status | |---|---| | Suporte a signet/testnet | Planejado | -| Visualização de cluster mempool (Bitcoin Core 28+) | Planejado | +| Modo read-only para redes públicas | Pronto (proteção bloqueia mutações de laboratório fora de regtest) | +| Visualização de cluster mempool | Pronto (grupos visuais via fallback; RPCs BC28+ detectados quando disponíveis) | | Cenário de expulsão da mempool | Planejado | | Topologia multi-nó | Planejado | | Postgres / TimescaleDB para persistência de eventos | Planejado | | Dashboards históricos | Pronto (SQLite) | +| Gráficos históricos | Pronto | +| Limiares de alerta configuráveis | Pronto | +| Rate limiting da API | Pronto | | API keys para endpoints mutantes (opcional) | Pronto | | OpenTelemetry traces | Planejado | | Kubernetes manifests / Helm chart | Planejado | diff --git a/ROADMAP.md b/ROADMAP.md index 786dda5..ac27b77 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,11 @@ Everything below is shipped and functional in the current release. | Live Simulation Engine | Auto-mines blocks and sends transactions at configurable intervals | | Prometheus metrics (`/metrics`) | 24+ metrics covering HTTP, RPC, ZMQ, mempool, chain, simulation, storage | | Operational Alerting Panel | Polls API every 15 s; surfaces RPC offline, simulation errors, env notes | +| Configurable alert thresholds | CRUD API and dashboard controls for operational alert rules | +| Historical trend charts | Mempool size and minimum fee time-series charts | +| Read-only network guard | Blocks mutating lab operations outside regtest unless explicitly allowed | +| API rate limiting | Sliding-window protection with demo-friendly defaults | +| Visual cluster mempool view | Uses BC28+ cluster RPCs when available; otherwise displays honest fallback groups | | Optional API key auth | Mutating endpoints protected via `X-NodeScope-API-Key` when `NODESCOPE_REQUIRE_API_KEY=true` | | SQLite persistence | Proof reports, demo/policy/reorg run history; in-memory fallback if SQLite unavailable | | Historical Dashboard | Paginated view of all past runs across all scenario types | @@ -59,13 +64,10 @@ Nothing is currently in active development. | Signet/testnet observer mode | `BITCOIN_NETWORK=signet` flag; ZMQ + RPC without wallet or regtest operations | | Dashboard adapted for signet | Remove "mine block" controls; read-only mode indicators | | Mainnet read-only mode | `BITCOIN_NETWORK=mainnet` with explicit network safeguards | -| Rate limiting on `/events/stream` | Relevant for public deployments | -| Cluster mempool visualization | Requires Bitcoin Core 28+; gated on getmempoolcluster availability | +| Hosted deployment tuning | Public rate-limit profiles, reverse proxy examples, and SSE sizing | +| Enhanced Bitcoin Core 28+ cluster views | More detailed diagrams when getmempoolcluster/getmempoolfeeratediagram are available | | Mempool eviction scenario | Demonstrate fee-based eviction from the mempool | | Advanced classification heuristics | UTXO consolidation, batch payments, Taproot script patterns | -| Historical trend charts | Event rate, mempool depth, fee rate over time | -| Configurable alert thresholds | Mempool size, fee rate, block interval alerts | -| Grafana integration | Pre-built dashboard consuming `/metrics` | | OpenTelemetry traces | RPC, ZMQ, and API request traces | | Multi-node support | Monitor multiple Bitcoin Core instances simultaneously | diff --git a/api/alerts_service.py b/api/alerts_service.py new file mode 100644 index 0000000..cc3d6d1 --- /dev/null +++ b/api/alerts_service.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Any + +from . import storage +from .rpc import RPCError, get_client + +SUPPORTED_METRICS = {"mempool_size", "mempool_bytes", "minfee", "rpc_offline"} +SUPPORTED_OPERATORS = {"gt", "lt", "eq", "gte", "lte"} +SUPPORTED_SEVERITIES = {"info", "warning", "critical"} + + +def _compare(value: float, operator: str, threshold: float) -> bool: + if operator == "gt": + return value > threshold + if operator == "lt": + return value < threshold + if operator == "eq": + return value == threshold + if operator == "gte": + return value >= threshold + if operator == "lte": + return value <= threshold + return False + + +def _current_values() -> dict[str, float]: + try: + info = get_client().getmempoolinfo() + return { + "mempool_size": float(info.get("size", 0)), + "mempool_bytes": float(info.get("bytes", 0)), + "minfee": float(info.get("mempoolminfee", 0.0)) * 100_000, + "rpc_offline": 0.0, + } + except RPCError: + return { + "mempool_size": 0.0, + "mempool_bytes": 0.0, + "minfee": 0.0, + "rpc_offline": 1.0, + } + + +def _format_config(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row.get("id"), + "metric": row.get("metric"), + "operator": row.get("operator"), + "threshold": row.get("threshold"), + "severity": row.get("severity"), + "enabled": bool(row.get("enabled")), + "created_at": row.get("created_at"), + } + + +def list_configs() -> list[dict[str, Any]]: + storage.seed_default_alerts() + return [_format_config(row) for row in storage.list_alert_configs()] + + +def validate_rule(rule: dict[str, Any]) -> None: + metric = rule.get("metric") + operator = rule.get("operator") + severity = rule.get("severity", "warning") + if rule.get("threshold") is None: + raise ValueError("Missing threshold") + if metric not in SUPPORTED_METRICS: + raise ValueError(f"Unsupported metric: {metric}") + if operator not in SUPPORTED_OPERATORS: + raise ValueError(f"Unsupported operator: {operator}") + if severity not in SUPPORTED_SEVERITIES: + raise ValueError(f"Unsupported severity: {severity}") + + +def create_config(rule: dict[str, Any]) -> dict[str, Any] | None: + validate_rule(rule) + row_id = storage.insert_alert_config( + rule["metric"], + rule["operator"], + float(rule["threshold"]), + severity=rule.get("severity", "warning"), + enabled=bool(rule.get("enabled", True)), + ) + return _format_config(storage.get_alert_config(row_id)) if row_id is not None else None + + +def update_config(config_id: int, values: dict[str, Any]) -> dict[str, Any] | None: + existing = storage.get_alert_config(config_id) + if existing is None: + return None + merged = {**existing, **{k: v for k, v in values.items() if v is not None}} + validate_rule(merged) + row = storage.update_alert_config(config_id, values) + return _format_config(row) if row else None + + +def delete_config(config_id: int) -> bool: + return storage.delete_alert_config(config_id) + + +def evaluate_alerts() -> list[dict[str, Any]]: + storage.seed_default_alerts() + values = _current_values() + active = [] + for row in storage.list_alert_configs(): + config = _format_config(row) + if not config["enabled"]: + continue + metric = str(config["metric"]) + value = values.get(metric) + if value is None: + continue + if _compare(value, str(config["operator"]), float(config["threshold"])): + active.append( + { + "id": config["id"], + "metric": metric, + "operator": config["operator"], + "threshold": config["threshold"], + "severity": config["severity"], + "current_value": value, + "enabled": True, + } + ) + return active diff --git a/api/app.py b/api/app.py index ca87721..9f5dfaf 100644 --- a/api/app.py +++ b/api/app.py @@ -14,7 +14,15 @@ from fastapi.responses import Response, StreamingResponse from fastapi.staticfiles import StaticFiles -from . import fee_service, history_service, metrics, simulation_service +from . import ( + alerts_service, + charts_service, + fee_service, + history_service, + metrics, + network_guard, + simulation_service, +) from .demo import STATIC_DIR, demo_page, root_redirect from .demo_service import ( get_status as demo_get_status, @@ -34,6 +42,7 @@ from .policy_service import ( reset_all as policy_reset_all, ) +from .rate_limiter import RateLimitMiddleware from .reorg_service import ( get_proof as reorg_get_proof, ) @@ -47,7 +56,12 @@ run as reorg_run, ) from .schemas import ( + ActiveAlertsResponse, + AlertConfigListResponse, + AlertConfigRequest, + AlertConfigResponse, BlockResponse, + ChartResponse, ClassificationsResponse, ClusterCompatibilityResponse, DemoProofResponse, @@ -58,7 +72,9 @@ HealthResponse, HistorySummaryResponse, IntelligenceSummaryResponse, + MempoolClustersResponse, MempoolSummaryResponse, + NetworkModeResponse, PolicyProofResponse, PolicyRunHistoryResponse, PolicyScenarioResponse, @@ -79,6 +95,7 @@ build_summary, get_classifications, get_cluster_compatibility, + get_cluster_mempool_visual, get_event_tape, get_intelligence_summary, get_latest_block, @@ -115,9 +132,29 @@ async def _verify_api_key( _PROTECTED = [Depends(_verify_api_key)] +async def _guard_readonly() -> None: + if network_guard.is_read_only(): + mode = network_guard.detect_network_mode() + raise HTTPException( + status_code=403, + detail={ + "message": "State-changing actions are disabled outside regtest.", + "chain": mode.get("chain"), + "reason": mode.get("reason"), + }, + ) + + +_WRITE_PROTECTED = [Depends(_verify_api_key), Depends(_guard_readonly)] + + @asynccontextmanager async def lifespan(app: FastAPI): + network_guard.refresh_network_mode() + if network_guard.is_read_only(): + simulation_service.prevent_auto_start() simulation_service.auto_start() + charts_service.start_snapshot_loop() yield @@ -155,10 +192,11 @@ async def metrics_middleware(request: Request, call_next): # type: ignore[no-un "http://localhost:3000", "http://127.0.0.1:3000", ], - allow_methods=["GET", "POST", "PUT"], + allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], ) app.middleware("http")(metrics_middleware) +app.add_middleware(RateLimitMiddleware) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @@ -186,6 +224,11 @@ def health(log_dir: str | None = None, file: str | None = None) -> dict: return result +@app.get("/network/mode", response_model=NetworkModeResponse) +def network_mode() -> dict: + return network_guard.detect_network_mode() + + @app.get("/summary", response_model=SummaryResponse) def summary(log_dir: str | None = None, file: str | None = None) -> dict: return build_summary(log_dir=_resolve_path(log_dir), file=_resolve_path(file)) @@ -206,11 +249,70 @@ def mempool_summary() -> dict: return result +@app.get("/charts/mempool", response_model=ChartResponse) +def charts_mempool(range: str = "1h") -> dict: # noqa: A002 + return charts_service.get_mempool_chart(range) + + +@app.get("/charts/fees", response_model=ChartResponse) +def charts_fees(range: str = "1h") -> dict: # noqa: A002 + return charts_service.get_fees_chart(range) + + +@app.get("/alerts/config", response_model=AlertConfigListResponse) +def alerts_config() -> dict: + return {"items": alerts_service.list_configs()} + + +@app.post( + "/alerts/config", + response_model=AlertConfigResponse, + dependencies=_WRITE_PROTECTED, +) +def alerts_config_create(req: AlertConfigRequest) -> dict: + try: + item = alerts_service.create_config(req.model_dump(exclude_none=True)) + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if item is None: + raise HTTPException(status_code=500, detail="Failed to create alert config") + return item + + +@app.put( + "/alerts/config/{config_id}", + response_model=AlertConfigResponse, + dependencies=_WRITE_PROTECTED, +) +def alerts_config_update(config_id: int, req: AlertConfigRequest) -> dict: + try: + item = alerts_service.update_config(config_id, req.model_dump(exclude_none=True)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if item is None: + raise HTTPException(status_code=404, detail="Alert config not found") + return item + + +@app.delete("/alerts/config/{config_id}", dependencies=_WRITE_PROTECTED) +def alerts_config_delete(config_id: int) -> dict: + if not alerts_service.delete_config(config_id): + raise HTTPException(status_code=404, detail="Alert config not found") + return {"ok": True} + + +@app.get("/alerts/active", response_model=ActiveAlertsResponse) +def alerts_active() -> dict: + return {"items": alerts_service.evaluate_alerts()} + + @app.get("/events/recent", response_model=RecentEventsResponse) def recent_events( limit: int = Query(default=10, ge=1, le=100), offset: int = Query(default=0, ge=0), event_type: str | None = Query(default=None), + sort_by: str = "ts", + sort_dir: str = "desc", log_dir: str | None = None, file: str | None = None, ) -> dict: @@ -218,6 +320,8 @@ def recent_events( limit=limit, offset=offset, event_type=event_type, + sort_by=sort_by, + sort_dir=sort_dir, log_dir=_resolve_path(log_dir), file=_resolve_path(file), ) @@ -228,6 +332,8 @@ def classifications( limit: int = Query(default=10, ge=1, le=100), offset: int = Query(default=0, ge=0), kind: str | None = Query(default=None), + sort_by: str = "ts", + sort_dir: str = "desc", log_dir: str | None = None, file: str | None = None, ) -> dict: @@ -235,6 +341,8 @@ def classifications( limit=limit, offset=offset, kind=kind, + sort_by=sort_by, + sort_dir=sort_dir, log_dir=_resolve_path(log_dir), file=_resolve_path(file), ) @@ -316,13 +424,13 @@ def demo_status() -> dict: return demo_get_status() -@app.post("/demo/run", response_model=DemoStatusResponse, dependencies=_PROTECTED) +@app.post("/demo/run", response_model=DemoStatusResponse, dependencies=_WRITE_PROTECTED) def demo_run() -> dict: metrics.record_demo_run() return start_full_demo() -@app.post("/demo/step/{step_id}", dependencies=_PROTECTED) +@app.post("/demo/step/{step_id}", dependencies=_WRITE_PROTECTED) def demo_step(step_id: str) -> dict: result = run_step(step_id) if result.get("error") and result.get("status") not in ("error", "unavailable"): @@ -330,7 +438,7 @@ def demo_step(step_id: str) -> dict: return result -@app.post("/demo/reset", response_model=DemoStatusResponse, dependencies=_PROTECTED) +@app.post("/demo/reset", response_model=DemoStatusResponse, dependencies=_WRITE_PROTECTED) def demo_reset() -> dict: return reset_demo() @@ -355,7 +463,9 @@ def policy_scenarios() -> dict: @app.post( - "/policy/run/{scenario_id}", response_model=PolicyScenarioResponse, dependencies=_PROTECTED + "/policy/run/{scenario_id}", + response_model=PolicyScenarioResponse, + dependencies=_WRITE_PROTECTED, ) def policy_run(scenario_id: str) -> dict: metrics.record_policy_scenario(scenario_id) @@ -374,7 +484,9 @@ def policy_status(scenario_id: str) -> dict: @app.post( - "/policy/reset/{scenario_id}", response_model=PolicyScenarioResponse, dependencies=_PROTECTED + "/policy/reset/{scenario_id}", + response_model=PolicyScenarioResponse, + dependencies=_WRITE_PROTECTED, ) def policy_reset_one(scenario_id: str) -> dict: result = reset_scenario(scenario_id) @@ -383,7 +495,7 @@ def policy_reset_one(scenario_id: str) -> dict: return result -@app.post("/policy/reset", response_model=ScenariosListResponse, dependencies=_PROTECTED) +@app.post("/policy/reset", response_model=ScenariosListResponse, dependencies=_WRITE_PROTECTED) def policy_reset_all_endpoint() -> dict: return {"scenarios": policy_reset_all()} @@ -413,13 +525,13 @@ def reorg_status() -> dict: return reorg_get_status() -@app.post("/reorg/run", response_model=ReorgStatusResponse, dependencies=_PROTECTED) +@app.post("/reorg/run", response_model=ReorgStatusResponse, dependencies=_WRITE_PROTECTED) def reorg_run_endpoint() -> dict: metrics.record_reorg_run() return reorg_run() -@app.post("/reorg/reset", response_model=ReorgStatusResponse, dependencies=_PROTECTED) +@app.post("/reorg/reset", response_model=ReorgStatusResponse, dependencies=_WRITE_PROTECTED) def reorg_reset_endpoint() -> dict: return reorg_reset() @@ -439,6 +551,11 @@ def cluster_compatibility() -> dict: return get_cluster_compatibility() +@app.get("/mempool/clusters", response_model=MempoolClustersResponse) +def mempool_clusters() -> dict: + return get_cluster_mempool_visual() + + # --------------------------------------------------------------------------- # Live Simulation endpoints # --------------------------------------------------------------------------- @@ -449,17 +566,23 @@ def simulation_status() -> dict: return simulation_service.get_status() -@app.post("/simulation/start", response_model=SimulationStatusResponse, dependencies=_PROTECTED) +@app.post( + "/simulation/start", response_model=SimulationStatusResponse, dependencies=_WRITE_PROTECTED +) def simulation_start() -> dict: return simulation_service.start() -@app.post("/simulation/stop", response_model=SimulationStatusResponse, dependencies=_PROTECTED) +@app.post( + "/simulation/stop", response_model=SimulationStatusResponse, dependencies=_WRITE_PROTECTED +) def simulation_stop() -> dict: return simulation_service.stop() -@app.put("/simulation/config", response_model=SimulationStatusResponse, dependencies=_PROTECTED) +@app.put( + "/simulation/config", response_model=SimulationStatusResponse, dependencies=_WRITE_PROTECTED +) def simulation_config(req: SimulationConfigRequest) -> dict: return simulation_service.configure( block_interval=req.block_interval, @@ -472,7 +595,7 @@ def simulation_config(req: SimulationConfigRequest) -> dict: # --------------------------------------------------------------------------- -@app.post("/session/reset", dependencies=_PROTECTED) +@app.post("/session/reset", dependencies=_WRITE_PROTECTED) def session_reset() -> dict: log_dir = os.environ.get("NODESCOPE_LOG_DIR", "/app/logs") today = datetime.date.today().isoformat() @@ -514,9 +637,16 @@ def history_proofs( offset: int = Query(default=0, ge=0), source: str | None = Query(default=None), success: bool | None = Query(default=None), + sort_by: str = "id", + sort_dir: str = "desc", ) -> dict: items = history_service.get_proof_reports( - limit=limit, offset=offset, source=source, success=success + limit=limit, + offset=offset, + source=source, + success=success, + sort_by=sort_by, + sort_dir=sort_dir, ) return {"items": items, "total_returned": len(items), "limit": limit, "offset": offset} diff --git a/api/charts_service.py b/api/charts_service.py new file mode 100644 index 0000000..c65f41f --- /dev/null +++ b/api/charts_service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import threading +from datetime import UTC, datetime, timedelta +from typing import Any + +from . import storage +from .rpc import RPCError, get_client + +_RANGES = {"1h": 3600, "6h": 21600, "24h": 86400} +_SNAPSHOT_INTERVAL = 60 +_thread: threading.Thread | None = None +_stop = threading.Event() + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _since(range_str: str) -> str: + seconds = _RANGES.get(range_str, _RANGES["1h"]) + return (_now() - timedelta(seconds=seconds)).isoformat() + + +def _take_snapshot() -> None: + try: + rpc = get_client() + mempool = rpc.getmempoolinfo() + blockchain = rpc.getblockchaininfo() + except RPCError: + return + ts = _now().isoformat() + storage.insert_time_series("mempool_size", float(mempool.get("size", 0)), ts=ts) + storage.insert_time_series("mempool_bytes", float(mempool.get("bytes", 0)), ts=ts) + storage.insert_time_series("minfee", float(mempool.get("mempoolminfee", 0.0)) * 100_000, ts=ts) + storage.insert_time_series("chain_height", float(blockchain.get("blocks", 0)), ts=ts) + + +def _snapshot_loop() -> None: + _take_snapshot() + while not _stop.wait(_SNAPSHOT_INTERVAL): + _take_snapshot() + + +def start_snapshot_loop() -> None: + global _thread + if _thread and _thread.is_alive(): + return + _stop.clear() + _thread = threading.Thread(target=_snapshot_loop, daemon=True, name="charts-snapshot-loop") + _thread.start() + + +def get_mempool_chart(range_str: str) -> dict[str, Any]: + selected = range_str if range_str in _RANGES else "1h" + size_rows = storage.query_time_series("mempool_size", _since(selected)) + bytes_rows = storage.query_time_series("mempool_bytes", _since(selected)) + by_ts: dict[str, dict[str, Any]] = {} + for row in size_rows: + by_ts.setdefault(row["ts"], {"ts": row["ts"]})["mempool_size"] = row["value"] + for row in bytes_rows: + by_ts.setdefault(row["ts"], {"ts": row["ts"]})["mempool_bytes"] = row["value"] + return {"range": selected, "points": [by_ts[ts] for ts in sorted(by_ts)]} + + +def get_fees_chart(range_str: str) -> dict[str, Any]: + selected = range_str if range_str in _RANGES else "1h" + rows = storage.query_time_series("minfee", _since(selected)) + return { + "range": selected, + "points": [{"ts": row["ts"], "minfee": row["value"]} for row in rows], + } diff --git a/api/history_service.py b/api/history_service.py index c6271d9..45d23fe 100644 --- a/api/history_service.py +++ b/api/history_service.py @@ -49,8 +49,17 @@ def get_proof_reports( offset: int = 0, source: str | None = None, success: bool | None = None, + sort_by: str = "id", + sort_dir: str = "desc", ) -> list[dict[str, Any]]: - rows = storage.list_proof_reports(limit=limit, offset=offset, source=source, success=success) + rows = storage.list_proof_reports( + limit=limit, + offset=offset, + source=source, + success=success, + sort_by=sort_by, + sort_dir=sort_dir, + ) return [_format_proof_report(r) for r in rows] diff --git a/api/metrics.py b/api/metrics.py index a4a9562..94ffb8d 100644 --- a/api/metrics.py +++ b/api/metrics.py @@ -41,6 +41,11 @@ "Total HTTP 4xx/5xx responses", ["method", "endpoint", "status"], ) + RATE_LIMITED_TOTAL = Counter( + "nodescope_rate_limited_total", + "Total requests rejected by NodeScope rate limiting", + ["method"], + ) # Bitcoin Core RPC RPC_REQUESTS_TOTAL = Counter( @@ -186,6 +191,12 @@ def record_http_request(method: str, endpoint: str, status: int, duration: float HTTP_ERRORS_TOTAL.labels(**labels).inc() +def record_rate_limited(method: str) -> None: + if not _PROMETHEUS_AVAILABLE: + return + RATE_LIMITED_TOTAL.labels(method=method).inc() + + def record_rpc_call(method: str, *, error: bool = False, duration: float = 0.0) -> None: if not _PROMETHEUS_AVAILABLE: return diff --git a/api/network_guard.py b/api/network_guard.py new file mode 100644 index 0000000..17a2d4c --- /dev/null +++ b/api/network_guard.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os +import threading + +_lock = threading.Lock() +_chain_cache: str | None = None +_read_only_cache: bool = False +_reason_cache: str = "regtest_writable" +_initialized: bool = False + +_FORCE_READONLY: bool = os.environ.get("NODESCOPE_FORCE_READONLY", "false").lower() == "true" + + +def refresh_network_mode() -> None: + global _chain_cache, _read_only_cache, _reason_cache, _initialized + if _FORCE_READONLY: + with _lock: + _read_only_cache = True + _reason_cache = "force_readonly_env" + _chain_cache = None + _initialized = True + return + + try: + from .rpc import get_client # noqa: PLC0415 + + info = get_client().getblockchaininfo() + chain = info.get("chain", "unknown") + with _lock: + _chain_cache = chain + if chain != "regtest": + _read_only_cache = True + _reason_cache = "non_regtest_chain" + else: + _read_only_cache = False + _reason_cache = "regtest_writable" + _initialized = True + except Exception: + # Fail-open: if RPC is offline we cannot confirm it's mainnet + with _lock: + _chain_cache = None + _read_only_cache = False + _reason_cache = "regtest_writable" + _initialized = True + + +def is_read_only() -> bool: + if not _initialized: + refresh_network_mode() + return _read_only_cache + + +def detect_network_mode() -> dict[str, object]: + if not _initialized: + refresh_network_mode() + with _lock: + return { + "chain": _chain_cache, + "read_only": _read_only_cache, + "reason": _reason_cache, + } diff --git a/api/rate_limiter.py b/api/rate_limiter.py new file mode 100644 index 0000000..1589154 --- /dev/null +++ b/api/rate_limiter.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import os +import threading +import time +from collections import defaultdict, deque + +from fastapi import Request +from fastapi.responses import JSONResponse + +_ENABLED: bool = os.environ.get("RATE_LIMIT_ENABLED", "true").lower() == "true" +_RPM_GLOBAL: int = int(os.environ.get("RATE_LIMIT_RPM", "600")) +_RPM_WRITE: int = int(os.environ.get("RATE_LIMIT_WRITE_RPM", "30")) +_WINDOW: float = 60.0 + +_WRITE_METHODS = {"POST", "PUT", "DELETE"} +_EXEMPT_PREFIXES = ("/events/stream", "/static") +_EXEMPT_PATHS = {"/", "/demo", "/metrics"} + +_lock = threading.Lock() +_global_windows: dict[str, deque[float]] = defaultdict(deque) +_write_windows: dict[str, deque[float]] = defaultdict(deque) + + +def _client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "unknown" + + +def _check_window(window: deque[float], limit: int, now: float) -> tuple[bool, int]: + while window and now - window[0] >= _WINDOW: + window.popleft() + if len(window) >= limit: + retry_after = max(1, int(_WINDOW - (now - window[0])) + 1) + return True, retry_after + window.append(now) + return False, 0 + + +def _is_exempt_path(path: str) -> bool: + return path in _EXEMPT_PATHS or any(path.startswith(prefix) for prefix in _EXEMPT_PREFIXES) + + +def is_rate_limited(ip: str, method: str, path: str = "") -> tuple[bool, int]: + if not _ENABLED: + return False, 0 + if _is_exempt_path(path): + return False, 0 + now = time.monotonic() + with _lock: + limited, retry = _check_window(_global_windows[ip], _RPM_GLOBAL, now) + if limited: + return True, retry + if method in _WRITE_METHODS: + limited, retry = _check_window(_write_windows[ip], _RPM_WRITE, now) + if limited: + return True, retry + return False, 0 + + +class RateLimitMiddleware: + def __init__(self, app: object) -> None: + self.app = app + + async def __call__(self, scope: dict, receive: object, send: object) -> None: + if not _ENABLED or scope.get("type") != "http": + await self.app(scope, receive, send) # type: ignore[misc] + return + + request = Request(scope) + limited, retry_after = is_rate_limited( + _client_ip(request), request.method, request.url.path + ) + if limited: + try: + from . import metrics # noqa: PLC0415 + + metrics.record_rate_limited(request.method) + except Exception: + pass + response = JSONResponse( + status_code=429, + content={"detail": "Rate limit exceeded. Too many requests."}, + headers={"Retry-After": str(retry_after)}, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) # type: ignore[misc] diff --git a/api/schemas.py b/api/schemas.py index 6d9bd8f..3ee9b32 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -20,6 +20,60 @@ class HealthResponse(BaseModel): rpc_error: str | None = None +class NetworkModeResponse(BaseModel): + chain: str | None = None + read_only: bool + reason: str + + +class ChartPointResponse(BaseModel): + ts: str + mempool_size: float | None = None + mempool_bytes: float | None = None + minfee: float | None = None + + +class ChartResponse(BaseModel): + range: str + points: list[ChartPointResponse] = Field(default_factory=list) + + +class AlertConfigRequest(BaseModel): + metric: str | None = None + operator: str | None = None + threshold: float | None = None + severity: str | None = "warning" + enabled: bool | None = True + + +class AlertConfigResponse(BaseModel): + id: int | None = None + metric: str + operator: str + threshold: float + severity: str + enabled: bool + created_at: str | None = None + + +class AlertConfigListResponse(BaseModel): + items: list[AlertConfigResponse] + + +class ActiveAlertResponse(BaseModel): + id: int | None = None + metric: str + operator: str + threshold: float + severity: str + current_value: float + enabled: bool + + +class ActiveAlertsResponse(BaseModel): + items: list[ActiveAlertResponse] + + class RawEventResponse(BaseModel): ts: str level: str @@ -321,6 +375,32 @@ class ClusterCompatibilityResponse(BaseModel): note: str | None = None +class ClusterTxResponse(BaseModel): + txid: str + vsize: int + fee_btc: float + fee_rate_sat_vb: float + depends: list[str] = Field(default_factory=list) + spentby: list[str] = Field(default_factory=list) + + +class MempoolClusterResponse(BaseModel): + id: str + tx_count: int + total_vsize: int + total_fee_btc: float + avg_fee_rate_sat_vb: float + txs: list[ClusterTxResponse] = Field(default_factory=list) + + +class MempoolClustersResponse(BaseModel): + clusters: list[MempoolClusterResponse] = Field(default_factory=list) + total_tx_count: int + cluster_count: int + rpc_ok: bool + error: str | None = None + + # --- Live Simulation --- @@ -331,6 +411,7 @@ class SimulationConfig(BaseModel): class SimulationStatusResponse(BaseModel): running: bool + read_only: bool = False blocks_mined: int txs_sent: int errors: int @@ -346,6 +427,7 @@ class SimulationStatusResponse(BaseModel): class SimulationConfigRequest(BaseModel): block_interval: int | None = None + tx_interval: int | None = None # --------------------------------------------------------------------------- diff --git a/api/service.py b/api/service.py index d2e2013..bb261c5 100644 --- a/api/service.py +++ b/api/service.py @@ -195,6 +195,8 @@ def get_recent_events( limit: int = 10, offset: int = 0, event_type: str | None = None, + sort_by: str = "ts", + sort_dir: str = "desc", log_dir: PathLike | None = None, file: PathLike | None = None, ) -> dict[str, Any]: @@ -203,7 +205,14 @@ def get_recent_events( if event_type is not None: filtered_events = [event for event in state.events if event.event == event_type] - ordered_events = list(reversed(filtered_events)) + valid_sort = {"ts", "event", "origin", "level"} + field = sort_by if sort_by in valid_sort else "ts" + reverse = sort_dir.lower() != "asc" + ordered_events = sorted( + filtered_events, + key=lambda event: getattr(event, field, "") or "", + reverse=reverse, + ) page_items, total, effective_limit, effective_offset = _paginate( ordered_events, limit=limit, @@ -222,6 +231,8 @@ def get_classifications( limit: int = 10, offset: int = 0, kind: str | None = None, + sort_by: str = "ts", + sort_dir: str = "desc", log_dir: PathLike | None = None, file: PathLike | None = None, ) -> dict[str, Any]: @@ -232,7 +243,22 @@ def get_classifications( result for result in state.classifications if result.kind == kind ] - ordered_classifications = list(reversed(filtered_classifications)) + valid_sort = {"ts", "kind", "confidence", "inputs", "outputs", "total_out"} + field = sort_by if sort_by in valid_sort else "ts" + reverse = sort_dir.lower() != "asc" + + def sort_value(result: ClassifiedEvent) -> Any: + if field == "ts": + return result.raw.ts + if field == "kind": + return result.kind + if field == "confidence": + return result.metadata.get("confidence", 0) + if result.tx is None: + return 0 + return getattr(result.tx, field, 0) + + ordered_classifications = sorted(filtered_classifications, key=sort_value, reverse=reverse) page_items, total, effective_limit, effective_offset = _paginate( ordered_classifications, limit=limit, @@ -784,3 +810,98 @@ def get_cluster_compatibility() -> dict: else "Bitcoin Core 26 does not include getmempoolcluster or getmempoolfeeratediagram." ), } + + +def _build_clusters(mempool: dict[str, dict[str, Any]]) -> list[set[str]]: + graph: dict[str, set[str]] = {txid: set() for txid in mempool} + for txid, entry in mempool.items(): + for related in [*(entry.get("depends") or []), *(entry.get("spentby") or [])]: + if related in mempool: + graph[txid].add(related) + graph[related].add(txid) + + visited: set[str] = set() + clusters: list[set[str]] = [] + for txid in mempool: + if txid in visited: + continue + component: set[str] = set() + stack = [txid] + visited.add(txid) + while stack: + current = stack.pop() + component.add(current) + for neighbour in graph[current]: + if neighbour not in visited: + visited.add(neighbour) + stack.append(neighbour) + clusters.append(component) + return clusters + + +def _fee_rate_sat_vb(entry: dict[str, Any]) -> float: + vsize = float(entry.get("vsize") or entry.get("weight", 0) / 4 or 0) + fee = entry.get("fees", {}).get("base", entry.get("fee", 0)) + if not vsize: + return 0.0 + return round(float(fee) * 100_000_000 / vsize, 2) + + +def get_cluster_mempool_visual() -> dict[str, Any]: + try: + raw = get_client().getrawmempool(verbose=True) + if not isinstance(raw, dict): + raw = {} + except RPCError as exc: + return { + "clusters": [], + "total_tx_count": 0, + "cluster_count": 0, + "rpc_ok": False, + "error": str(exc), + } + + clusters = [] + for index, txids in enumerate(_build_clusters(raw), start=1): + tx_items = [] + total_vsize = 0 + total_fee = 0.0 + for txid in sorted(txids): + entry = raw[txid] + vsize = int(entry.get("vsize") or entry.get("weight", 0) / 4 or 0) + fee = float(entry.get("fees", {}).get("base", entry.get("fee", 0))) + total_vsize += vsize + total_fee += fee + tx_items.append( + { + "txid": txid, + "vsize": vsize, + "fee_btc": fee, + "fee_rate_sat_vb": _fee_rate_sat_vb(entry), + "depends": entry.get("depends") or [], + "spentby": entry.get("spentby") or [], + } + ) + clusters.append( + { + "id": f"cluster-{index}", + "tx_count": len(tx_items), + "total_vsize": total_vsize, + "total_fee_btc": round(total_fee, 8), + "avg_fee_rate_sat_vb": round( + sum(tx["fee_rate_sat_vb"] for tx in tx_items) / len(tx_items), 2 + ) + if tx_items + else 0, + "txs": tx_items, + } + ) + + clusters.sort(key=lambda item: (item["tx_count"], item["total_vsize"]), reverse=True) + return { + "clusters": clusters, + "total_tx_count": len(raw), + "cluster_count": len(clusters), + "rpc_ok": True, + "error": None, + } diff --git a/api/simulation_service.py b/api/simulation_service.py index 34aa733..7b0140c 100644 --- a/api/simulation_service.py +++ b/api/simulation_service.py @@ -31,6 +31,7 @@ _lock = threading.Lock() _stop_event = threading.Event() _thread: threading.Thread | None = None +_readonly_flag = False _state: dict[str, Any] = { "running": False, @@ -222,9 +223,12 @@ def start() -> dict[str, Any]: global _thread with _lock: - if _state["running"]: - return get_status() + readonly = _readonly_flag + already_running = bool(_state["running"]) + if readonly or already_running: + return get_status() + with _lock: # Reset stats _state["blocks_mined"] = 0 _state["txs_sent"] = 0 @@ -247,8 +251,9 @@ def start() -> dict[str, Any]: def stop() -> dict[str, Any]: with _lock: - if not _state["running"]: - return get_status() + running = bool(_state["running"]) + if not running: + return get_status() _stop_event.set() if _thread is not None: @@ -274,6 +279,7 @@ def get_status() -> dict[str, Any]: with _lock: return { **_state, + "read_only": _readonly_flag, "config": { "block_interval": _block_interval, "tx_interval": _tx_interval, @@ -295,6 +301,15 @@ def reset_stats() -> None: def auto_start() -> None: + if _readonly_flag: + logger.info("simulation: auto-start disabled because network mode is read-only") + return if os.environ.get("SIMULATION_ENABLED", "").lower() == "true": logger.info("simulation: auto-starting (SIMULATION_ENABLED=true)") start() + + +def prevent_auto_start() -> None: + global _readonly_flag + with _lock: + _readonly_flag = True diff --git a/api/storage.py b/api/storage.py index 1d28541..931fa2f 100644 --- a/api/storage.py +++ b/api/storage.py @@ -44,6 +44,8 @@ class _MemStore: demo_runs: list[dict[str, Any]] = field(default_factory=list) policy_runs: list[dict[str, Any]] = field(default_factory=list) reorg_runs: list[dict[str, Any]] = field(default_factory=list) + time_series: list[dict[str, Any]] = field(default_factory=list) + alert_configs: list[dict[str, Any]] = field(default_factory=list) _next_id: int = 1 def next_id(self) -> int: @@ -103,6 +105,25 @@ def next_id(self) -> int: proof_report_id INTEGER, created_at TEXT ); + +CREATE TABLE IF NOT EXISTS time_series ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + metric TEXT NOT NULL, + value REAL NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_time_series_metric_ts ON time_series(metric, ts); + +CREATE TABLE IF NOT EXISTS alert_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metric TEXT NOT NULL, + operator TEXT NOT NULL, + threshold REAL NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); """ # --------------------------------------------------------------------------- @@ -230,7 +251,12 @@ def list_proof_reports( offset: int = 0, source: str | None = None, success: bool | None = None, + sort_by: str = "id", + sort_dir: str = "desc", ) -> list[dict[str, Any]]: + allowed_sort = {"id", "created_at", "source", "status", "success", "txid"} + sort_col = sort_by if sort_by in allowed_sort else "id" + direction = "ASC" if sort_dir.lower() == "asc" else "DESC" with _lock: if _backend == "sqlite" and _conn: try: @@ -245,7 +271,7 @@ def list_proof_reports( where = ("WHERE " + " AND ".join(conditions)) if conditions else "" params += [limit, offset] rows = _conn.execute( - f"SELECT * FROM proof_reports {where} ORDER BY id DESC LIMIT ? OFFSET ?", + f"SELECT * FROM proof_reports {where} ORDER BY {sort_col} {direction} LIMIT ? OFFSET ?", # noqa: S608 params, ).fetchall() return [dict(r) for r in rows] @@ -257,7 +283,8 @@ def list_proof_reports( rows = [r for r in rows if r.get("source") == source] if success is not None: rows = [r for r in rows if bool(r.get("success")) == success] - rows = list(reversed(rows)) + reverse = direction == "DESC" + rows = sorted(rows, key=lambda r: r.get(sort_col) or 0, reverse=reverse) return rows[offset : offset + limit] @@ -519,3 +546,159 @@ def summary_counts() -> dict[str, Any]: "storage_up": storage_up(), "init_error": _init_error, } + + +# --------------------------------------------------------------------------- +# time_series +# --------------------------------------------------------------------------- + + +def insert_time_series(metric: str, value: float, ts: str | None = None) -> int | None: + created_at = ts or _now() + with _lock: + if _backend == "sqlite" and _conn: + try: + cur = _conn.execute( + "INSERT INTO time_series (ts, metric, value) VALUES (?,?,?)", + (created_at, metric, float(value)), + ) + _conn.commit() + return cur.lastrowid + except Exception: + return None + row = {"id": _mem.next_id(), "ts": created_at, "metric": metric, "value": float(value)} + _mem.time_series.append(row) + return row["id"] + + +def query_time_series(metric: str, since_ts: str) -> list[dict[str, Any]]: + with _lock: + if _backend == "sqlite" and _conn: + try: + rows = _conn.execute( + "SELECT * FROM time_series WHERE metric = ? AND ts >= ? ORDER BY ts ASC", + (metric, since_ts), + ).fetchall() + return [dict(r) for r in rows] + except Exception: + return [] + return [ + r + for r in sorted(_mem.time_series, key=lambda row: row.get("ts") or "") + if r.get("metric") == metric and str(r.get("ts") or "") >= since_ts + ] + + +# --------------------------------------------------------------------------- +# alert_config +# --------------------------------------------------------------------------- + + +def seed_default_alerts() -> None: + if list_alert_configs(): + return + insert_alert_config("mempool_size", "gt", 500, severity="warning", enabled=True) + insert_alert_config("minfee", "gt", 50, severity="warning", enabled=True) + insert_alert_config("rpc_offline", "eq", 1, severity="critical", enabled=True) + + +def insert_alert_config( + metric: str, + operator: str, + threshold: float, + *, + severity: str = "warning", + enabled: bool = True, +) -> int | None: + created_at = _now() + with _lock: + if _backend == "sqlite" and _conn: + try: + cur = _conn.execute( + """INSERT INTO alert_config + (metric, operator, threshold, severity, enabled, created_at) + VALUES (?,?,?,?,?,?)""", + (metric, operator, float(threshold), severity, int(enabled), created_at), + ) + _conn.commit() + return cur.lastrowid + except Exception: + return None + row = { + "id": _mem.next_id(), + "metric": metric, + "operator": operator, + "threshold": float(threshold), + "severity": severity, + "enabled": int(enabled), + "created_at": created_at, + } + _mem.alert_configs.append(row) + return row["id"] + + +def list_alert_configs() -> list[dict[str, Any]]: + with _lock: + if _backend == "sqlite" and _conn: + try: + rows = _conn.execute("SELECT * FROM alert_config ORDER BY id ASC").fetchall() + return [dict(r) for r in rows] + except Exception: + return [] + return list(_mem.alert_configs) + + +def get_alert_config(config_id: int) -> dict[str, Any] | None: + with _lock: + if _backend == "sqlite" and _conn: + try: + row = _conn.execute( + "SELECT * FROM alert_config WHERE id = ?", (config_id,) + ).fetchone() + return dict(row) if row else None + except Exception: + return None + return next((r for r in _mem.alert_configs if r.get("id") == config_id), None) + + +def update_alert_config(config_id: int, values: dict[str, Any]) -> dict[str, Any] | None: + allowed = {"metric", "operator", "threshold", "severity", "enabled"} + updates = {k: v for k, v in values.items() if k in allowed and v is not None} + if not updates: + return get_alert_config(config_id) + if "threshold" in updates: + updates["threshold"] = float(updates["threshold"]) + if "enabled" in updates: + updates["enabled"] = int(bool(updates["enabled"])) + with _lock: + if _backend == "sqlite" and _conn: + try: + assignments = ", ".join(f"{key} = ?" for key in updates) + params = [*updates.values(), config_id] + _conn.execute( + f"UPDATE alert_config SET {assignments} WHERE id = ?", # noqa: S608 + params, + ) + _conn.commit() + except Exception: + return None + else: + row = next((r for r in _mem.alert_configs if r.get("id") == config_id), None) + if row is None: + return None + row.update(updates) + return get_alert_config(config_id) + + +def delete_alert_config(config_id: int) -> bool: + with _lock: + if _backend == "sqlite" and _conn: + try: + cur = _conn.execute("DELETE FROM alert_config WHERE id = ?", (config_id,)) + _conn.commit() + return cur.rowcount > 0 + except Exception: + return False + before = len(_mem.alert_configs) + _mem.alert_configs = [r for r in _mem.alert_configs if r.get("id") != config_id] + return len(_mem.alert_configs) < before diff --git a/docs/api.md b/docs/api.md index 5d5dd86..966ae13 100644 --- a/docs/api.md +++ b/docs/api.md @@ -951,6 +951,7 @@ Endpoints that support them: `/health`, `/summary`, `/events/recent`, `/events/c | `400` | Bad request — demo step returned a non-recoverable error | | `401` | Invalid or missing API key (only when `NODESCOPE_REQUIRE_API_KEY=true`) | | `404` | Resource not found (unknown scenario ID, TXID not in store, proof report ID) | +| `429` | Rate limit exceeded — response includes `Retry-After` | | `422` | Unprocessable Entity — query parameter validation failed | | `503` | Bitcoin Core RPC offline, or API key protection misconfigured | @@ -977,8 +978,17 @@ FastAPI validation errors follow the standard format: |---|---|---|---| | GET | `/health` | open | API status and RPC reachability | | GET | `/summary` | open | Aggregate event statistics | +| GET | `/network/mode` | open | Current Bitcoin network and read-only state | | GET | `/mempool/summary` | open | Live mempool stats via RPC | +| GET | `/mempool/clusters` | open | Cluster mempool visualization data and fallback groups | | GET | `/intelligence/summary` | open | Higher-level chain intelligence | +| GET | `/charts/mempool` | open | Mempool size historical chart points | +| GET | `/charts/fees` | open | Minimum mempool fee historical chart points | +| GET | `/alerts/config` | open | Alert rule configuration | +| POST | `/alerts/config` | protected | Create alert rule | +| PUT | `/alerts/config/{id}` | protected | Update alert rule | +| DELETE | `/alerts/config/{id}` | protected | Delete alert rule | +| GET | `/alerts/active` | open | Active operational alerts | | GET | `/events/recent` | open | Paginated raw events | | GET | `/events/classifications` | open | Paginated classified events | | GET | `/events/stream` | open | Server-Sent Events stream | diff --git a/docs/assets/nodescope-api-docs.png b/docs/assets/nodescope-api-docs.png index afdddc2..4003adc 100644 Binary files a/docs/assets/nodescope-api-docs.png and b/docs/assets/nodescope-api-docs.png differ diff --git a/docs/assets/nodescope-command-center.png b/docs/assets/nodescope-command-center.png index 0079351..485bb4c 100644 Binary files a/docs/assets/nodescope-command-center.png and b/docs/assets/nodescope-command-center.png differ diff --git a/docs/assets/nodescope-dashboard.png b/docs/assets/nodescope-dashboard.png index e32d43d..e64b928 100644 Binary files a/docs/assets/nodescope-dashboard.png and b/docs/assets/nodescope-dashboard.png differ diff --git a/docs/assets/nodescope-demo-page.png b/docs/assets/nodescope-demo-page.png index 4c9cefb..b348501 100644 Binary files a/docs/assets/nodescope-demo-page.png and b/docs/assets/nodescope-demo-page.png differ diff --git a/docs/assets/nodescope-health.png b/docs/assets/nodescope-health.png index 532d768..2fae52d 100644 Binary files a/docs/assets/nodescope-health.png and b/docs/assets/nodescope-health.png differ diff --git a/docs/assets/nodescope-latest-block.png b/docs/assets/nodescope-latest-block.png index 5cc9d88..9b03829 100644 Binary files a/docs/assets/nodescope-latest-block.png and b/docs/assets/nodescope-latest-block.png differ diff --git a/docs/assets/nodescope-live-events.png b/docs/assets/nodescope-live-events.png index 4a90b96..3f737c7 100644 Binary files a/docs/assets/nodescope-live-events.png and b/docs/assets/nodescope-live-events.png differ diff --git a/docs/assets/nodescope-mempool-summary.png b/docs/assets/nodescope-mempool-summary.png index e94efa8..5d0f10c 100644 Binary files a/docs/assets/nodescope-mempool-summary.png and b/docs/assets/nodescope-mempool-summary.png differ diff --git a/docs/assets/nodescope-transaction-lifecycle.png b/docs/assets/nodescope-transaction-lifecycle.png index e6232eb..9e05569 100644 Binary files a/docs/assets/nodescope-transaction-lifecycle.png and b/docs/assets/nodescope-transaction-lifecycle.png differ diff --git a/docs/implementation-plan-v1.2.md b/docs/implementation-plan-v1.2.md new file mode 100644 index 0000000..25bf3b1 --- /dev/null +++ b/docs/implementation-plan-v1.2.md @@ -0,0 +1,265 @@ +# NodeScope v1.2 — Plano de Implementação + +> Documento gerado em 2026-05-08. Serve como referência para continuidade da implementação. + +## Funcionalidades a Implementar + +| # | Feature | Status | +|---|---------|--------| +| 1 | Signet/Mainnet read-only mode + proteção por rede | Em andamento | +| 2 | Gráficos históricos (time-series SVG) | Pendente | +| 3 | Alertas configuráveis (CRUD dinâmico) | Pendente | +| 4 | Ordenação avançada em tabelas | Pendente | +| 5 | Rate limiting (sliding window) | **Arquivo criado** ✓ | +| 6 | Cluster mempool visual real (Bitcoin Core 28+) | Pendente | + +## Estado Atual (2026-05-08) + +### Arquivos já criados: +- `api/rate_limiter.py` ✓ — Rate limiting ASGI middleware completo +- `api/network_guard.py` ✓ — Network mode detection / read-only guard completo +- `docs/implementation-plan-v1.2.md` ✓ — Este documento + +### Próximos passos imediatos (Feature 1 — continuação): +1. Modificar `api/app.py`: + - Importar `RateLimitMiddleware` e registrar com `app.add_middleware(RateLimitMiddleware)` após linha 161 + - Importar `network_guard` e `NetworkModeResponse` + - Adicionar `_guard_readonly` dependency + `_WRITE_PROTECTED = [Depends(_verify_api_key), Depends(_guard_readonly)]` + - Substituir `dependencies=_PROTECTED` por `_WRITE_PROTECTED` nas seguintes rotas: `/demo/run`, `/demo/step/{step_id}`, `/demo/reset`, `/policy/run/{scenario_id}`, `/policy/reset/{scenario_id}`, `/policy/reset`, `/reorg/run`, `/reorg/reset`, `/simulation/start`, `/simulation/stop`, `/simulation/config`, `/session/reset` + - No `lifespan`: adicionar `network_guard.refresh_network_mode()` e `if network_guard.is_read_only(): simulation_service.prevent_auto_start()` + - Novo endpoint `GET /network/mode` +2. Modificar `api/simulation_service.py`: adicionar `_readonly_flag = False` e `def prevent_auto_start():` +3. Modificar `api/schemas.py`: adicionar `NetworkModeResponse` +4. Frontend completo (Feature 1) +5. Então avançar para FASE 2 (storage), FASE 3 (charts + alerts), FASE 4 (sorting + cluster) + +--- + +## Sequência de Implementação + +### FASE 1 — Infraestrutura (base para tudo) + +#### Feature 5: Rate Limiting + +**Novo arquivo:** `api/rate_limiter.py` +- Sliding window (60s) por IP cliente +- `RATE_LIMIT_ENABLED` (default `true`), `RATE_LIMIT_RPM=600`, `RATE_LIMIT_WRITE_RPM=30` +- `/events/stream`, `/static`, `/metrics`, `/demo` e `/` são isentos para evitar quebrar SSE, assets e páginas públicas +- `_client_ip(request)` → extrai de `X-Forwarded-For` ou `request.client.host` +- Classe `RateLimitMiddleware` (ASGI): retorna HTTP 429 com header `Retry-After` +- `deque` por IP para sliding window thread-safe com `threading.Lock` + +**Modificar:** `api/app.py` +- Após `app.middleware("http")(metrics_middleware)`: + ```python + from .rate_limiter import RateLimitMiddleware + app.add_middleware(RateLimitMiddleware) + ``` + +**Modificar:** `api/metrics.py` +- Adicionar `RATE_LIMITED_TOTAL = Counter("nodescope_rate_limited_total", ..., ["method"])` + +**Modificar:** `frontend/src/api/client.ts` +- Se `res.status === 429`, throw com mensagem incluindo `Retry-After` + +#### Feature 1: Signet/Mainnet Read-Only Mode + +**Novo arquivo:** `api/network_guard.py` +```python +def detect_network_mode() -> dict: # {chain, read_only, reason} +def is_read_only() -> bool: # cached +def refresh_network_mode() -> None: # chamado no lifespan +``` +- `chain != 'regtest'` → read_only=True, reason="non_regtest_chain" +- `NODESCOPE_FORCE_READONLY=true` → read_only=True, reason="force_readonly_env" +- RPC offline → read_only=False (fail-open) + +**Modificar:** `api/app.py` +- Adicionar `_guard_readonly` dependency + `_WRITE_PROTECTED` +- Substituir `_PROTECTED` por `_WRITE_PROTECTED` em todos os POST/PUT de escrita +- Novo endpoint `GET /network/mode` +- No `lifespan`: `refresh_network_mode()` + guard na simulação + +**Modificar:** `api/simulation_service.py` +- Adicionar `prevent_auto_start()` com flag global + +**Modificar:** `api/schemas.py` — `NetworkModeResponse(chain, read_only, reason)` + +**Frontend:** +- `frontend/src/types/api.ts` — `NetworkModeData` +- `frontend/src/api/client.ts` — `networkMode()` +- `frontend/src/App.tsx` — state `networkMode`, `isReadOnly`, prop threading +- `frontend/src/components/ReadOnlyBanner.tsx` (novo) — banner âmbar full-width +- `GuidedDemo`, `MempoolPolicyArena`, `ReorgLab`, `SimulationPanel` — prop `readOnly?` + +**i18n:** `network: { readOnlyBanner, modeLabel, readOnlyReason }` em `types.ts`, `enUS.ts`, `ptBR.ts` + +--- + +### FASE 2 — Storage Extensions + +**Modificar:** `api/storage.py` — acrescentar ao `_DDL`: +```sql +CREATE TABLE IF NOT EXISTS time_series ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + metric TEXT NOT NULL, + value REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_time_series_metric_ts ON time_series(metric, ts); + +CREATE TABLE IF NOT EXISTS alert_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metric TEXT NOT NULL, + operator TEXT NOT NULL, + threshold REAL NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); +``` + +Novas funções: +- `insert_time_series(metric, value)`, `query_time_series(metric, since_ts) -> list[dict]` +- `seed_default_alerts()`, `insert_alert_config(...)`, `list_alert_configs()`, `get_alert_config(id)`, `update_alert_config(id, ...)`, `delete_alert_config(id)` + +`_MemStore`: campos `time_series: list[dict]` e `alert_configs: list[dict]` como fallback + +--- + +### FASE 3 — Endpoints e Serviços + +#### Feature 2: Historical Charts + +**Novo arquivo:** `api/charts_service.py` +```python +def start_snapshot_loop() -> None: # daemon thread, snapshot a cada 60s +def _take_snapshot() -> None: # getmempoolinfo() + getblockchaininfo() → insert_time_series +def get_mempool_chart(range_str: str) -> dict: # {range, points: [{ts, mempool_size, mempool_bytes}]} +def get_fees_chart(range_str: str) -> dict: # {range, points: [{ts, minfee}]} +``` +- Ranges: `1h` (3600s), `6h` (21600s), `24h` (86400s) +- RPCError silenciado em `_take_snapshot` + +**Novos endpoints em app.py:** +- `GET /charts/mempool?range=1h|6h|24h` +- `GET /charts/fees?range=1h|6h|24h` + +**Novo componente:** `frontend/src/components/HistoricalChartsPanel.tsx` +- SVG `` puro (sem libs externas), viewBox 400×80 +- Range selector: botões 1h/6h/24h +- Dois gráficos: mempool_size (verde `#22c55e`) e minfee sat/vb (âmbar `#f59e0b`) +- Nova view `'charts'` em `App.tsx` + `Header.tsx` + +**i18n:** `charts: { navLabel, title, mempoolSize, mempoolBytes, feeRate, noData, waitingSnapshot, range1h, range6h, range24h }` + +#### Feature 3: Configurable Alerts + +**Novo arquivo:** `api/alerts_service.py` +```python +SUPPORTED_METRICS = {"mempool_size", "mempool_bytes", "minfee", "rpc_offline"} +SUPPORTED_OPERATORS = {"gt", "lt", "eq", "gte", "lte"} +def evaluate_alerts() -> list[dict]: +def _compare(value: float, operator: str, threshold: float) -> bool: +``` +- Defaults: `mempool_size > 500 (warning)`, `minfee > 50 (warning)`, `rpc_offline eq 1 (critical)` + +**Novos endpoints:** `GET/POST /alerts/config`, `PUT/DELETE /alerts/config/{id}`, `GET /alerts/active` + +**Reescrita major:** `frontend/src/components/AlertingPanel.tsx` +- Alertas ativos via API +- Tabela de regras configuradas (edit inline, delete, enable toggle) +- Form "+ Add rule" +- Prop `readOnly?` + +**i18n ext:** `alerts.configTitle`, `configuredRules`, `addRule`, `metricLabel`, `operatorLabel`, `thresholdLabel`, `severityLabel`, `enabledLabel`, `saveRule`, `cancelEdit`, `deleteRule`, `noRules`, `currentValue`, `ruleReadOnly` + +--- + +### FASE 4 — Sorting & Cluster Visual + +#### Feature 4: Advanced Sorting + +**Modificar:** `api/service.py` +- `get_recent_events(sort_by="ts", sort_dir="desc", ...)` — sort antes do paginate +- `get_classifications(sort_by="ts", sort_dir="desc", ...)` — campos válidos: `ts`, `kind`, `confidence`, `inputs`, `outputs`, `total_out` + +**Modificar:** `api/history_service.py` + `api/storage.py` +- `list_proof_reports(sort_by="id", sort_dir="desc")` — allowlist SQL + +**Endpoints:** `sort_by` + `sort_dir` em `/events/recent`, `/events/classifications`, `/history/proofs` + +**Frontend:** Headers clicáveis com ▲/▼/– em `ClassificationsTable`, `EventsTable`; sort control em `HistoricalDashboard` + +**i18n:** `sort: { label, field, direction, asc, desc, byTs, byKind, byConfidence }` + +#### Feature 6: Visual Mempool Cluster + +**Modificar:** `api/service.py` +```python +def _build_clusters(mempool: dict[str, dict]) -> list[set[str]]: + # BFS via "depends" + "spentby" — compatível com todas as versões do Bitcoin Core + +def get_cluster_mempool_visual() -> dict: + # {clusters, total_tx_count, cluster_count, rpc_ok, error} +``` + +**Novo endpoint:** `GET /mempool/clusters` + +**Expansão major:** `frontend/src/components/ClusterMempoolPanel.tsx` +- `ClusterVisualGrid` sub-componente: flex-wrap de blocos coloridos por fee_rate +- Tamanho ∝ vsize (min 40px, max 160px) +- Verde ≥20 sat/vb, âmbar 5-20, vermelho <5 +- Click → expandir lista de txids + +**i18n ext:** `cluster.visualGrid`, `clustersFound`, `totalTx`, `clickToExpand`, `highFeeRate`, `medFeeRate`, `lowFeeRate`, `noMempool`, `versionNote`, `satVb` + +--- + +## Arquivos Críticos + +| Arquivo | Features | +|---------|----------| +| `api/app.py` | Todas | +| `api/storage.py` | 2, 3 | +| `api/network_guard.py` (novo) | 1 | +| `api/rate_limiter.py` (novo) | 5 | +| `api/charts_service.py` (novo) | 2 | +| `api/alerts_service.py` (novo) | 3 | +| `api/schemas.py` | 1, 2, 3, 6 | +| `api/service.py` | 4, 6 | +| `api/simulation_service.py` | 1 | +| `frontend/src/App.tsx` | 1, 2 | +| `frontend/src/components/Header.tsx` | 2 | +| `frontend/src/components/AlertingPanel.tsx` | 1, 3 | +| `frontend/src/components/ClusterMempoolPanel.tsx` | 6 | +| `frontend/src/components/ReadOnlyBanner.tsx` (novo) | 1 | +| `frontend/src/components/HistoricalChartsPanel.tsx` (novo) | 2 | +| `frontend/src/components/ClassificationsTable.tsx` | 4 | +| `frontend/src/components/EventsTable.tsx` | 4 | +| `frontend/src/components/HistoricalDashboard.tsx` | 4 | +| `frontend/src/api/client.ts` | Todas | +| `frontend/src/types/api.ts` | Todas | +| `frontend/src/i18n/types.ts` | Todas | +| `frontend/src/i18n/enUS.ts` | Todas | +| `frontend/src/i18n/ptBR.ts` | Todas | + +--- + +## Verificação por Feature + +1. **Rate limiting:** `for i in $(seq 70); do curl -s http://localhost:8000/health; done` → 429 após 60/min +2. **Read-only:** `NODESCOPE_FORCE_READONLY=true` → `GET /network/mode` retorna `{read_only: true}` → `POST /demo/run` retorna 403 +3. **Charts:** Aguardar 2 snapshots (120s) → `GET /charts/mempool?range=1h` retorna points → SVG polyline visível +4. **Alerts:** `POST /alerts/config {metric: "mempool_size", operator: "gt", threshold: 1}` → `GET /alerts/active` dispara +5. **Sorting:** `GET /events/classifications?sort_by=kind&sort_dir=asc` → alfabético +6. **Cluster:** `GET /mempool/clusters` com mempool não-vazio → clusters corretos; grid visual colorido + +--- + +## Restrições + +- Sem novos pacotes npm +- Sem breaking changes em endpoints existentes +- Commits sem `Co-Authored-By` +- Dark theme: `#111`, `#1f2937`, `#374151`; verde `#22c55e`, âmbar `#f59e0b`, vermelho `#ef4444` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e44cee0..35c4d95 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useCallback, useState, useEffect } from 'react' import { api } from './api/client' import { useInterval } from './hooks/useInterval' import { useSSE } from './hooks/useSSE' @@ -12,6 +12,7 @@ import type { TxData, IntelligenceData, DemoStep, + NetworkModeData, } from './types/api' import { Header, type ActiveView } from './components/Header' import { KpiRow } from './components/KpiRow' @@ -32,9 +33,12 @@ import { ZmqEventTape } from './components/ZmqEventTape' import { MempoolPolicyArena } from './components/MempoolPolicyArena' import { ReorgLab } from './components/ReorgLab' import { SimulationPanel } from './components/SimulationPanel' +import { ReadOnlyBanner } from './components/ReadOnlyBanner' import AlertingPanel from './components/AlertingPanel' import HistoricalDashboard from './components/HistoricalDashboard' import { FeeEstimationPlayground } from './components/FeeEstimationPlayground' +import { HistoricalChartsPanel } from './components/HistoricalChartsPanel' +import { ClusterMempoolPanel } from './components/ClusterMempoolPanel' import { Footer } from './components/Footer' import { ExplainBox } from './components/ui/ExplainBox' import { @@ -55,9 +59,18 @@ export default function App() { const [latestBlock, setLatestBlock] = useState(null) const [latestTx, setLatestTx] = useState(null) const [intelligence, setIntelligence] = useState(null) + const [networkMode, setNetworkMode] = useState(null) const [activeView, setActiveView] = useState('dashboard') const [inspectorTxid, setInspectorTxid] = useState('') const [guidedDemoSteps, setGuidedDemoSteps] = useState([]) + const [eventSort, setEventSort] = useState<{ by: string; dir: 'asc' | 'desc' }>({ + by: 'ts', + dir: 'desc', + }) + const [classificationSort, setClassificationSort] = useState<{ + by: string + dir: 'asc' | 'desc' + }>({ by: 'ts', dir: 'desc' }) const { events: sseEvents, connected: sseConnected, @@ -75,19 +88,21 @@ export default function App() { const i18nValue: I18nContextValue = { lang, t, setLang } - const fetchAll = async () => { + const fetchAll = useCallback(async () => { try { - const [h, m, s, e, c, b, tx, intel] = await Promise.allSettled([ + const [h, mode, m, s, e, c, b, tx, intel] = await Promise.allSettled([ api.health(), + api.networkMode(), api.mempool(), api.summary(), - api.recentEvents(20), - api.classifications(20), + api.recentEvents(20, eventSort.by, eventSort.dir), + api.classifications(20, classificationSort.by, classificationSort.dir), api.latestBlock(), api.latestTx(), api.intelligenceSummary(), ]) if (h.status === 'fulfilled') setHealth(h.value) + if (mode.status === 'fulfilled') setNetworkMode(mode.value) if (m.status === 'fulfilled') setMempool(m.value) if (s.status === 'fulfilled') setSummary(s.value) if (e.status === 'fulfilled') setEvents(e.value.items) @@ -98,16 +113,17 @@ export default function App() { } catch { /* ignore */ } - } + }, [eventSort, classificationSort]) useEffect(() => { void fetchAll() - }, []) + }, [fetchAll]) useInterval(fetchAll, 5000) const network = health?.chain ?? health?.network ?? 'regtest' const rpcOk = health?.rpc_ok ?? false const apiOk = health !== null + const isReadOnly = networkMode?.read_only ?? false const handleInspect = (txid: string) => { setInspectorTxid(txid) @@ -115,6 +131,10 @@ export default function App() { } const handleNewSession = async () => { + if (isReadOnly) { + window.alert(t.network.readOnlyActionBlocked) + return + } if (!window.confirm(t.header.newSessionConfirm)) return try { await api.sessionReset() @@ -130,6 +150,7 @@ export default function App() { setHealth(null) setMempool(null) setIntelligence(null) + setNetworkMode(null) void fetchAll() } @@ -149,12 +170,14 @@ export default function App() { }} /> ) + const readOnlyBanner = return ( {activeView === 'guided-demo' ? (
{header} + {readOnlyBanner}
{/* Left: scrollable steps list */}
- +
{/* Right: fixed sidebar — lifecycle + explain */}
{header} + {readOnlyBanner}
@@ -205,6 +229,7 @@ export default function App() { ) : activeView === 'zmq-tape' ? (
{header} + {readOnlyBanner}
@@ -214,20 +239,26 @@ export default function App() { ) : activeView === 'policy-arena' ? (
{header} + {readOnlyBanner}
- setActiveView('dashboard')} /> + setActiveView('dashboard')} + readOnly={isReadOnly} + />
) : activeView === 'reorg-lab' ? (
{header} + {readOnlyBanner}
setActiveView('dashboard')} + readOnly={isReadOnly} />
@@ -235,14 +266,34 @@ export default function App() { ) : activeView === 'history' ? (
{header} + {readOnlyBanner}
+ ) : activeView === 'charts' ? ( +
+ {header} + {readOnlyBanner} +
+ +
+
+
+ ) : activeView === 'cluster' ? ( +
+ {header} + {readOnlyBanner} +
+ +
+
+
) : activeView === 'fee-estimation' ? (
{header} + {readOnlyBanner}
@@ -253,9 +304,10 @@ export default function App() { // Default: dashboard
{header} + {readOnlyBanner}
- +
- +
@@ -276,13 +328,33 @@ export default function App() {
- + + setEventSort((prev) => ({ + by, + dir: prev.by === by && prev.dir === 'desc' ? 'asc' : 'desc', + })) + } + />
- + + setClassificationSort((prev) => ({ + by, + dir: prev.by === by && prev.dir === 'desc' ? 'asc' : 'desc', + })) + } + />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e775ced..a9d02e1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,23 +1,70 @@ async function get(path: string): Promise { 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 + if (!res.ok) throw await buildApiError(path, res) + return parseJson(path, res) } async function post(path: string): Promise { const res = await fetch(path, { method: 'POST' }) - if (!res.ok) throw new Error(`${path}: ${res.status}`) - return res.json() as Promise + if (!res.ok) throw await buildApiError(path, res) + return parseJson(path, res) +} + +async function parseJson(path: string, res: Response): Promise { + const contentType = res.headers.get('Content-Type') ?? '' + const text = await res.text() + if (!contentType.includes('application/json')) { + const hint = text.trim().startsWith('<') + ? 'Received HTML instead of JSON. Check the frontend proxy/API route.' + : 'Response is not JSON.' + throw new Error(`${path}: ${hint}`) + } + try { + return JSON.parse(text) as T + } catch (e) { + throw new Error(`${path}: Invalid JSON response (${e instanceof Error ? e.message : e})`) + } +} + +async function buildApiError(path: string, res: Response): Promise { + const retryAfter = res.headers.get('Retry-After') + const contentType = res.headers.get('Content-Type') ?? '' + let detail = '' + try { + const text = await res.text() + if (contentType.includes('application/json')) { + const body = JSON.parse(text) as { detail?: unknown } + detail = + typeof body.detail === 'string' + ? body.detail + : body.detail + ? JSON.stringify(body.detail) + : '' + } else { + detail = text.trim().startsWith('<') + ? 'Received HTML instead of API JSON' + : text.slice(0, 120) + } + } catch { + /* response body is optional */ + } + const retry = res.status === 429 && retryAfter ? ` Retry after ${retryAfter}s.` : '' + return new Error(`${path}: ${res.status}${detail ? ` - ${detail}` : ''}${retry}`) } export const api = { health: () => get('/health'), + networkMode: () => get('/network/mode'), 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, sortBy = 'ts', sortDir: 'asc' | 'desc' = 'desc') => + get( + `/events/recent?limit=${limit}&sort_by=${sortBy}&sort_dir=${sortDir}` + ), + classifications: (limit = 20, sortBy = 'ts', sortDir: 'asc' | 'desc' = 'desc') => + get( + `/events/classifications?limit=${limit}&sort_by=${sortBy}&sort_dir=${sortDir}` + ), latestBlock: () => get('/blocks/latest'), latestTx: () => get('/tx/latest'), txById: (txid: string) => get(`/tx/${txid}`), @@ -52,6 +99,38 @@ export const api = { // Cluster Mempool Compatibility clusterCompatibility: () => get('/mempool/cluster/compatibility'), + mempoolClusters: () => get('/mempool/clusters'), + // Charts + chartsMempool: (range = '1h') => + get(`/charts/mempool?range=${range}`), + chartsFees: (range = '1h') => + get(`/charts/fees?range=${range}`), + // Alerts + alertsConfig: () => get('/alerts/config'), + alertsActive: () => get('/alerts/active'), + alertsCreate: (body: import('../types/api').AlertConfigInput) => + fetch('/alerts/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(async (r) => { + if (!r.ok) throw await buildApiError('/alerts/config', r) + return r.json() as Promise + }), + alertsUpdate: (id: number, body: import('../types/api').AlertConfigInput) => + fetch(`/alerts/config/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(async (r) => { + if (!r.ok) throw await buildApiError(`/alerts/config/${id}`, r) + return r.json() as Promise + }), + alertsDelete: (id: number) => + fetch(`/alerts/config/${id}`, { method: 'DELETE' }).then(async (r) => { + if (!r.ok) throw await buildApiError(`/alerts/config/${id}`, r) + return r.json() as Promise<{ ok: boolean }> + }), // Simulation simulationStatus: () => get('/simulation/status'), simulationStart: () => post('/simulation/start'), @@ -62,14 +141,29 @@ export const api = { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body, - }).then((r) => r.json() as Promise) + }).then(async (r) => { + if (!r.ok) throw await buildApiError('/simulation/config', r) + return r.json() as Promise + }) }, // Session sessionReset: () => post<{ ok: boolean; truncated: boolean; file: string }>('/session/reset'), // History historySummary: () => get('/history/summary'), - historyProofs: (limit = 20, offset = 0, source?: string, success?: boolean) => { - const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }) + historyProofs: ( + limit = 20, + offset = 0, + source?: string, + success?: boolean, + sortBy = 'id', + sortDir: 'asc' | 'desc' = 'desc' + ) => { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + sort_by: sortBy, + sort_dir: sortDir, + }) if (source) params.set('source', source) if (success !== undefined) params.set('success', String(success)) return get< diff --git a/frontend/src/components/AlertingPanel.tsx b/frontend/src/components/AlertingPanel.tsx index 8f0eaca..0e30a94 100644 --- a/frontend/src/components/AlertingPanel.tsx +++ b/frontend/src/components/AlertingPanel.tsx @@ -1,203 +1,216 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { api } from '../api/client' import { useI18n } from '../i18n/index' -import type { HealthData, SimulationData } from '../types/api' - -type Severity = 'critical' | 'warning' | 'info' - -interface Alert { - id: string - severity: Severity - title: string - description: string -} - -const POLL_INTERVAL_MS = 15_000 - -const SEVERITY_COLORS: Record = { - critical: '#ef4444', - warning: '#f59e0b', - info: '#3b82f6', +import type { ActiveAlert, AlertConfig } from '../types/api' + +const METRICS = ['mempool_size', 'mempool_bytes', 'minfee', 'rpc_offline'] +const OPERATORS = ['gt', 'lt', 'eq', 'gte', 'lte'] +const controlStyle: React.CSSProperties = { + background: '#0d1117', + border: '1px solid #374151', + borderRadius: 4, + color: '#e5e7eb', + padding: '5px 8px', + fontFamily: 'monospace', + fontSize: 12, } -const SEVERITY_BG: Record = { - critical: 'rgba(239,68,68,0.08)', - warning: 'rgba(245,158,11,0.08)', - info: 'rgba(59,130,246,0.08)', -} - -const SEVERITY_ICON: Record = { - critical: '✕', - warning: '⚠', - info: 'ℹ', -} - -export default function AlertingPanel() { +export default function AlertingPanel({ readOnly = false }: { readOnly?: boolean }) { const { t } = useI18n() - const [alerts, setAlerts] = useState([]) - const [lastCheck, setLastCheck] = useState(null) - - async function evaluate() { - const newAlerts: Alert[] = [] + const [active, setActive] = useState([]) + const [configs, setConfigs] = useState([]) + const [draft, setDraft] = useState({ metric: 'mempool_size', operator: 'gt', threshold: 500 }) + const [error, setError] = useState(null) - // --- RPC status --- - let health: HealthData | null = null + const load = useCallback(async () => { try { - health = await api.health() - if (!health.rpc_ok) { - newAlerts.push({ - id: 'rpc_offline', - severity: 'critical', - title: t.alerts.rpcOffline, - description: t.alerts.rpcOfflineDesc, - }) - } - } catch { - newAlerts.push({ - id: 'rpc_offline', - severity: 'critical', - title: t.alerts.rpcOffline, - description: t.alerts.rpcOfflineDesc, - }) + const [activeData, configData] = await Promise.all([api.alertsActive(), api.alertsConfig()]) + setActive(activeData.items) + setConfigs(configData.items) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) } + }, []) - // --- Simulation errors --- + useEffect(() => { + void load() + const id = setInterval(() => void load(), 15_000) + return () => clearInterval(id) + }, [load]) + + async function addRule() { + if (readOnly) return try { - const sim = (await api.simulationStatus()) as SimulationData - if (sim.errors > 0) { - newAlerts.push({ - id: 'sim_errors', - severity: 'warning', - title: t.alerts.simulationError, - description: t.alerts.simulationErrorDesc, - }) - } - } catch { - // Simulation endpoint unavailable — not an alert condition + await api.alertsCreate({ ...draft, severity: 'warning', enabled: true }) + await load() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) } + } - // --- Cluster mempool unavailable (info) --- + async function toggleRule(rule: AlertConfig) { + if (readOnly || rule.id == null) return try { - const cluster = await api.clusterCompatibility() - if (!cluster.supported) { - newAlerts.push({ - id: 'cluster_unavailable', - severity: 'info', - title: t.alerts.clusterUnavailable, - description: t.alerts.clusterUnavailableDesc, - }) - } - } catch { - // Not critical + await api.alertsUpdate(rule.id, { enabled: !rule.enabled }) + await load() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) } - - setAlerts(newAlerts) - setLastCheck(new Date()) } - useEffect(() => { - evaluate() - const id = setInterval(evaluate, POLL_INTERVAL_MS) - return () => clearInterval(id) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + async function deleteRule(rule: AlertConfig) { + if (readOnly || rule.id == null) return + try { + await api.alertsDelete(rule.id) + await load() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } - const actionable = alerts.filter((a) => a.severity === 'critical' || a.severity === 'warning') - const allGood = actionable.length === 0 - const hasCritical = actionable.some((a) => a.severity === 'critical') - const hasWarning = actionable.some((a) => a.severity === 'warning') - const panelBorder = hasCritical - ? '1px solid rgba(239,68,68,0.4)' - : hasWarning - ? '1px solid rgba(245,158,11,0.3)' - : '1px solid rgba(34,197,94,0.3)' - const panelHeaderColor = hasCritical ? '#ef4444' : hasWarning ? '#f59e0b' : '#22c55e' + const activeActionable = active.filter((item) => item.severity !== 'info') return (
-
- +
+ {t.alerts.title.toUpperCase()} - {lastCheck && ( - {lastCheck.toLocaleTimeString()} - )} +
- {allGood ? ( -
- - {t.alerts.allGood} + {error &&
{error}
} + {readOnly && ( +
+ {t.network.readOnlyActionBlocked}
+ )} + + {active.length === 0 ? ( +
✓ {t.alerts.allGood}
) : ( -
- {actionable.map((alert) => ( +
+ {active.map((item) => (
- - {SEVERITY_ICON[alert.severity]} - -
-
- - {t.alerts.severity[alert.severity].toUpperCase()} - - {alert.title} -
-
{alert.description}
-
+ {item.severity.toUpperCase()} · {item.metric} {item.operator} {item.threshold} ·{' '} + {item.current_value}
))}
)} + +
+ {configs.map((rule) => ( +
+ void toggleRule(rule)} + /> + + {rule.metric} {rule.operator} {rule.threshold} · {rule.severity} + + +
+ ))} +
+ +
+ + + setDraft((d) => ({ ...d, threshold: Number(e.target.value) }))} + style={{ ...controlStyle, width: 90 }} + /> + +
) } diff --git a/frontend/src/components/ClassificationsTable.tsx b/frontend/src/components/ClassificationsTable.tsx index 2ffde82..b61eae8 100644 --- a/frontend/src/components/ClassificationsTable.tsx +++ b/frontend/src/components/ClassificationsTable.tsx @@ -6,6 +6,9 @@ import { Term } from './ui/InfoTooltip' interface Props { classifications: ClassificationItem[] + sortBy?: string + sortDir?: 'asc' | 'desc' + onSort?: (field: string) => void } function kindClass(kind: string): string { @@ -15,9 +18,15 @@ function kindClass(kind: string): string { return 'unknown' } -export function ClassificationsTable({ classifications }: Props) { +export function ClassificationsTable({ + classifications, + sortBy = 'ts', + sortDir = 'desc', + onSort, +}: Props) { const { t } = useI18n() const [copiedKey, setCopiedKey] = useState(null) + const sortLabel = (field: string) => (sortBy === field ? (sortDir === 'asc' ? '▲' : '▼') : '–') async function copyValue(key: string, value: string | null | undefined) { if (await copyText(value)) { @@ -37,6 +46,24 @@ export function ClassificationsTable({ classifications }: Props) {
+
+ {['ts', 'kind', 'confidence', 'inputs', 'outputs', 'total_out'].map((field) => ( + + ))} +
{classifications.length === 0 ? (
{t.dashboard.noClassifications}
) : ( diff --git a/frontend/src/components/ClusterMempoolPanel.tsx b/frontend/src/components/ClusterMempoolPanel.tsx index 1c79e3c..d2b6885 100644 --- a/frontend/src/components/ClusterMempoolPanel.tsx +++ b/frontend/src/components/ClusterMempoolPanel.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react' +import { useCallback, useState, useEffect } from 'react' import { api } from '../api/client' -import type { ClusterCompatibilityData } from '../types/api' +import type { ClusterCompatibilityData, MempoolClustersData } from '../types/api' import { useI18n } from '../i18n' import { Term } from './ui/InfoTooltip' import { LearnMore } from './ui/LearnMore' @@ -8,24 +8,29 @@ import { LearnMore } from './ui/LearnMore' export function ClusterMempoolPanel() { const { t } = useI18n() const [data, setData] = useState(null) + const [clusters, setClusters] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const fetchData = async () => { + const fetchData = useCallback(async () => { setLoading(true) setError(null) try { - const result = await api.clusterCompatibility() - setData(result) + const [compat, visual] = await Promise.all([ + api.clusterCompatibility(), + api.mempoolClusters(), + ]) + setData(compat) + setClusters(visual) } catch (err) { setError(err instanceof Error ? err.message : t.generic.error) } setLoading(false) - } + }, [t.generic.error]) useEffect(() => { void fetchData() - }, []) + }, [fetchData]) return (
+ {clusters && ( +
+
+ {clusters.cluster_count} clusters · {clusters.total_tx_count} tx +
+ {!clusters.rpc_ok && ( +
+ {clusters.error} +
+ )} + {clusters.rpc_ok && clusters.clusters.length === 0 && ( +
+ {t.cluster.fallback} +
+ )} +
+ {clusters.clusters.flatMap((cluster) => + cluster.txs.map((tx) => { + const fee = tx.fee_rate_sat_vb + const bg = fee >= 20 ? '#14532d' : fee >= 5 ? '#78350f' : '#7f1d1d' + const size = Math.max(40, Math.min(160, tx.vsize / 2)) + return ( +
+
{fee} sat/vB
+
{tx.txid.slice(0, 10)}…
+
+ ) + }) + )} +
+
+ )} +
diff --git a/frontend/src/components/EventsTable.tsx b/frontend/src/components/EventsTable.tsx index 87b4c96..11b6be9 100644 --- a/frontend/src/components/EventsTable.tsx +++ b/frontend/src/components/EventsTable.tsx @@ -4,6 +4,9 @@ import { Term } from './ui/InfoTooltip' interface Props { events: EventItem[] + sortBy?: string + sortDir?: 'asc' | 'desc' + onSort?: (field: string) => void } function eventBadgeClass(event: string): string { @@ -12,8 +15,9 @@ function eventBadgeClass(event: string): string { return 'unknown' } -export function EventsTable({ events }: Props) { +export function EventsTable({ events, sortBy = 'ts', sortDir = 'desc', onSort }: Props) { const { t } = useI18n() + const sortLabel = (field: string) => (sortBy === field ? (sortDir === 'asc' ? '▲' : '▼') : '–') return (
@@ -25,6 +29,24 @@ export function EventsTable({ events }: Props) {
+
+ {['ts', 'event', 'origin'].map((field) => ( + + ))} +
{events.length === 0 ? (
{t.zmq.noEvents}
) : ( diff --git a/frontend/src/components/FeeEstimationPlayground.tsx b/frontend/src/components/FeeEstimationPlayground.tsx index 9eb2c53..bb7d078 100644 --- a/frontend/src/components/FeeEstimationPlayground.tsx +++ b/frontend/src/components/FeeEstimationPlayground.tsx @@ -242,12 +242,8 @@ export function FeeEstimationPlayground() { cursor: 'pointer', }} > - - + +
diff --git a/frontend/src/components/GuidedDemo.tsx b/frontend/src/components/GuidedDemo.tsx index 1f22b14..9f9e466 100644 --- a/frontend/src/components/GuidedDemo.tsx +++ b/frontend/src/components/GuidedDemo.tsx @@ -49,15 +49,17 @@ function StepRow({ index, onRunStep, running, + readOnly, }: { step: DemoStep index: number onRunStep: (id: string) => void running: boolean + readOnly?: boolean }) { const { t } = useI18n() const [expanded, setExpanded] = useState(false) - const canRun = !running && step.status !== 'running' + const canRun = !readOnly && !running && step.status !== 'running' const desc = (t.demo.stepDesc as Record)[step.id] return ( @@ -372,7 +374,13 @@ function btnStyle(bg: string): React.CSSProperties { const POLL_INTERVAL_MS = 1500 -export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep[]) => void }) { +export function GuidedDemo({ + onStepsChange, + readOnly = false, +}: { + onStepsChange?: (steps: DemoStep[]) => void + readOnly?: boolean +}) { const { t } = useI18n() const [demoState, setDemoState] = useState(null) const [error, setError] = useState(null) @@ -468,8 +476,8 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep onClick={() => { void handleRunFull() }} - disabled={running} - style={btnStyle(running ? '#374151' : '#16a34a')} + disabled={readOnly || running} + style={btnStyle(readOnly || running ? '#374151' : '#16a34a')} > {running ? t.status.running : t.actions.runFull} @@ -477,8 +485,8 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep onClick={() => { void handleReset() }} - disabled={running} - style={btnStyle('#7f1d1d')} + disabled={readOnly || running} + style={btnStyle(readOnly || running ? '#374151' : '#7f1d1d')} > {t.actions.reset} @@ -511,6 +519,22 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep
)} + {readOnly && ( +
+ {t.network.readOnlyActionBlocked} +
+ )} + {/* Steps */} {steps.length === 0 ? (
{t.demo.loadingState}
@@ -524,6 +548,7 @@ export function GuidedDemo({ onStepsChange }: { onStepsChange?: (steps: DemoStep void handleRunStep(id) }} running={running} + readOnly={readOnly} /> )) )} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index c5a1d40..b015078 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -3,6 +3,7 @@ import type { Lang } from '../i18n' export type ActiveView = | 'dashboard' + | 'charts' | 'guided-demo' | 'inspector' | 'zmq-tape' @@ -10,6 +11,7 @@ export type ActiveView = | 'reorg-lab' | 'history' | 'fee-estimation' + | 'cluster' interface Props { network: string @@ -41,6 +43,7 @@ export function Header({ const NAV: { id: ActiveView; label: string }[] = [ { id: 'dashboard', label: t.nav.dashboard }, + { id: 'charts', label: t.charts.navLabel }, { id: 'guided-demo', label: t.nav.guidedDemo }, { id: 'inspector', label: t.nav.txInspector }, { id: 'zmq-tape', label: t.nav.zmqTape }, @@ -48,6 +51,7 @@ export function Header({ { id: 'reorg-lab', label: t.nav.reorgLab }, { id: 'history', label: t.history.title }, { id: 'fee-estimation', label: t.fees.navLabel }, + { id: 'cluster', label: t.nav.clusterMempool }, ] const networkClass = ['mainnet', 'regtest', 'signet', 'testnet'].includes(network) @@ -55,14 +59,14 @@ export function Header({ : 'badge-regtest' return ( -
+
{t.header.title} {network}
{/* Nav tabs */} -
+
{NAV.map(({ id, label }) => ( + ) + + const hasData = (mempool?.points.length ?? 0) > 0 || (fees?.points.length ?? 0) > 0 + + return ( +
+
+ {t.charts.title} + + {button('1h', t.charts.range1h)} + {button('6h', t.charts.range6h)} + {button('24h', t.charts.range24h)} + +
+
+ {!hasData && ( +
+ {loading ? t.status.loading : `${t.charts.noData} ${t.charts.waitingSnapshot}`} +
+ )} +
+
+
+ {t.charts.mempoolSize} +
+ +
+
+
+ {t.charts.feeRate} +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/components/HistoricalDashboard.tsx b/frontend/src/components/HistoricalDashboard.tsx index b506c56..072d015 100644 --- a/frontend/src/components/HistoricalDashboard.tsx +++ b/frontend/src/components/HistoricalDashboard.tsx @@ -19,10 +19,16 @@ const SECTION_STYLE: React.CSSProperties = { const TABLE_STYLE: React.CSSProperties = { width: '100%', + minWidth: 560, borderCollapse: 'collapse', fontSize: 13, } +const TABLE_WRAP_STYLE: React.CSSProperties = { + maxWidth: '100%', + overflowX: 'auto', +} + const TH_STYLE: React.CSSProperties = { textAlign: 'left', padding: '6px 10px', @@ -108,6 +114,10 @@ export default function HistoricalDashboard() { const [copied, setCopied] = useState(null) const [exportMsg, setExportMsg] = useState(null) const [activeTab, setActiveTab] = useState<'proofs' | 'demo' | 'policy' | 'reorg'>('proofs') + const [proofSort, setProofSort] = useState<{ by: string; dir: 'asc' | 'desc' }>({ + by: 'id', + dir: 'desc', + }) const load = useCallback(async () => { setLoading(true) @@ -115,7 +125,7 @@ export default function HistoricalDashboard() { try { const [sumData, proofsData, demoData, policyData, reorgData] = await Promise.all([ api.historySummary(), - api.historyProofs(20), + api.historyProofs(20, 0, undefined, undefined, proofSort.by, proofSort.dir), api.historyDemoRuns(20), api.historyPolicyRuns(20), api.historyReorgRuns(20), @@ -130,7 +140,7 @@ export default function HistoricalDashboard() { } finally { setLoading(false) } - }, []) + }, [proofSort]) useEffect(() => { load() @@ -315,6 +325,32 @@ export default function HistoricalDashboard() { ))}
+ {activeTab === 'proofs' && ( +
+ {['id', 'created_at', 'source', 'status', 'success'].map((field) => ( + + ))} +
+ )} + {/* Tables */} {activeTab === 'proofs' && (
@@ -324,75 +360,77 @@ export default function HistoricalDashboard() { {proofs.length === 0 ? (

{t.history.empty}

) : ( - - - - - - - - - - - - - - {proofs.map((item) => ( - - - - - - - - + + + + + + + ))} + +
{t.history.scenarioLabel}{t.history.sourceLabel}{t.history.successLabel}{t.history.txidLabel}{t.history.blockLabel}{t.history.createdLabel}
- - {item.scenario_name ?? '—'} - - - - {item.source ?? '—'} - - - - - - - - {item.block_height ?? '—'} - - - - - {item.summary && ( - + + + + + + {item.block_height ?? '—'} + + + + + {item.summary && ( + + )} +
+
)}
)} @@ -405,36 +443,38 @@ export default function HistoricalDashboard() { {demoRuns.length === 0 ? (

{t.history.empty}

) : ( - - - - - - - - - - - {demoRuns.map((item) => ( - - - - - +
+
{t.history.successLabel}{t.history.txidLabel}Duration{t.history.createdLabel}
- - - - - - {item.duration_ms != null ? `${Math.round(item.duration_ms)} ms` : '—'} - - - -
+ + + + + + - ))} - -
{t.history.successLabel}{t.history.txidLabel}Duration{t.history.createdLabel}
+ + + {demoRuns.map((item) => ( + + + + + + + + + + {item.duration_ms != null ? `${Math.round(item.duration_ms)} ms` : '—'} + + + + + + + ))} + + +
)}
)} @@ -447,44 +487,46 @@ export default function HistoricalDashboard() { {policyRuns.length === 0 ? (

{t.history.empty}

) : ( - - - - - - - - - - - {policyRuns.map((item) => ( - - - - - +
+
{t.history.scenarioLabel}{t.history.successLabel}{t.history.txidLabel}{t.history.createdLabel}
- - {item.scenario_id ?? '—'} - - - - - - - -
+ + + + + + - ))} - -
{t.history.scenarioLabel}{t.history.successLabel}{t.history.txidLabel}{t.history.createdLabel}
+ + + {policyRuns.map((item) => ( + + + + {item.scenario_id ?? '—'} + + + + + + + + + + + + + ))} + + +
)}
)} @@ -497,38 +539,40 @@ export default function HistoricalDashboard() { {reorgRuns.length === 0 ? (

{t.history.empty}

) : ( - - - - - - - - - - - - {reorgRuns.map((item) => ( - - - - - - +
+
{t.history.successLabel}{t.history.txidLabel}Original BlockFinal Block{t.history.createdLabel}
- - - - - - - - - -
+ + + + + + + - ))} - -
{t.history.successLabel}{t.history.txidLabel}Original BlockFinal Block{t.history.createdLabel}
+ + + {reorgRuns.map((item) => ( + + + + + + + + + + + + + + + + + + ))} + + +
)}
)} diff --git a/frontend/src/components/MempoolPolicyArena.tsx b/frontend/src/components/MempoolPolicyArena.tsx index 889ecc1..20868f7 100644 --- a/frontend/src/components/MempoolPolicyArena.tsx +++ b/frontend/src/components/MempoolPolicyArena.tsx @@ -236,6 +236,7 @@ interface ScenarioCardProps { onRun: (id: string) => void onReset: (id: string) => void onGoToDashboard: () => void + readOnly?: boolean } function ScenarioCard({ @@ -244,6 +245,7 @@ function ScenarioCard({ onRun, onReset, onGoToDashboard, + readOnly, }: ScenarioCardProps) { const { t } = useI18n() const [detail, setDetail] = useState(null) @@ -342,16 +344,16 @@ function ScenarioCard({
)} + {readOnly && ( +
+ {t.network.readOnlyActionBlocked} +
+ )} + {/* Scenario legend */}
onGoToDashboard?.()} + readOnly={readOnly} /> ))}
diff --git a/frontend/src/components/ReadOnlyBanner.tsx b/frontend/src/components/ReadOnlyBanner.tsx new file mode 100644 index 0000000..95b8087 --- /dev/null +++ b/frontend/src/components/ReadOnlyBanner.tsx @@ -0,0 +1,27 @@ +import type { NetworkModeData } from '../types/api' +import { useI18n } from '../i18n' + +export function ReadOnlyBanner({ mode }: { mode: NetworkModeData | null }) { + const { t } = useI18n() + if (!mode?.read_only) return null + + return ( +
+ {t.network.readOnlyBanner}{' '} + + {t.network.modeLabel}: {mode.chain ?? 'unknown'} · {t.network.readOnlyReason}: {mode.reason} + +
+ ) +} diff --git a/frontend/src/components/ReorgLab.tsx b/frontend/src/components/ReorgLab.tsx index c56c1a9..ce4c5e0 100644 --- a/frontend/src/components/ReorgLab.tsx +++ b/frontend/src/components/ReorgLab.tsx @@ -33,9 +33,10 @@ const REORG_WARNING_KEYS: Record void onGoToDashboard?: () => void + readOnly?: boolean } -export function ReorgLab({ onInspect, onGoToDashboard }: Props) { +export function ReorgLab({ onInspect, onGoToDashboard, readOnly = false }: Props) { const { t } = useI18n() const [data, setData] = useState(null) const [loading, setLoading] = useState(false) @@ -154,16 +155,16 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { onClick={() => { void handleRun() }} - disabled={loading || data?.running} + disabled={readOnly || loading || data?.running} style={{ padding: '6px 16px', fontSize: '12px', borderRadius: '4px', cursor: 'pointer', - background: data?.running ? '#374151' : '#1d4ed8', + background: readOnly || data?.running ? '#374151' : '#1d4ed8', color: '#fff', border: '1px solid #3b82f6', - opacity: loading || data?.running ? 0.6 : 1, + opacity: readOnly || loading || data?.running ? 0.6 : 1, }} > {data?.running ? `⏳ ${t.reorg.running}` : t.reorg.runReorg} @@ -172,7 +173,7 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) { onClick={() => { void handleReset() }} - disabled={data?.running} + disabled={readOnly || data?.running} style={{ padding: '6px 14px', fontSize: '12px', @@ -205,6 +206,22 @@ export function ReorgLab({ onInspect, onGoToDashboard }: Props) {
+ {readOnly && ( +
+ {t.network.readOnlyActionBlocked} +
+ )} + {/* Status bar */}
+
{icon} -
+
{label}
-
{step.message}
+
+ {step.message} +
{step.status === 'error' && !!step.technical && (
{JSON.stringify(step.technical).slice(0, 120)} diff --git a/frontend/src/components/SimulationPanel.tsx b/frontend/src/components/SimulationPanel.tsx index 033faed..228655e 100644 --- a/frontend/src/components/SimulationPanel.tsx +++ b/frontend/src/components/SimulationPanel.tsx @@ -62,7 +62,7 @@ function CountdownBar({ ) } -export function SimulationPanel() { +export function SimulationPanel({ readOnly = false }: { readOnly?: boolean }) { const { t } = useI18n() const [data, setData] = useState(null) const [configOpen, setConfigOpen] = useState(false) @@ -93,7 +93,7 @@ export function SimulationPanel() { }, [data, editing]) const handleStartStop = async () => { - if (!data || loading) return + if (readOnly || !data || loading) return setLoading(true) try { const result = data.running ? await api.simulationStop() : await api.simulationStart() @@ -109,7 +109,7 @@ export function SimulationPanel() { setEditing(false) const bi = parseInt(blockInput, 10) const ti = parseInt(txInput, 10) - if (!isNaN(bi) && !isNaN(ti) && bi >= 5 && ti >= 3) { + if (!readOnly && !isNaN(bi) && !isNaN(ti) && bi >= 5 && ti >= 3) { try { setData(await api.simulationConfig(bi, ti)) } catch { @@ -186,7 +186,7 @@ export function SimulationPanel() { onClick={() => { void handleStartStop() }} - disabled={loading || data === null} + disabled={readOnly || loading || data === null} style={{ background: running ? '#1c1c1c' : '#14532d', color: running ? '#9ca3af' : '#86efac', @@ -194,8 +194,8 @@ export function SimulationPanel() { borderRadius: 5, padding: '3px 12px', fontSize: 12, - cursor: data === null || loading ? 'not-allowed' : 'pointer', - opacity: loading ? 0.6 : 1, + cursor: readOnly || data === null || loading ? 'not-allowed' : 'pointer', + opacity: readOnly || loading ? 0.6 : 1, transition: 'all 0.15s', }} > @@ -206,6 +206,22 @@ export function SimulationPanel() { {/* Body */}
+ {readOnly && ( +
+ {t.network.readOnlyActionBlocked} +
+ )} + {/* Stats */}
{stat(t.simulation.blocksMined, data?.blocks_mined ?? 0, '#60a5fa')} @@ -298,6 +314,7 @@ export function SimulationPanel() { type="number" min={5} value={blockInput} + disabled={readOnly} onChange={(e) => setBlockInput(e.target.value)} onFocus={() => setEditing(true)} onBlur={() => { @@ -327,6 +344,7 @@ export function SimulationPanel() { type="number" min={3} value={txInput} + disabled={readOnly} onChange={(e) => setTxInput(e.target.value)} onFocus={() => setEditing(true)} onBlur={() => { diff --git a/frontend/src/components/ZmqEventTape.tsx b/frontend/src/components/ZmqEventTape.tsx index b1eaf2c..91f13aa 100644 --- a/frontend/src/components/ZmqEventTape.tsx +++ b/frontend/src/components/ZmqEventTape.tsx @@ -20,6 +20,7 @@ function EventRow({ ev, onInspect }: { ev: TapeEvent; onInspect: (txid: string) style={{ display: 'flex', alignItems: 'center', + flexWrap: 'wrap', gap: '10px', padding: '8px 12px', background: '#0f172a', @@ -33,6 +34,7 @@ function EventRow({ ev, onInspect }: { ev: TapeEvent; onInspect: (txid: string) - + {ev.ts ? ev.ts.replace('T', ' ').slice(0, 23) : '—'} - + {isRawtx ? ( ev.txid ? ( OP_RETURN} {isRawtx && ev.script_types.length > 0 && ( - {ev.script_types.slice(0, 2).join(', ')} + + {ev.script_types.slice(0, 2).join(', ')} + )} {isRawtx && ev.txid && ( @@ -236,6 +240,7 @@ export function ZmqEventTape({ onInspectTxid }: Props) {
+ +export interface AlertConfigListData { + items: AlertConfig[] +} + +export interface ActiveAlert { + id?: number | null + metric: string + operator: string + threshold: number + severity: 'info' | 'warning' | 'critical' + current_value: number + enabled: boolean +} + +export interface ActiveAlertsData { + items: ActiveAlert[] +} + export interface SummaryData { total_events: number rawtx_count: number @@ -393,6 +441,32 @@ export interface ClusterCompatibilityData { note: string | null } +export interface MempoolClusterTx { + txid: string + vsize: number + fee_btc: number + fee_rate_sat_vb: number + depends: string[] + spentby: string[] +} + +export interface MempoolCluster { + id: string + tx_count: number + total_vsize: number + total_fee_btc: number + avg_fee_rate_sat_vb: number + txs: MempoolClusterTx[] +} + +export interface MempoolClustersData { + clusters: MempoolCluster[] + total_tx_count: number + cluster_count: number + rpc_ok: boolean + error: string | null +} + // --- Live Simulation --- export interface SimulationConfig { @@ -402,6 +476,7 @@ export interface SimulationConfig { export interface SimulationData { running: boolean + read_only?: boolean blocks_mined: number txs_sent: number errors: number diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e528015..1411b35 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,8 +9,11 @@ export default defineConfig({ port: 5173, proxy: { '/health': { target: apiTarget, changeOrigin: true }, + '/network': { target: apiTarget, changeOrigin: true }, '/summary': { target: apiTarget, changeOrigin: true }, '/mempool': { target: apiTarget, changeOrigin: true }, + '/charts': { target: apiTarget, changeOrigin: true }, + '/alerts': { target: apiTarget, changeOrigin: true }, '/events': { target: apiTarget, changeOrigin: true }, '/blocks': { target: apiTarget, changeOrigin: true }, '/tx': { target: apiTarget, changeOrigin: true }, diff --git a/tests/test_network_guard.py b/tests/test_network_guard.py new file mode 100644 index 0000000..012ab8d --- /dev/null +++ b/tests/test_network_guard.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import importlib +import os +import unittest +from unittest.mock import patch + + +class NetworkGuardTests(unittest.TestCase): + def _reload_guard(self): + import api.network_guard as network_guard + + return importlib.reload(network_guard) + + def test_force_readonly_env_marks_read_only(self) -> None: + with patch.dict(os.environ, {"NODESCOPE_FORCE_READONLY": "true"}): + network_guard = self._reload_guard() + self.assertTrue(network_guard.is_read_only()) + self.assertEqual(network_guard.detect_network_mode()["reason"], "force_readonly_env") + + def test_regtest_is_writable(self) -> None: + with patch.dict(os.environ, {"NODESCOPE_FORCE_READONLY": "false"}): + network_guard = self._reload_guard() + with patch("api.rpc.get_client") as mock_get: + mock_get.return_value.getblockchaininfo.return_value = {"chain": "regtest"} + network_guard.refresh_network_mode() + mode = network_guard.detect_network_mode() + self.assertEqual(mode["chain"], "regtest") + self.assertFalse(mode["read_only"]) + self.assertEqual(mode["reason"], "regtest_writable") + + def test_non_regtest_chain_is_read_only(self) -> None: + with patch.dict(os.environ, {"NODESCOPE_FORCE_READONLY": "false"}): + network_guard = self._reload_guard() + with patch("api.rpc.get_client") as mock_get: + mock_get.return_value.getblockchaininfo.return_value = {"chain": "signet"} + network_guard.refresh_network_mode() + mode = network_guard.detect_network_mode() + self.assertEqual(mode["chain"], "signet") + self.assertTrue(mode["read_only"]) + self.assertEqual(mode["reason"], "non_regtest_chain") + + +class RateLimiterTests(unittest.TestCase): + def test_sliding_window_rejects_after_limit(self) -> None: + import api.rate_limiter as rate_limiter + + window: rate_limiter.deque[float] = rate_limiter.deque() + limited, retry = rate_limiter._check_window(window, limit=2, now=100.0) + self.assertFalse(limited) + self.assertEqual(retry, 0) + + limited, retry = rate_limiter._check_window(window, limit=2, now=101.0) + self.assertFalse(limited) + self.assertEqual(retry, 0) + + limited, retry = rate_limiter._check_window(window, limit=2, now=102.0) + self.assertTrue(limited) + self.assertGreaterEqual(retry, 1) + + def test_sliding_window_expires_old_entries(self) -> None: + import api.rate_limiter as rate_limiter + + window: rate_limiter.deque[float] = rate_limiter.deque([1.0, 2.0]) + limited, _ = rate_limiter._check_window(window, limit=2, now=61.9) + self.assertFalse(limited) + self.assertEqual(list(window), [2.0, 61.9])