From d98b2c777221d709d9fcb6ee77f6da521763406c Mon Sep 17 00:00:00 2001 From: oib Date: Mon, 22 Dec 2025 07:55:09 +0100 Subject: [PATCH] Based on the repository's commit message style and the changes in the diff, here's an appropriate commit message: ``` feat: add websocket tests, PoA metrics, marketplace endpoints, and enhanced observability - Add comprehensive websocket tests for blocks and transactions streams including multi-subscriber and high-volume scenarios - Extend PoA consensus with per-proposer block metrics and rotation tracking - Add latest block interval gauge and RPC error spike alerting - Enhance mock coordinator --- .github/workflows/python-tests.yml | 3 + .windsurf/workflows/git.md | 6 + .windsurf/workflows/roadmap.md | 6 - .windsurf/workflows/runscriptdebug.md | 7 + .windsurf/workflows/stillsameissue.md | 6 + apps/blockchain-node/data/devnet/genesis.json | 23 ++ apps/blockchain-node/observability/README.md | 2 +- apps/blockchain-node/observability/alerts.yml | 10 + .../blockchain-node-overview.json | 255 ++++++++++++++ .../coordinator-overview.json | 322 ++++++++++++++++++ .../observability/grafana-dashboard.json | 74 ++++ apps/blockchain-node/scripts/devnet_up.sh | 0 .../scripts/mock_coordinator.py | 67 +++- .../src/aitbc_chain/consensus/poa.py | 17 + .../blockchain-node/src/aitbc_chain/models.py | 10 +- .../src/aitbc_chain/observability/__init__.py | 9 + .../aitbc_chain/observability/dashboards.py | 267 +++++++++++++++ .../aitbc_chain/observability/exporters.py | 17 + apps/blockchain-node/tests/conftest.py | 7 + .../tests/test_observability_dashboards.py | 39 +++ apps/blockchain-node/tests/test_websocket.py | 100 +++++- apps/coordinator-api/README.md | 21 ++ .../src/app/domain/__init__.py | 10 +- .../src/app/domain/marketplace.py | 36 ++ apps/coordinator-api/src/app/main.py | 10 +- apps/coordinator-api/src/app/models.py | 96 +++++- .../src/app/routers/__init__.py | 8 + .../src/app/routers/explorer.py | 63 ++++ .../src/app/routers/marketplace.py | 57 ++++ .../src/app/services/__init__.py | 4 +- .../src/app/services/explorer.py | 182 ++++++++++ .../src/app/services/marketplace.py | 83 +++++ apps/coordinator-api/src/app/storage/db.py | 2 +- .../coordinator-api/tests/test_marketplace.py | 113 ++++++ apps/explorer-web/README.md | 143 +------- apps/explorer-web/package.json | 5 +- apps/explorer-web/playwright.config.ts | 23 ++ apps/explorer-web/public/css/layout.css | 54 ++- apps/explorer-web/src/lib/mockData.ts | 73 ++-- apps/explorer-web/test-results/.last-run.json | 8 + .../error-context.md | 91 +++++ .../error-context.md | 91 +++++ .../error-context.md | 91 +++++ .../tests/e2e/explorer-live.spec.ts | 111 ++++++ apps/marketplace-web/.gitignore | 24 ++ apps/marketplace-web/README.md | 42 ++- apps/marketplace-web/index.html | 13 + apps/marketplace-web/package.json | 15 + apps/marketplace-web/public/mock/offers.json | 36 ++ apps/marketplace-web/public/mock/stats.json | 6 + apps/marketplace-web/public/vite.svg | 1 + apps/marketplace-web/src/counter.ts | 9 + apps/marketplace-web/src/lib/api.ts | 118 +++++++ apps/marketplace-web/src/lib/auth.ts | 33 ++ apps/marketplace-web/src/main.ts | 216 ++++++++++++ apps/marketplace-web/src/style.css | 219 ++++++++++++ apps/marketplace-web/src/typescript.svg | 1 + apps/marketplace-web/tsconfig.json | 26 ++ apps/wallet-daemon/src/app/api_rest.py | 6 +- apps/wallet-daemon/tests/test_wallet_api.py | 30 +- docs/done.md | 5 + docs/ports.md | 26 ++ docs/roadmap.md | 202 ++++++++--- docs/run.md | 38 +++ .../src/aitbc_crypto.egg-info/PKG-INFO | 7 + .../src/aitbc_crypto.egg-info/SOURCES.txt | 13 + .../dependency_links.txt | 1 + .../src/aitbc_crypto.egg-info/requires.txt | 2 + .../src/aitbc_crypto.egg-info/top_level.txt | 4 + scripts/ci/run_python_tests.sh | 3 +- 70 files changed, 3472 insertions(+), 246 deletions(-) create mode 100644 .windsurf/workflows/git.md delete mode 100644 .windsurf/workflows/roadmap.md create mode 100644 .windsurf/workflows/runscriptdebug.md create mode 100644 .windsurf/workflows/stillsameissue.md create mode 100644 apps/blockchain-node/data/devnet/genesis.json create mode 100644 apps/blockchain-node/observability/generated_dashboards/blockchain-node-overview.json create mode 100644 apps/blockchain-node/observability/generated_dashboards/coordinator-overview.json mode change 100644 => 100755 apps/blockchain-node/scripts/devnet_up.sh create mode 100644 apps/blockchain-node/src/aitbc_chain/observability/__init__.py create mode 100644 apps/blockchain-node/src/aitbc_chain/observability/dashboards.py create mode 100644 apps/blockchain-node/src/aitbc_chain/observability/exporters.py create mode 100644 apps/blockchain-node/tests/test_observability_dashboards.py create mode 100644 apps/coordinator-api/src/app/domain/marketplace.py create mode 100644 apps/coordinator-api/src/app/routers/explorer.py create mode 100644 apps/coordinator-api/src/app/routers/marketplace.py create mode 100644 apps/coordinator-api/src/app/services/explorer.py create mode 100644 apps/coordinator-api/src/app/services/marketplace.py create mode 100644 apps/coordinator-api/tests/test_marketplace.py create mode 100644 apps/explorer-web/playwright.config.ts create mode 100644 apps/explorer-web/test-results/.last-run.json create mode 100644 apps/explorer-web/test-results/explorer-live-Explorer-liv-3dc9a-tions-table-shows-live-rows-chromium/error-context.md create mode 100644 apps/explorer-web/test-results/explorer-live-Explorer-liv-b10c9-view-renders-live-summaries-chromium/error-context.md create mode 100644 apps/explorer-web/test-results/explorer-live-Explorer-liv-f2fe7-locks-table-shows-live-rows-chromium/error-context.md create mode 100644 apps/explorer-web/tests/e2e/explorer-live.spec.ts create mode 100644 apps/marketplace-web/.gitignore create mode 100644 apps/marketplace-web/index.html create mode 100644 apps/marketplace-web/package.json create mode 100644 apps/marketplace-web/public/mock/offers.json create mode 100644 apps/marketplace-web/public/mock/stats.json create mode 100644 apps/marketplace-web/public/vite.svg create mode 100644 apps/marketplace-web/src/counter.ts create mode 100644 apps/marketplace-web/src/lib/api.ts create mode 100644 apps/marketplace-web/src/lib/auth.ts create mode 100644 apps/marketplace-web/src/main.ts create mode 100644 apps/marketplace-web/src/style.css create mode 100644 apps/marketplace-web/src/typescript.svg create mode 100644 apps/marketplace-web/tsconfig.json create mode 100644 docs/ports.md create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/PKG-INFO create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/SOURCES.txt create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/dependency_links.txt create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/requires.txt create mode 100644 packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/top_level.txt diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 06cc430..b4a476e 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -32,3 +32,6 @@ jobs: run: | chmod +x scripts/ci/run_python_tests.sh ./scripts/ci/run_python_tests.sh + - name: Run blockchain-node websocket tests + run: | + poetry run pytest apps/blockchain-node/tests/test_websocket.py diff --git a/.windsurf/workflows/git.md b/.windsurf/workflows/git.md new file mode 100644 index 0000000..6657fed --- /dev/null +++ b/.windsurf/workflows/git.md @@ -0,0 +1,6 @@ +--- +description: do the git thing +auto_execution_mode: 3 +--- + +do the git thing \ No newline at end of file diff --git a/.windsurf/workflows/roadmap.md b/.windsurf/workflows/roadmap.md deleted file mode 100644 index 8f78428..0000000 --- a/.windsurf/workflows/roadmap.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -description: docs/roadmap.md -auto_execution_mode: 3 ---- - -Check docs/roadmap.md and carry out the next recommended step. \ No newline at end of file diff --git a/.windsurf/workflows/runscriptdebug.md b/.windsurf/workflows/runscriptdebug.md new file mode 100644 index 0000000..8b41cc9 --- /dev/null +++ b/.windsurf/workflows/runscriptdebug.md @@ -0,0 +1,7 @@ +--- +description: run script and debug +auto_execution_mode: 3 +--- + +run script and debug +rerun script \ No newline at end of file diff --git a/.windsurf/workflows/stillsameissue.md b/.windsurf/workflows/stillsameissue.md new file mode 100644 index 0000000..6ce08b8 --- /dev/null +++ b/.windsurf/workflows/stillsameissue.md @@ -0,0 +1,6 @@ +--- +description: still same issue +auto_execution_mode: 3 +--- + +still same issue \ No newline at end of file diff --git a/apps/blockchain-node/data/devnet/genesis.json b/apps/blockchain-node/data/devnet/genesis.json new file mode 100644 index 0000000..2cca964 --- /dev/null +++ b/apps/blockchain-node/data/devnet/genesis.json @@ -0,0 +1,23 @@ +{ + "accounts": [ + { + "address": "ait1faucet000000000000000000000000000000000", + "balance": 1000000000, + "nonce": 0 + } + ], + "authorities": [ + { + "address": "ait1devproposer000000000000000000000000000000", + "weight": 1 + } + ], + "chain_id": "ait-devnet", + "params": { + "base_fee": 10, + "coordinator_ratio": 0.05, + "fee_per_byte": 1, + "mint_per_unit": 1000 + }, + "timestamp": 1766383019 +} diff --git a/apps/blockchain-node/observability/README.md b/apps/blockchain-node/observability/README.md index 3d96fc5..c435cd1 100644 --- a/apps/blockchain-node/observability/README.md +++ b/apps/blockchain-node/observability/README.md @@ -8,7 +8,7 @@ This directory contains Prometheus and Grafana assets for the devnet environment ## Files - `prometheus.yml` – Scrapes both blockchain node and mock coordinator/miner metrics. -- `grafana-dashboard.json` – Panels for block interval, RPC throughput, miner activity, coordinator receipt flow, **plus new gossip queue, subscriber, and publication rate panels**. +- `grafana-dashboard.json` – Panels for block interval (including latest interval gauge), RPC throughput, miner activity, coordinator receipt flow, gossip queue/subscriber/publication metrics, and PoA proposer visibility (rotation counts, blocks proposed per proposer). - `alerts.yml` – Alertmanager rules highlighting proposer stalls, miner errors, and coordinator receipt drop-offs. - `gossip-recording-rules.yml` – Prometheus recording rules that derive queue/subscriber gauges and publication rates from gossip metrics. diff --git a/apps/blockchain-node/observability/alerts.yml b/apps/blockchain-node/observability/alerts.yml index 0c89e7d..8731e5f 100644 --- a/apps/blockchain-node/observability/alerts.yml +++ b/apps/blockchain-node/observability/alerts.yml @@ -41,3 +41,13 @@ groups: summary: "No receipts attested in 5 minutes" description: | Receipt attestations ceased during the last five minutes. Inspect coordinator connectivity. + + - alert: RpcErrorsSpiking + expr: increase(rpc_request_failures_total[5m]) > 0 + for: 5m + labels: + severity: warning + annotations: + summary: "RPC error spike detected" + description: | + RPC request failures have increased during the last five minutes. Investigate rpc_request_failures_total for details. diff --git a/apps/blockchain-node/observability/generated_dashboards/blockchain-node-overview.json b/apps/blockchain-node/observability/generated_dashboards/blockchain-node-overview.json new file mode 100644 index 0000000..c843bbb --- /dev/null +++ b/apps/blockchain-node/observability/generated_dashboards/blockchain-node-overview.json @@ -0,0 +1,255 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "rate(blockchain_block_height[1m])", + "refId": "A" + } + ], + "title": "Block Production Interval (seconds)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "avg_over_time(mempool_queue_depth[1m])", + "refId": "A" + } + ], + "title": "Mempool Queue Depth", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "avg_over_time(miner_queue_depth[1m])", + "refId": "A" + } + ], + "title": "Miner Queue Depth", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "avg_over_time(miner_job_duration_seconds_sum[1m]) / avg_over_time(miner_job_duration_seconds_count[1m])", + "refId": "A" + } + ], + "title": "Miner Job Duration Seconds", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "aitbc", + "blockchain" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "AITBC Blockchain Node", + "uid": "aitbc-node", + "version": 1 +} \ No newline at end of file diff --git a/apps/blockchain-node/observability/generated_dashboards/coordinator-overview.json b/apps/blockchain-node/observability/generated_dashboards/coordinator-overview.json new file mode 100644 index 0000000..0936adb --- /dev/null +++ b/apps/blockchain-node/observability/generated_dashboards/coordinator-overview.json @@ -0,0 +1,322 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "rate(coordinator_jobs_submitted_total[1m])", + "refId": "A" + } + ], + "title": "Jobs Submitted", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "rate(coordinator_jobs_completed_total[1m])", + "refId": "A" + } + ], + "title": "Jobs Completed", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "rate(coordinator_jobs_failed_total[1m])", + "refId": "A" + } + ], + "title": "Jobs Failed", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 5 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "miner_active_jobs", + "refId": "A" + } + ], + "title": "Active Jobs", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 5 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "miner_error_rate", + "refId": "A" + } + ], + "title": "Miner Error Rate", + "type": "stat" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "aitbc", + "coordinator" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "AITBC Coordinator Overview", + "uid": "aitbc-coordinator", + "version": 1 +} \ No newline at end of file diff --git a/apps/blockchain-node/observability/grafana-dashboard.json b/apps/blockchain-node/observability/grafana-dashboard.json index b84e1bb..23eea80 100644 --- a/apps/blockchain-node/observability/grafana-dashboard.json +++ b/apps/blockchain-node/observability/grafana-dashboard.json @@ -352,6 +352,80 @@ ], "title": "Gossip Publication Rate by Topic", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PROMETHEUS_DS" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "increase(poa_proposer_rotations_total[30m])", + "legendFormat": "rotations (30m)", + "refId": "A" + } + ], + "title": "Proposer Rotation Count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PROMETHEUS_DS" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 10, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "label_replace(sum(rate({__name__=~\"poa_blocks_proposed_total_.*\"}[5m])) by (__name__), \"proposer\", \"$1\", \"__name__\", \"poa_blocks_proposed_total_(.*)\")", + "legendFormat": "{{proposer}}", + "refId": "A" + } + ], + "title": "Blocks Proposed per Proposer (5m rate)", + "type": "timeseries" } ], "refresh": "10s", diff --git a/apps/blockchain-node/scripts/devnet_up.sh b/apps/blockchain-node/scripts/devnet_up.sh old mode 100644 new mode 100755 diff --git a/apps/blockchain-node/scripts/mock_coordinator.py b/apps/blockchain-node/scripts/mock_coordinator.py index e5e80bf..371e642 100644 --- a/apps/blockchain-node/scripts/mock_coordinator.py +++ b/apps/blockchain-node/scripts/mock_coordinator.py @@ -3,9 +3,11 @@ from __future__ import annotations +import asyncio +import contextlib import random -import time -from typing import Dict +from collections import deque +from typing import Deque, Dict, List from fastapi import FastAPI from fastapi.responses import PlainTextResponse @@ -14,21 +16,78 @@ from aitbc_chain.metrics import metrics_registry app = FastAPI(title="Mock Coordinator API", version="0.1.0") +SIMULATED_MINERS: List[str] = ["miner-alpha", "miner-beta", "miner-gamma"] +SIMULATED_CLIENTS: List[str] = ["client-labs", "client-trading", "client-research"] + MOCK_JOBS: Dict[str, Dict[str, str]] = { "job_1": {"status": "complete", "price": "50000", "compute_units": 2500}, "job_2": {"status": "complete", "price": "25000", "compute_units": 1200}, } +_simulation_task: asyncio.Task | None = None +_job_rollup: Deque[str] = deque(maxlen=120) + def _simulate_miner_metrics() -> None: - metrics_registry.set_gauge("miner_active_jobs", float(random.randint(0, 5))) + active_jobs = random.randint(1, 6) + metrics_registry.set_gauge("miner_active_jobs", float(active_jobs)) metrics_registry.set_gauge("miner_error_rate", float(random.randint(0, 1))) - metrics_registry.observe("miner_job_duration_seconds", random.uniform(1.0, 5.0)) + metrics_registry.observe("miner_job_duration_seconds", random.uniform(1.5, 8.0)) + metrics_registry.observe("miner_queue_depth", float(random.randint(0, 12))) + + +async def _simulation_loop() -> None: + job_counter = 3 + while True: + _simulate_miner_metrics() + + job_id = f"job_{job_counter}" + client = random.choice(SIMULATED_CLIENTS) + miner = random.choice(SIMULATED_MINERS) + price = random.randint(15_000, 75_000) + compute_units = random.randint(750, 5000) + + MOCK_JOBS[job_id] = { + "status": random.choice(["complete", "pending", "failed"]), + "price": str(price), + "compute_units": compute_units, + "client": client, + "assigned_miner": miner, + } + _job_rollup.append(job_id) + + if len(MOCK_JOBS) > _job_rollup.maxlen: + oldest = _job_rollup.popleft() + MOCK_JOBS.pop(oldest, None) + + metrics_registry.increment("coordinator_jobs_submitted_total") + metrics_registry.observe("coordinator_job_price", float(price)) + metrics_registry.observe("coordinator_job_compute_units", float(compute_units)) + + if MOCK_JOBS[job_id]["status"] == "failed": + metrics_registry.increment("coordinator_jobs_failed_total") + else: + metrics_registry.increment("coordinator_jobs_completed_total") + + job_counter += 1 + await asyncio.sleep(random.uniform(1.5, 3.5)) @app.on_event("startup") async def _startup() -> None: + global _simulation_task _simulate_miner_metrics() + _simulation_task = asyncio.create_task(_simulation_loop()) + + +@app.on_event("shutdown") +async def _shutdown() -> None: + global _simulation_task + if _simulation_task: + _simulation_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await _simulation_task + _simulation_task = None @app.get("/health") diff --git a/apps/blockchain-node/src/aitbc_chain/consensus/poa.py b/apps/blockchain-node/src/aitbc_chain/consensus/poa.py index c67fccf..ebfb3f3 100644 --- a/apps/blockchain-node/src/aitbc_chain/consensus/poa.py +++ b/apps/blockchain-node/src/aitbc_chain/consensus/poa.py @@ -4,12 +4,21 @@ import asyncio import hashlib from dataclasses import dataclass from datetime import datetime +import re from typing import Callable, ContextManager, Optional from sqlmodel import Session, select from ..logging import get_logger from ..metrics import metrics_registry + + +_METRIC_KEY_SANITIZE = re.compile(r"[^0-9a-zA-Z]+") + + +def _sanitize_metric_suffix(value: str) -> str: + sanitized = _METRIC_KEY_SANITIZE.sub("_", value).strip("_") + return sanitized or "unknown" from ..models import Block from ..gossip import gossip_broker @@ -33,6 +42,7 @@ class PoAProposer: self._logger = get_logger(__name__) self._stop_event = asyncio.Event() self._task: Optional[asyncio.Task[None]] = None + self._last_proposer_id: Optional[str] = None async def start(self) -> None: if self._task is not None: @@ -104,6 +114,13 @@ class PoAProposer: metrics_registry.set_gauge("chain_head_height", float(next_height)) if interval_seconds is not None and interval_seconds >= 0: metrics_registry.observe("block_interval_seconds", interval_seconds) + metrics_registry.set_gauge("poa_last_block_interval_seconds", float(interval_seconds)) + + proposer_suffix = _sanitize_metric_suffix(self._config.proposer_id) + metrics_registry.increment(f"poa_blocks_proposed_total_{proposer_suffix}") + if self._last_proposer_id is not None and self._last_proposer_id != self._config.proposer_id: + metrics_registry.increment("poa_proposer_rotations_total") + self._last_proposer_id = self._config.proposer_id asyncio.create_task( gossip_broker.publish( diff --git a/apps/blockchain-node/src/aitbc_chain/models.py b/apps/blockchain-node/src/aitbc_chain/models.py index f7461df..3b1b593 100644 --- a/apps/blockchain-node/src/aitbc_chain/models.py +++ b/apps/blockchain-node/src/aitbc_chain/models.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime import re -from typing import List, Optional +from typing import Optional from pydantic import field_validator from sqlalchemy import Column @@ -34,8 +34,8 @@ class Block(SQLModel, table=True): tx_count: int = 0 state_root: Optional[str] = None - transactions: List["Transaction"] = Relationship(back_populates="block") - receipts: List["Receipt"] = Relationship(back_populates="block") + transactions: list["Transaction"] = Relationship(back_populates="block") + receipts: list["Receipt"] = Relationship(back_populates="block") @field_validator("hash", mode="before") @classmethod @@ -69,7 +69,7 @@ class Transaction(SQLModel, table=True): ) created_at: datetime = Field(default_factory=datetime.utcnow, index=True) - block: Optional[Block] = Relationship(back_populates="transactions") + block: Optional["Block"] = Relationship(back_populates="transactions") @field_validator("tx_hash", mode="before") @classmethod @@ -101,7 +101,7 @@ class Receipt(SQLModel, table=True): minted_amount: Optional[int] = None recorded_at: datetime = Field(default_factory=datetime.utcnow, index=True) - block: Optional[Block] = Relationship(back_populates="receipts") + block: Optional["Block"] = Relationship(back_populates="receipts") @field_validator("receipt_id", mode="before") @classmethod diff --git a/apps/blockchain-node/src/aitbc_chain/observability/__init__.py b/apps/blockchain-node/src/aitbc_chain/observability/__init__.py new file mode 100644 index 0000000..3b5547d --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/observability/__init__.py @@ -0,0 +1,9 @@ +"""Observability tooling for the AITBC blockchain node.""" + +from .dashboards import generate_default_dashboards +from .exporters import register_exporters + +__all__ = [ + "generate_default_dashboards", + "register_exporters", +] diff --git a/apps/blockchain-node/src/aitbc_chain/observability/dashboards.py b/apps/blockchain-node/src/aitbc_chain/observability/dashboards.py new file mode 100644 index 0000000..e632fe6 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/observability/dashboards.py @@ -0,0 +1,267 @@ +"""Generate Grafana dashboards for the devnet observability stack.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, Iterable + + +def _timeseries_panel( + panel_id: int, + title: str, + expr: str, + grid_x: int, + grid_y: int, + datasource_uid: str, +) -> Dict[str, object]: + return { + "datasource": {"type": "prometheus", "uid": datasource_uid}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": None}, + {"color": "red", "value": 80}, + ], + }, + }, + "overrides": [], + }, + "gridPos": {"h": 8, "w": 12, "x": grid_x, "y": grid_y}, + "id": panel_id, + "options": { + "legend": {"displayMode": "list", "placement": "bottom"}, + "tooltip": {"mode": "multi", "sort": "none"}, + }, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": datasource_uid}, + "expr": expr, + "refId": "A", + } + ], + "title": title, + "type": "timeseries", + } + + +def _stat_panel( + panel_id: int, + title: str, + expr: str, + grid_x: int, + grid_y: int, + datasource_uid: str, +) -> Dict[str, object]: + return { + "datasource": {"type": "prometheus", "uid": datasource_uid}, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": None}, + {"color": "orange", "value": 5}, + {"color": "red", "value": 10}, + ], + }, + }, + "overrides": [], + }, + "gridPos": {"h": 4, "w": 6, "x": grid_x, "y": grid_y}, + "id": panel_id, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False}, + "textMode": "auto", + }, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": datasource_uid}, + "expr": expr, + "refId": "A", + } + ], + "title": title, + "type": "stat", + } + + +def _coordinator_dashboard(datasource_uid: str) -> Dict[str, object]: + return { + "uid": "aitbc-coordinator", + "title": "AITBC Coordinator Overview", + "editable": True, + "tags": ["aitbc", "coordinator"], + "timezone": "", + "schemaVersion": 38, + "version": 1, + "refresh": "10s", + "style": "dark", + "annotations": {"list": []}, + "templating": {"list": []}, + "time": {"from": "now-5m", "to": "now"}, + "timepicker": {}, + "panels": [ + _timeseries_panel( + panel_id=1, + title="Jobs Submitted", + expr="rate(coordinator_jobs_submitted_total[1m])", + grid_x=0, + grid_y=0, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=2, + title="Jobs Completed", + expr="rate(coordinator_jobs_completed_total[1m])", + grid_x=12, + grid_y=0, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=3, + title="Jobs Failed", + expr="rate(coordinator_jobs_failed_total[1m])", + grid_x=0, + grid_y=8, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=6, + title="Average Bid Price", + expr="avg_over_time(coordinator_job_price[5m])", + grid_x=12, + grid_y=8, + datasource_uid=datasource_uid, + ), + _stat_panel( + panel_id=4, + title="Active Jobs", + expr="miner_active_jobs", + grid_x=0, + grid_y=16, + datasource_uid=datasource_uid, + ), + _stat_panel( + panel_id=5, + title="Miner Error Rate", + expr="miner_error_rate", + grid_x=6, + grid_y=16, + datasource_uid=datasource_uid, + ), + _stat_panel( + panel_id=7, + title="Avg Compute Units", + expr="avg_over_time(coordinator_job_compute_units[5m])", + grid_x=12, + grid_y=16, + datasource_uid=datasource_uid, + ), + ], + } + + +def _node_dashboard(datasource_uid: str) -> Dict[str, object]: + return { + "uid": "aitbc-node", + "title": "AITBC Blockchain Node", + "editable": True, + "tags": ["aitbc", "blockchain"], + "timezone": "", + "schemaVersion": 38, + "version": 1, + "refresh": "10s", + "style": "dark", + "annotations": {"list": []}, + "templating": {"list": []}, + "time": {"from": "now-5m", "to": "now"}, + "timepicker": {}, + "panels": [ + _timeseries_panel( + panel_id=1, + title="Block Production Interval (seconds)", + expr="1 / rate(blockchain_block_height[1m])", + grid_x=0, + grid_y=0, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=2, + title="Mempool Queue Depth", + expr="avg_over_time(mempool_queue_depth[1m])", + grid_x=12, + grid_y=0, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=5, + title="Proposer Rotation Count", + expr="increase(poa_proposer_rotations_total[5m])", + grid_x=0, + grid_y=8, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=3, + title="Miner Queue Depth", + expr="avg_over_time(miner_queue_depth[1m])", + grid_x=12, + grid_y=8, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=4, + title="Miner Job Duration Seconds", + expr="avg_over_time(miner_job_duration_seconds_sum[1m]) / avg_over_time(miner_job_duration_seconds_count[1m])", + grid_x=0, + grid_y=16, + datasource_uid=datasource_uid, + ), + _timeseries_panel( + panel_id=6, + title="RPC 95th Percentile Latency", + expr="histogram_quantile(0.95, sum(rate(rpc_request_duration_seconds_bucket[5m])) by (le))", + grid_x=12, + grid_y=16, + datasource_uid=datasource_uid, + ), + ], + } + + +def _dashboard_payloads(datasource_uid: str) -> Iterable[tuple[str, Dict[str, object]]]: + return ( + ("coordinator-overview.json", _coordinator_dashboard(datasource_uid)), + ("blockchain-node-overview.json", _node_dashboard(datasource_uid)), + ) + + +def generate_default_dashboards(output_dir: Path, datasource_uid: str = "${DS_PROMETHEUS}") -> None: + """Write Grafana dashboard JSON exports to ``output_dir``. + + Parameters + ---------- + output_dir: + Directory that will receive the generated JSON files. It is created if + it does not already exist. + datasource_uid: + Grafana datasource UID for Prometheus queries (defaults to the + built-in "${DS_PROMETHEUS}" variable). + """ + + output_dir.mkdir(parents=True, exist_ok=True) + + for filename, payload in _dashboard_payloads(datasource_uid): + dashboard_path = output_dir / filename + with dashboard_path.open("w", encoding="utf-8") as fp: + json.dump(payload, fp, indent=2, sort_keys=True) diff --git a/apps/blockchain-node/src/aitbc_chain/observability/exporters.py b/apps/blockchain-node/src/aitbc_chain/observability/exporters.py new file mode 100644 index 0000000..80cff1c --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/observability/exporters.py @@ -0,0 +1,17 @@ +"""Placeholder exporter registration for metrics/log sinks.""" + +from __future__ import annotations + +from typing import Iterable + +REGISTERED_EXPORTERS: list[str] = [] + + +def register_exporters(exporters: Iterable[str]) -> None: + """Attach exporters for observability pipelines. + + Real implementations might wire Prometheus registrations, log shippers, + or tracing exporters. For now, we simply record the names to keep track + of requested sinks. + """ + REGISTERED_EXPORTERS.extend(exporters) diff --git a/apps/blockchain-node/tests/conftest.py b/apps/blockchain-node/tests/conftest.py index 1b91783..2dfa3a3 100644 --- a/apps/blockchain-node/tests/conftest.py +++ b/apps/blockchain-node/tests/conftest.py @@ -1,8 +1,15 @@ from __future__ import annotations +import sys +from pathlib import Path + import pytest from sqlmodel import SQLModel, Session, create_engine +PROJECT_ROOT = Path(__file__).resolve().parent.parent / "src" +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + from aitbc_chain.models import Block, Transaction, Receipt # noqa: F401 - ensure models imported for metadata diff --git a/apps/blockchain-node/tests/test_observability_dashboards.py b/apps/blockchain-node/tests/test_observability_dashboards.py new file mode 100644 index 0000000..13c8559 --- /dev/null +++ b/apps/blockchain-node/tests/test_observability_dashboards.py @@ -0,0 +1,39 @@ +"""Tests for the observability dashboard helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from aitbc_chain.observability.dashboards import generate_default_dashboards +from aitbc_chain.observability import exporters + + +def test_generate_default_dashboards_creates_files(tmp_path: Path) -> None: + output_dir = tmp_path / "dashboards" + + generate_default_dashboards(output_dir, datasource_uid="prometheus") + + expected_files = { + "blockchain-node-overview.json", + "coordinator-overview.json", + } + actual_files = {path.name for path in output_dir.glob("*.json")} + + assert actual_files == expected_files + + for file_path in output_dir.glob("*.json"): + with file_path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + + assert payload["uid"] in {"aitbc-coordinator", "aitbc-node"} + assert payload["title"].startswith("AITBC") + assert payload["panels"], "Dashboard should contain at least one panel" + + +def test_register_exporters_tracks_names() -> None: + exporters.REGISTERED_EXPORTERS.clear() + + exporters.register_exporters(["prometheus", "loki"]) + + assert exporters.REGISTERED_EXPORTERS == ["prometheus", "loki"] diff --git a/apps/blockchain-node/tests/test_websocket.py b/apps/blockchain-node/tests/test_websocket.py index ceaecd1..998a9d8 100644 --- a/apps/blockchain-node/tests/test_websocket.py +++ b/apps/blockchain-node/tests/test_websocket.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from contextlib import ExitStack from fastapi.testclient import TestClient @@ -10,8 +11,6 @@ from aitbc_chain.gossip import gossip_broker def _publish(topic: str, message: dict) -> None: asyncio.run(gossip_broker.publish(topic, message)) - - def test_blocks_websocket_stream() -> None: client = TestClient(create_app()) @@ -28,19 +27,100 @@ def test_blocks_websocket_stream() -> None: assert message == payload -def test_transactions_websocket_stream() -> None: +def test_blocks_websocket_multiple_subscribers_receive_all_payloads() -> None: + with TestClient(create_app()) as client, ExitStack() as stack: + sockets = [ + stack.enter_context(client.websocket_connect("/rpc/ws/blocks")) + for _ in range(3) + ] + + payloads = [ + { + "height": height, + "hash": "0x" + f"{height:064x}", + "parent_hash": ( + "0x" + f"{height - 1:064x}" if height > 0 else "0x" + "0" * 64 + ), + "timestamp": f"2025-01-01T00:00:{height:02d}Z", + "tx_count": height % 3, + } + for height in range(5) + ] + + for payload in payloads: + _publish("blocks", payload) + + for socket in sockets: + received = [socket.receive_json() for _ in payloads] + assert received == payloads + + # Publish another payload to ensure subscribers continue receiving in order. + final_payload = { + "height": 99, + "hash": "0x" + "f" * 64, + "parent_hash": "0x" + "e" * 64, + "timestamp": "2025-01-01T00:01:39Z", + "tx_count": 5, + } + _publish("blocks", final_payload) + + for socket in sockets: + assert socket.receive_json() == final_payload + + +def test_blocks_websocket_high_volume_load() -> None: + message_count = 40 + subscriber_count = 4 + + with TestClient(create_app()) as client, ExitStack() as stack: + sockets = [ + stack.enter_context(client.websocket_connect("/rpc/ws/blocks")) + for _ in range(subscriber_count) + ] + + payloads = [] + for height in range(message_count): + payload = { + "height": height, + "hash": "0x" + f"{height + 100:064x}", + "parent_hash": "0x" + f"{height + 99:064x}" if height > 0 else "0x" + "0" * 64, + "timestamp": f"2025-01-01T00:{height // 60:02d}:{height % 60:02d}Z", + "tx_count": height % 7, + } + payloads.append(payload) + _publish("blocks", payload) + + for socket in sockets: + received = [socket.receive_json() for _ in payloads] + assert received == payloads + + +def test_transactions_websocket_cleans_up_on_disconnect() -> None: client = TestClient(create_app()) with client.websocket_connect("/rpc/ws/transactions") as websocket: payload = { - "tx_hash": "0x" + "a" * 64, + "tx_hash": "0x" + "b" * 64, "sender": "alice", - "recipient": "bob", - "payload": {"amount": 1}, - "nonce": 1, - "fee": 0, + "recipient": "carol", + "payload": {"amount": 2}, + "nonce": 7, + "fee": 1, "type": "TRANSFER", } _publish("transactions", payload) - message = websocket.receive_json() - assert message == payload + assert websocket.receive_json() == payload + + # After closing the websocket, publishing again should not raise and should not hang. + _publish( + "transactions", + { + "tx_hash": "0x" + "c" * 64, + "sender": "alice", + "recipient": "dave", + "payload": {"amount": 3}, + "nonce": 8, + "fee": 1, + "type": "TRANSFER", + }, + ) diff --git a/apps/coordinator-api/README.md b/apps/coordinator-api/README.md index 1568d86..ef28745 100644 --- a/apps/coordinator-api/README.md +++ b/apps/coordinator-api/README.md @@ -4,6 +4,27 @@ FastAPI service that accepts client compute jobs, matches miners, and tracks job lifecycle for the AITBC network. +## Marketplace Extensions + +Stage 2 introduces public marketplace endpoints exposed under `/v1/marketplace`: + +- `GET /v1/marketplace/offers` – list available provider offers (filterable by status). +- `GET /v1/marketplace/stats` – aggregated supply/demand metrics surfaced in the marketplace web dashboard. +- `POST /v1/marketplace/bids` – accept bid submissions for matching (mock-friendly; returns `202 Accepted`). + +These endpoints serve the `apps/marketplace-web/` dashboard via `VITE_MARKETPLACE_DATA_MODE=live`. + +## Explorer Endpoints + +The coordinator now exposes read-only explorer data under `/v1/explorer` for `apps/explorer-web/` live mode: + +- `GET /v1/explorer/blocks` – block summaries derived from recent job activity. +- `GET /v1/explorer/transactions` – transaction-like records for coordinator jobs. +- `GET /v1/explorer/addresses` – aggregated address activity and balances. +- `GET /v1/explorer/receipts` – latest job receipts (filterable by `job_id`). + +Set `VITE_DATA_MODE=live` and `VITE_COORDINATOR_API` in the explorer web app to consume these APIs. + ## Development Setup 1. Create a virtual environment in `apps/coordinator-api/.venv`. diff --git a/apps/coordinator-api/src/app/domain/__init__.py b/apps/coordinator-api/src/app/domain/__init__.py index 0e3bc00..3e9fe25 100644 --- a/apps/coordinator-api/src/app/domain/__init__.py +++ b/apps/coordinator-api/src/app/domain/__init__.py @@ -3,5 +3,13 @@ from .job import Job from .miner import Miner from .job_receipt import JobReceipt +from .marketplace import MarketplaceOffer, MarketplaceBid, OfferStatus -__all__ = ["Job", "Miner", "JobReceipt"] +__all__ = [ + "Job", + "Miner", + "JobReceipt", + "MarketplaceOffer", + "MarketplaceBid", + "OfferStatus", +] diff --git a/apps/coordinator-api/src/app/domain/marketplace.py b/apps/coordinator-api/src/app/domain/marketplace.py new file mode 100644 index 0000000..c44d9af --- /dev/null +++ b/apps/coordinator-api/src/app/domain/marketplace.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Optional +from uuid import uuid4 + +from sqlalchemy import Column, Enum as SAEnum, JSON +from sqlmodel import Field, SQLModel + + +class OfferStatus(str, Enum): + open = "open" + reserved = "reserved" + closed = "closed" + + +class MarketplaceOffer(SQLModel, table=True): + id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True) + provider: str = Field(index=True) + capacity: int = Field(default=0, nullable=False) + price: float = Field(default=0.0, nullable=False) + sla: str = Field(default="") + status: OfferStatus = Field(default=OfferStatus.open, sa_column=Column(SAEnum(OfferStatus), nullable=False)) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, index=True) + attributes: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) + + +class MarketplaceBid(SQLModel, table=True): + id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True) + provider: str = Field(index=True) + capacity: int = Field(default=0, nullable=False) + price: float = Field(default=0.0, nullable=False) + notes: Optional[str] = Field(default=None) + status: str = Field(default="pending", nullable=False) + submitted_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, index=True) diff --git a/apps/coordinator-api/src/app/main.py b/apps/coordinator-api/src/app/main.py index 141450e..816a281 100644 --- a/apps/coordinator-api/src/app/main.py +++ b/apps/coordinator-api/src/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .config import settings -from .routers import client, miner, admin +from .routers import client, miner, admin, marketplace, explorer def create_app() -> FastAPI: @@ -20,9 +20,11 @@ def create_app() -> FastAPI: allow_headers=["*"] ) - app.include_router(client.router, prefix="/v1") - app.include_router(miner.router, prefix="/v1") - app.include_router(admin.router, prefix="/v1") + app.include_router(client, prefix="/v1") + app.include_router(miner, prefix="/v1") + app.include_router(admin, prefix="/v1") + app.include_router(marketplace, prefix="/v1") + app.include_router(explorer, prefix="/v1") @app.get("/v1/health", tags=["health"], summary="Service healthcheck") async def health() -> dict[str, str]: diff --git a/apps/coordinator-api/src/app/models.py b/apps/coordinator-api/src/app/models.py index 24f1c69..fec574a 100644 --- a/apps/coordinator-api/src/app/models.py +++ b/apps/coordinator-api/src/app/models.py @@ -4,7 +4,7 @@ from datetime import datetime from enum import Enum from typing import Any, Dict, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict class JobState(str, Enum): @@ -76,3 +76,97 @@ class JobFailSubmit(BaseModel): error_code: str error_message: str metrics: Dict[str, Any] = Field(default_factory=dict) + + +class MarketplaceOfferView(BaseModel): + id: str + provider: str + capacity: int + price: float + sla: str + status: str + created_at: datetime + + +class MarketplaceStatsView(BaseModel): + totalOffers: int + openCapacity: int + averagePrice: float + activeBids: int + + +class MarketplaceBidRequest(BaseModel): + provider: str = Field(..., min_length=1) + capacity: int = Field(..., gt=0) + price: float = Field(..., gt=0) + notes: Optional[str] = Field(default=None, max_length=1024) + + +class BlockSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + height: int + hash: str + timestamp: datetime + txCount: int + proposer: str + + +class BlockListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + items: list[BlockSummary] + next_offset: Optional[str | int] = None + + +class TransactionSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True, ser_json_tuples=True) + + hash: str + block: str | int + from_address: str = Field(alias="from") + to_address: Optional[str] = Field(default=None, alias="to") + value: str + status: str + + +class TransactionListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + items: list[TransactionSummary] + next_offset: Optional[str | int] = None + + +class AddressSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + address: str + balance: str + txCount: int + lastActive: datetime + recentTransactions: Optional[list[str]] = Field(default=None) + + +class AddressListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + items: list[AddressSummary] + next_offset: Optional[str | int] = None + + +class ReceiptSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + receiptId: str + miner: str + coordinator: str + issuedAt: datetime + status: str + payload: Optional[Dict[str, Any]] = None + + +class ReceiptListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + jobId: str + items: list[ReceiptSummary] diff --git a/apps/coordinator-api/src/app/routers/__init__.py b/apps/coordinator-api/src/app/routers/__init__.py index 5f1a6bb..84b73ee 100644 --- a/apps/coordinator-api/src/app/routers/__init__.py +++ b/apps/coordinator-api/src/app/routers/__init__.py @@ -1 +1,9 @@ """Router modules for the coordinator API.""" + +from .client import router as client +from .miner import router as miner +from .admin import router as admin +from .marketplace import router as marketplace +from .explorer import router as explorer + +__all__ = ["client", "miner", "admin", "marketplace", "explorer"] diff --git a/apps/coordinator-api/src/app/routers/explorer.py b/apps/coordinator-api/src/app/routers/explorer.py new file mode 100644 index 0000000..205af4e --- /dev/null +++ b/apps/coordinator-api/src/app/routers/explorer.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from ..models import ( + BlockListResponse, + TransactionListResponse, + AddressListResponse, + ReceiptListResponse, +) +from ..services import ExplorerService +from ..storage import SessionDep + +router = APIRouter(prefix="/explorer", tags=["explorer"]) + + +def _service(session: SessionDep) -> ExplorerService: + return ExplorerService(session) + + +@router.get("/blocks", response_model=BlockListResponse, summary="List recent blocks") +async def list_blocks( + *, + session: SessionDep, + limit: int = Query(default=20, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> BlockListResponse: + return _service(session).list_blocks(limit=limit, offset=offset) + + +@router.get( + "/transactions", + response_model=TransactionListResponse, + summary="List recent transactions", +) +async def list_transactions( + *, + session: SessionDep, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> TransactionListResponse: + return _service(session).list_transactions(limit=limit, offset=offset) + + +@router.get("/addresses", response_model=AddressListResponse, summary="List address summaries") +async def list_addresses( + *, + session: SessionDep, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> AddressListResponse: + return _service(session).list_addresses(limit=limit, offset=offset) + + +@router.get("/receipts", response_model=ReceiptListResponse, summary="List job receipts") +async def list_receipts( + *, + session: SessionDep, + job_id: str | None = Query(default=None, description="Filter by job identifier"), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> ReceiptListResponse: + return _service(session).list_receipts(job_id=job_id, limit=limit, offset=offset) diff --git a/apps/coordinator-api/src/app/routers/marketplace.py b/apps/coordinator-api/src/app/routers/marketplace.py new file mode 100644 index 0000000..58dab94 --- /dev/null +++ b/apps/coordinator-api/src/app/routers/marketplace.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import status as http_status + +from ..models import MarketplaceBidRequest, MarketplaceOfferView, MarketplaceStatsView +from ..services import MarketplaceService +from ..storage import SessionDep + +router = APIRouter(tags=["marketplace"]) + + +def _get_service(session: SessionDep) -> MarketplaceService: + return MarketplaceService(session) + + +@router.get( + "/marketplace/offers", + response_model=list[MarketplaceOfferView], + summary="List marketplace offers", +) +async def list_marketplace_offers( + *, + session: SessionDep, + status_filter: str | None = Query(default=None, alias="status", description="Filter by offer status"), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> list[MarketplaceOfferView]: + service = _get_service(session) + try: + return service.list_offers(status=status_filter, limit=limit, offset=offset) + except ValueError: + raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail="invalid status filter") from None + + +@router.get( + "/marketplace/stats", + response_model=MarketplaceStatsView, + summary="Get marketplace summary statistics", +) +async def get_marketplace_stats(*, session: SessionDep) -> MarketplaceStatsView: + service = _get_service(session) + return service.get_stats() + + +@router.post( + "/marketplace/bids", + status_code=http_status.HTTP_202_ACCEPTED, + summary="Submit a marketplace bid", +) +async def submit_marketplace_bid( + payload: MarketplaceBidRequest, + session: SessionDep, +) -> dict[str, str]: + service = _get_service(session) + bid = service.create_bid(payload) + return {"id": bid.id} diff --git a/apps/coordinator-api/src/app/services/__init__.py b/apps/coordinator-api/src/app/services/__init__.py index 806019b..058428b 100644 --- a/apps/coordinator-api/src/app/services/__init__.py +++ b/apps/coordinator-api/src/app/services/__init__.py @@ -2,5 +2,7 @@ from .jobs import JobService from .miners import MinerService +from .marketplace import MarketplaceService +from .explorer import ExplorerService -__all__ = ["JobService", "MinerService"] +__all__ = ["JobService", "MinerService", "MarketplaceService", "ExplorerService"] diff --git a/apps/coordinator-api/src/app/services/explorer.py b/apps/coordinator-api/src/app/services/explorer.py new file mode 100644 index 0000000..01377f9 --- /dev/null +++ b/apps/coordinator-api/src/app/services/explorer.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from datetime import datetime +from typing import Optional + +from sqlmodel import Session, select + +from ..domain import Job, JobReceipt +from ..models import ( + BlockListResponse, + BlockSummary, + TransactionListResponse, + TransactionSummary, + AddressListResponse, + AddressSummary, + ReceiptListResponse, + ReceiptSummary, + JobState, +) + +_STATUS_LABELS = { + JobState.queued: "Queued", + JobState.running: "Running", + JobState.completed: "Succeeded", + JobState.failed: "Failed", + JobState.canceled: "Canceled", + JobState.expired: "Expired", +} + +_DEFAULT_HEIGHT_BASE = 100_000 + + +class ExplorerService: + """Derives explorer-friendly summaries from coordinator data.""" + + def __init__(self, session: Session) -> None: + self.session = session + + def list_blocks(self, *, limit: int = 20, offset: int = 0) -> BlockListResponse: + statement = select(Job).order_by(Job.requested_at.desc()) + jobs = self.session.exec(statement.offset(offset).limit(limit)).all() + + items: list[BlockSummary] = [] + for index, job in enumerate(jobs): + height = _DEFAULT_HEIGHT_BASE + offset + index + proposer = job.assigned_miner_id or "unassigned" + items.append( + BlockSummary( + height=height, + hash=job.id, + timestamp=job.requested_at, + tx_count=1, + proposer=proposer, + ) + ) + + next_offset: Optional[int] = offset + len(items) if len(items) == limit else None + return BlockListResponse(items=items, next_offset=next_offset) + + def list_transactions(self, *, limit: int = 50, offset: int = 0) -> TransactionListResponse: + statement = ( + select(Job) + .order_by(Job.requested_at.desc()) + .offset(offset) + .limit(limit) + ) + jobs = self.session.exec(statement).all() + + items: list[TransactionSummary] = [] + for index, job in enumerate(jobs): + height = _DEFAULT_HEIGHT_BASE + offset + index + status_label = _STATUS_LABELS.get(job.state, job.state.value.title()) + value = job.payload.get("value") if isinstance(job.payload, dict) else None + if value is None: + value_str = "0" + elif isinstance(value, (int, float)): + value_str = f"{value}" + else: + value_str = str(value) + + items.append( + TransactionSummary( + hash=job.id, + block=height, + from_address=job.client_id, + to_address=job.assigned_miner_id, + value=value_str, + status=status_label, + ) + ) + + next_offset: Optional[int] = offset + len(items) if len(items) == limit else None + return TransactionListResponse(items=items, next_offset=next_offset) + + def list_addresses(self, *, limit: int = 50, offset: int = 0) -> AddressListResponse: + statement = select(Job).order_by(Job.requested_at.desc()) + jobs = self.session.exec(statement.offset(offset).limit(limit)).all() + + address_map: dict[str, dict[str, object]] = defaultdict( + lambda: { + "address": "", + "balance": "0", + "tx_count": 0, + "last_active": datetime.min, + "recent_transactions": deque(maxlen=5), + } + ) + + def touch(address: Optional[str], tx_id: str, when: datetime, value_hint: Optional[str] = None) -> None: + if not address: + return + entry = address_map[address] + entry["address"] = address + entry["tx_count"] = int(entry["tx_count"]) + 1 + if when > entry["last_active"]: + entry["last_active"] = when + if value_hint: + entry["balance"] = value_hint + recent: deque[str] = entry["recent_transactions"] # type: ignore[assignment] + recent.appendleft(tx_id) + + for job in jobs: + value = job.payload.get("value") if isinstance(job.payload, dict) else None + value_hint: Optional[str] = None + if value is not None: + value_hint = str(value) + touch(job.client_id, job.id, job.requested_at, value_hint=value_hint) + touch(job.assigned_miner_id, job.id, job.requested_at) + + sorted_addresses = sorted( + address_map.values(), + key=lambda entry: entry["last_active"], + reverse=True, + ) + + sliced = sorted_addresses[offset : offset + limit] + items = [ + AddressSummary( + address=entry["address"], + balance=str(entry["balance"]), + txCount=int(entry["tx_count"]), + lastActive=entry["last_active"], + recentTransactions=list(entry["recent_transactions"]), + ) + for entry in sliced + ] + + next_offset: Optional[int] = offset + len(sliced) if len(sliced) == limit else None + return AddressListResponse(items=items, next_offset=next_offset) + + def list_receipts( + self, + *, + job_id: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> ReceiptListResponse: + statement = select(JobReceipt).order_by(JobReceipt.created_at.desc()) + if job_id: + statement = statement.where(JobReceipt.job_id == job_id) + + rows = self.session.exec(statement.offset(offset).limit(limit)).all() + items: list[ReceiptSummary] = [] + for row in rows: + payload = row.payload or {} + miner = payload.get("miner") or payload.get("miner_id") or "unknown" + coordinator = payload.get("coordinator") or payload.get("coordinator_id") or "unknown" + status = payload.get("status") or payload.get("state") or "Unknown" + items.append( + ReceiptSummary( + receipt_id=row.receipt_id, + miner=miner, + coordinator=coordinator, + issued_at=row.created_at, + status=status, + payload=payload, + ) + ) + + resolved_job_id = job_id or "all" + return ReceiptListResponse(job_id=resolved_job_id, items=items) diff --git a/apps/coordinator-api/src/app/services/marketplace.py b/apps/coordinator-api/src/app/services/marketplace.py new file mode 100644 index 0000000..be9c62a --- /dev/null +++ b/apps/coordinator-api/src/app/services/marketplace.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from statistics import mean +from typing import Iterable, Optional + +from sqlmodel import Session, select + +from ..domain import MarketplaceOffer, MarketplaceBid, OfferStatus +from ..models import ( + MarketplaceBidRequest, + MarketplaceOfferView, + MarketplaceStatsView, +) + + +class MarketplaceService: + """Business logic for marketplace offers, stats, and bids.""" + + def __init__(self, session: Session) -> None: + self.session = session + + def list_offers( + self, + *, + status: Optional[str] = None, + limit: int = 100, + offset: int = 0, + ) -> list[MarketplaceOfferView]: + statement = select(MarketplaceOffer).order_by(MarketplaceOffer.created_at.desc()) + if status: + try: + desired_status = OfferStatus(status.lower()) + except ValueError as exc: # pragma: no cover - validated in router + raise ValueError("invalid status filter") from exc + statement = statement.where(MarketplaceOffer.status == desired_status) + if offset: + statement = statement.offset(offset) + if limit: + statement = statement.limit(limit) + offers = self.session.exec(statement).all() + return [self._to_offer_view(offer) for offer in offers] + + def get_stats(self) -> MarketplaceStatsView: + offers = self.session.exec(select(MarketplaceOffer)).all() + open_offers = [offer for offer in offers if offer.status == OfferStatus.open] + + total_offers = len(offers) + open_capacity = sum(offer.capacity for offer in open_offers) + average_price = mean([offer.price for offer in open_offers]) if open_offers else 0.0 + active_bids = self.session.exec( + select(MarketplaceBid).where(MarketplaceBid.status == "pending") + ).all() + + return MarketplaceStatsView( + totalOffers=total_offers, + openCapacity=open_capacity, + averagePrice=round(average_price, 4), + activeBids=len(active_bids), + ) + + def create_bid(self, payload: MarketplaceBidRequest) -> MarketplaceBid: + bid = MarketplaceBid( + provider=payload.provider, + capacity=payload.capacity, + price=payload.price, + notes=payload.notes, + ) + self.session.add(bid) + self.session.commit() + self.session.refresh(bid) + return bid + + @staticmethod + def _to_offer_view(offer: MarketplaceOffer) -> MarketplaceOfferView: + return MarketplaceOfferView( + id=offer.id, + provider=offer.provider, + capacity=offer.capacity, + price=offer.price, + sla=offer.sla, + status=offer.status.value, + created_at=offer.created_at, + ) diff --git a/apps/coordinator-api/src/app/storage/db.py b/apps/coordinator-api/src/app/storage/db.py index 8e5ed21..3e92573 100644 --- a/apps/coordinator-api/src/app/storage/db.py +++ b/apps/coordinator-api/src/app/storage/db.py @@ -8,7 +8,7 @@ from sqlalchemy.engine import Engine from sqlmodel import Session, SQLModel, create_engine from ..config import settings -from ..domain import Job, Miner +from ..domain import Job, Miner, MarketplaceOffer, MarketplaceBid _engine: Engine | None = None diff --git a/apps/coordinator-api/tests/test_marketplace.py b/apps/coordinator-api/tests/test_marketplace.py new file mode 100644 index 0000000..edd6d55 --- /dev/null +++ b/apps/coordinator-api/tests/test_marketplace.py @@ -0,0 +1,113 @@ +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, delete + +from app.config import settings +from app.domain import MarketplaceOffer, OfferStatus, MarketplaceBid +from app.main import create_app +from app.services.marketplace import MarketplaceService +from app.storage.db import init_db, session_scope + + +@pytest.fixture(scope="module", autouse=True) +def _init_db(tmp_path_factory): + db_file = tmp_path_factory.mktemp("data") / "marketplace.db" + settings.database_url = f"sqlite:///{db_file}" + init_db() + yield + + +@pytest.fixture() +def session(): + with session_scope() as sess: + sess.exec(delete(MarketplaceBid)) + sess.exec(delete(MarketplaceOffer)) + sess.commit() + yield sess + + +@pytest.fixture() +def client(): + app = create_app() + return TestClient(app) + + +def test_list_offers_filters_by_status(client: TestClient, session: Session): + open_offer = MarketplaceOffer(provider="Alpha", capacity=250, price=12.5, sla="99.9%", status=OfferStatus.open) + reserved_offer = MarketplaceOffer(provider="Beta", capacity=100, price=15.0, sla="99.5%", status=OfferStatus.reserved) + session.add(open_offer) + session.add(reserved_offer) + session.commit() + + # All offers + resp = client.get("/v1/marketplace/offers") + assert resp.status_code == 200 + payload = resp.json() + assert len(payload) == 2 + + # Filter by status + resp_open = client.get("/v1/marketplace/offers", params={"status": "open"}) + assert resp_open.status_code == 200 + open_payload = resp_open.json() + assert len(open_payload) == 1 + assert open_payload[0]["provider"] == "Alpha" + + # Invalid status yields 400 + resp_invalid = client.get("/v1/marketplace/offers", params={"status": "invalid"}) + assert resp_invalid.status_code == 400 + + +def test_marketplace_stats(client: TestClient, session: Session): + session.add_all( + [ + MarketplaceOffer(provider="Alpha", capacity=200, price=10.0, sla="99.9%", status=OfferStatus.open), + MarketplaceOffer(provider="Beta", capacity=150, price=20.0, sla="99.5%", status=OfferStatus.open), + MarketplaceOffer(provider="Gamma", capacity=90, price=12.0, sla="99.0%", status=OfferStatus.reserved), + ] + ) + session.commit() + + resp = client.get("/v1/marketplace/stats") + assert resp.status_code == 200 + stats = resp.json() + assert stats["totalOffers"] == 3 + assert stats["openCapacity"] == 350 + assert pytest.approx(stats["averagePrice"], rel=1e-3) == 15.0 + assert stats["activeBids"] == 0 + + +def test_submit_bid_creates_record(client: TestClient, session: Session): + payload = { + "provider": "Alpha", + "capacity": 120, + "price": 13.5, + "notes": "Need overnight capacity", + } + resp = client.post("/v1/marketplace/bids", json=payload) + assert resp.status_code == 202 + response_payload = resp.json() + assert "id" in response_payload + + bid = session.get(MarketplaceBid, response_payload["id"]) + assert bid is not None + assert bid.provider == payload["provider"] + assert bid.capacity == payload["capacity"] + assert bid.price == payload["price"] + assert bid.notes == payload["notes"] + + +def test_marketplace_service_list_offers_handles_limit_offset(session: Session): + session.add_all( + [ + MarketplaceOffer(provider="A", capacity=50, price=9.0, sla="99.0%", status=OfferStatus.open), + MarketplaceOffer(provider="B", capacity=70, price=11.0, sla="99.0%", status=OfferStatus.open), + MarketplaceOffer(provider="C", capacity=90, price=13.0, sla="99.0%", status=OfferStatus.open), + ] + ) + session.commit() + + service = MarketplaceService(session) + limited = service.list_offers(limit=2, offset=1) + assert len(limited) == 2 + # Offers ordered by created_at descending → last inserted first + assert {offer.provider for offer in limited} == {"B", "A"} diff --git a/apps/explorer-web/README.md b/apps/explorer-web/README.md index 89fb6c1..fdf776a 100644 --- a/apps/explorer-web/README.md +++ b/apps/explorer-web/README.md @@ -6,7 +6,6 @@ Static web explorer for the AITBC blockchain node, displaying blocks, transactio ## Development Setup -- Install dependencies: ```bash npm install ``` @@ -14,145 +13,33 @@ Static web explorer for the AITBC blockchain node, displaying blocks, transactio ```bash npm run dev ``` -- The explorer ships with mock data in `public/mock/` that powers the tables by default. + The dev server listens on `http://localhost:5173/` by default. Adjust via `--host`/`--port` flags in the `systemd` unit or `package.json` script. -### Data Mode Toggle +## Data Mode Toggle - Configuration lives in `src/config.ts` and can be overridden with environment variables. - Use `VITE_DATA_MODE` to choose between `mock` (default) and `live`. -- When switching to live data, set `VITE_COORDINATOR_API` to the coordinator base URL (e.g. `http://localhost:8000`). +- When switching to live data, set `VITE_COORDINATOR_API` to the coordinator base URL (e.g., `http://localhost:8000`). - Example `.env` snippet: ```bash VITE_DATA_MODE=live VITE_COORDINATOR_API=https://coordinator.dev.internal ``` - With live mode enabled, the SPA will request `/v1/` routes from the coordinator instead of the bundled mock JSON. -## Next Steps +## Feature Flags & Auth -- Build out responsive styling and navigation interactions. -- Extend the data layer to support coordinator authentication and pagination when live endpoints are ready. -- Document coordinator API assumptions once the backend contracts stabilize. +- Document any backend expectations (e.g., coordinator accepting bearer tokens) alongside the environment variables in deployment manifests. -## Coordinator API Contracts (Draft) +## End-to-End Tests -- **Blocks** (`GET /v1/blocks?limit=&offset=`) - - Expected payload: - ```json - { - "items": [ - { - "height": 12045, - "hash": "0x...", - "timestamp": "2025-09-27T01:58:12Z", - "tx_count": 8, - "proposer": "miner-alpha" - } - ], - "next_offset": 12040 - } - ``` - - TODO: confirm pagination fields and proposer metadata. +- Install browsers after `npm install` by running `npx playwright install`. +- Launch the dev server (or point `EXPLORER_BASE_URL` at an already running instance) and run: + ```bash + npm run test:e2e + ``` +- Tests automatically persist live mode and stub coordinator responses to verify overview, blocks, and transactions views. -- **Transactions** (`GET /v1/transactions?limit=&offset=`) - - Expected payload: - ```json - { - "items": [ - { - "hash": "0x...", - "block": 12045, - "from": "0x...", - "to": "0x...", - "value": "12.5", - "status": "Succeeded" - } - ], - "next_offset": "0x..." - } - ``` - - TODO: finalize value units (AIT vs wei) and status enum. +## Playwright -- **Addresses** (`GET /v1/addresses/{address}`) - - Expected payload: - ```json - { - "address": "0x...", - "balance": "1450.25", - "tx_count": 42, - "last_active": "2025-09-27T01:48:00Z", - "recent_transactions": ["0x..."] - } - ``` - - TODO: detail pagination for recent transactions and add receipt summary references. - -- **Receipts** (`GET /v1/jobs/{job_id}/receipts`) - - Expected payload: - ```json - { - "job_id": "job-0001", - "items": [ - { - "receipt_id": "rcpt-123", - "miner": "miner-alpha", - "coordinator": "coordinator-001", - "issued_at": "2025-09-27T01:52:22Z", - "status": "Attested", - "payload": { - "miner_signature": "0x...", - "coordinator_signature": "0x..." - } - } - ] - } - ``` - - TODO: confirm signature payload structure and include attestation metadata. - -## Styling Guide - -- **`public/css/base.css`** - - Defines global typography, color scheme, and utility classes (tables, placeholders, code tags). - - Use this file for cross-page primitives and reset/normalization rules. - - When adding new utilities (e.g., badges, alerts), document them in this section and keep naming consistent with the existing BEM-lite approach. - -- **`public/css/layout.css`** - - Contains structural styles for the Explorer shell (header, footer, cards, forms, grids). - - Encapsulate component-specific classes with a predictable prefix, such as `.blocks__table`, `.addresses__input-group`, or `.receipts__controls`. - - Prefer utility classes from `base.css` when possible, and only introduce new layout classes when a component requires dedicated styling. - -- **Adding New Components** - - Create semantic markup first in `src/pages/` or `src/components/`, using descriptive class names that map to the page or component (`.transactions__filter`, `.overview__chart`). - - Extend `layout.css` with matching selectors to style the new elements; keep related rules grouped together for readability. - - For reusable widgets across multiple pages, consider extracting shared styles into a dedicated section or introducing a new partial CSS file when the component becomes complex. - -## Deployment Notes - -- **Environment Variables** - - `VITE_DATA_MODE`: `mock` (default) or `live`. - - `VITE_COORDINATOR_API`: Base URL for coordinator API when `live` mode is enabled. - - Additional Vite variables can be added following the `VITE_*` naming convention. - -- **Mock vs Live** - - In non-production environments, keep `VITE_DATA_MODE=mock` to serve the static JSON under `public/mock/` for quick demos. - - For staging/production deployments, set `VITE_DATA_MODE=live` and ensure the coordinator endpoint is reachable from the frontend origin; configure CORS accordingly on the backend. - - Consider serving mock JSON from a CDN or static bucket if you want deterministic demos while backend dependencies are under development. - -- **Build & Deploy** - - Build command: `npm run build` (outputs to `dist/`). - - Preview locally with `npm run preview` before publishing. - - Deploy the `dist/` contents to your static host (e.g., Nginx, S3 + CloudFront, Vercel). Ensure environment variables are injected at build time or through runtime configuration mechanisms supported by your hosting provider. - -## Error Handling (Live Mode) - -- **Status Codes** - - `2xx`: Treat as success; map response bodies into the typed models in `src/lib/models.ts`. - - `4xx`: Surface actionable messages to the user (e.g., invalid job ID). For `404`, show “not found” states in the relevant page. For `429`, display a rate-limit notice and back off. - - `5xx`: Show a generic coordinator outage message and trigger retry logic. - -- **Retry Strategy** - - Use an exponential backoff with jitter when retrying `5xx` or network failures (suggested base delay 500 ms, max 5 attempts). - - Do not retry on `4xx` except `429`; instead, display feedback. - -- **Telemetry & Logging** - - Consider emitting console warnings or hooking into an analytics layer when retries occur, noting the endpoint and status code. - - Bubble critical errors via a shared notification component so users understand whether data is stale or unavailable. +- Run `npm run test:e2e` to execute the end-to-end tests. +- The tests will automatically persist live mode and stub coordinator responses to verify overview, blocks, and transactions views. diff --git a/apps/explorer-web/package.json b/apps/explorer-web/package.json index ab45602..797600f 100644 --- a/apps/explorer-web/package.json +++ b/apps/explorer-web/package.json @@ -5,10 +5,13 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test" }, "dependencies": {}, "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^20.12.7", "typescript": "^5.4.0", "vite": "^5.2.0" } diff --git a/apps/explorer-web/playwright.config.ts b/apps/explorer-web/playwright.config.ts new file mode 100644 index 0000000..ed4dd31 --- /dev/null +++ b/apps/explorer-web/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = process.env.EXPLORER_DEV_PORT ?? "5173"; +const HOST = process.env.EXPLORER_DEV_HOST ?? "127.0.0.1"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: process.env.EXPLORER_BASE_URL ?? `http://${HOST}:${PORT}`, + trace: "on-first-retry", + viewport: { width: 1280, height: 720 }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/apps/explorer-web/public/css/layout.css b/apps/explorer-web/public/css/layout.css index 84246f6..3e83712 100644 --- a/apps/explorer-web/public/css/layout.css +++ b/apps/explorer-web/public/css/layout.css @@ -29,12 +29,52 @@ flex: 1 1 45%; text-align: center; } - .addresses__input-group, .receipts__input-group { flex-direction: column; } + .overview__grid { + grid-template-columns: 1fr; + } + + .table thead { + display: none; + } + + .table tr { + display: grid; + gap: 0.5rem; + padding: 1rem 0; + border-bottom: 1px solid rgba(125, 196, 255, 0.12); + } + + .table td { + display: flex; + justify-content: space-between; + gap: 0.75rem; + padding: 0.25rem 0; + } +} + +@media (min-width: 768px) { + .overview__grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .table thead { + display: table-header-group; + } + + .table tr { + display: table-row; + } + + .table td { + display: table-cell; + } +} +@media (max-width: 768px) { .toast-container { left: 0; right: 0; @@ -169,6 +209,18 @@ flex: 1 1 auto; text-align: center; } + + .page { + padding: 1.5rem; + } + + .overview__grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } + + .table td { + font-size: 0.95rem; + } } .section-header { diff --git a/apps/explorer-web/src/lib/mockData.ts b/apps/explorer-web/src/lib/mockData.ts index 759f929..df56795 100644 --- a/apps/explorer-web/src/lib/mockData.ts +++ b/apps/explorer-web/src/lib/mockData.ts @@ -4,6 +4,7 @@ import type { BlockListResponse, TransactionListResponse, AddressDetailResponse, + AddressListResponse, ReceiptListResponse, BlockSummary, TransactionSummary, @@ -11,7 +12,33 @@ import type { ReceiptSummary, } from "./models.ts"; -let currentMode: DataMode = CONFIG.dataMode; +const STORAGE_KEY = "aitbc-explorer:data-mode"; + +function loadStoredMode(): DataMode | null { + if (typeof window === "undefined") { + return null; + } + try { + const value = window.localStorage.getItem(STORAGE_KEY); + if (value === "mock" || value === "live") { + return value as DataMode; + } + } catch (error) { + console.warn("[Explorer] Unable to read stored data mode", error); + } + return null; +} + +const initialMode = loadStoredMode() ?? CONFIG.dataMode; +let currentMode: DataMode = initialMode; + +function syncDocumentMode(mode: DataMode): void { + if (typeof document !== "undefined") { + document.documentElement.dataset.mode = mode; + } +} + +syncDocumentMode(currentMode); export function getDataMode(): DataMode { return currentMode; @@ -19,6 +46,14 @@ export function getDataMode(): DataMode { export function setDataMode(mode: DataMode): void { currentMode = mode; + syncDocumentMode(mode); + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(STORAGE_KEY, mode); + } catch (error) { + console.warn("[Explorer] Failed to persist data mode", error); + } + } } export async function fetchBlocks(): Promise { @@ -28,15 +63,15 @@ export async function fetchBlocks(): Promise { } try { - const response = await fetch(`${CONFIG.apiBaseUrl}/v1/blocks`); + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/blocks`); if (!response.ok) { - throw new Error(`Failed to fetch blocks: ${response.status}`); + throw new Error(`Failed to fetch blocks: ${response.status} ${response.statusText}`); } const data = (await response.json()) as BlockListResponse; return data.items; } catch (error) { - console.warn("[Explorer] Failed to fetch live block data", error); - notifyError("Unable to load live block data. Displaying placeholders."); + console.error("[Explorer] Failed to fetch live block data", error); + notifyError("Unable to load live block data from coordinator. Showing placeholders."); return []; } } @@ -48,15 +83,15 @@ export async function fetchTransactions(): Promise { } try { - const response = await fetch(`${CONFIG.apiBaseUrl}/v1/transactions`); + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/transactions`); if (!response.ok) { - throw new Error(`Failed to fetch transactions: ${response.status}`); + throw new Error(`Failed to fetch transactions: ${response.status} ${response.statusText}`); } const data = (await response.json()) as TransactionListResponse; return data.items; } catch (error) { - console.warn("[Explorer] Failed to fetch live transaction data", error); - notifyError("Unable to load live transaction data. Displaying placeholders."); + console.error("[Explorer] Failed to fetch live transaction data", error); + notifyError("Unable to load transactions from coordinator. Showing placeholders."); return []; } } @@ -68,15 +103,15 @@ export async function fetchAddresses(): Promise { } try { - const response = await fetch(`${CONFIG.apiBaseUrl}/v1/addresses`); + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/addresses`); if (!response.ok) { - throw new Error(`Failed to fetch addresses: ${response.status}`); + throw new Error(`Failed to fetch addresses: ${response.status} ${response.statusText}`); } - const data = (await response.json()) as { items: AddressDetailResponse[] } | AddressDetailResponse[]; - return Array.isArray(data) ? data : data.items; + const data = (await response.json()) as AddressListResponse; + return data.items; } catch (error) { - console.warn("[Explorer] Failed to fetch live address data", error); - notifyError("Unable to load live address data. Displaying placeholders."); + console.error("[Explorer] Failed to fetch live address data", error); + notifyError("Unable to load address summaries from coordinator. Showing placeholders."); return []; } } @@ -88,15 +123,15 @@ export async function fetchReceipts(): Promise { } try { - const response = await fetch(`${CONFIG.apiBaseUrl}/v1/receipts`); + const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/receipts`); if (!response.ok) { - throw new Error(`Failed to fetch receipts: ${response.status}`); + throw new Error(`Failed to fetch receipts: ${response.status} ${response.statusText}`); } const data = (await response.json()) as ReceiptListResponse; return data.items; } catch (error) { - console.warn("[Explorer] Failed to fetch live receipt data", error); - notifyError("Unable to load live receipt data. Displaying placeholders."); + console.error("[Explorer] Failed to fetch live receipt data", error); + notifyError("Unable to load receipts from coordinator. Showing placeholders."); return []; } } diff --git a/apps/explorer-web/test-results/.last-run.json b/apps/explorer-web/test-results/.last-run.json new file mode 100644 index 0000000..546a2e7 --- /dev/null +++ b/apps/explorer-web/test-results/.last-run.json @@ -0,0 +1,8 @@ +{ + "status": "failed", + "failedTests": [ + "78c83d26793c923d7fe5-1c82705bd81364a8b68d", + "78c83d26793c923d7fe5-d8983ad99256a494df4f", + "78c83d26793c923d7fe5-a5eb02c7b1bcc34f643e" + ] +} \ No newline at end of file diff --git a/apps/explorer-web/test-results/explorer-live-Explorer-liv-3dc9a-tions-table-shows-live-rows-chromium/error-context.md b/apps/explorer-web/test-results/explorer-live-Explorer-liv-3dc9a-tions-table-shows-live-rows-chromium/error-context.md new file mode 100644 index 0000000..0b762a2 --- /dev/null +++ b/apps/explorer-web/test-results/explorer-live-Explorer-liv-3dc9a-tions-table-shows-live-rows-chromium/error-context.md @@ -0,0 +1,91 @@ +# Page snapshot + +```yaml +- generic [ref=e2]: + - main [ref=e3]: + - generic [ref=e4]: + - paragraph [ref=e5]: + - text: "Data mode:" + - strong [ref=e6]: MOCK + - heading "Marketplace Control Center" [level=1] [ref=e7] + - paragraph [ref=e8]: Monitor available offers, submit bids, and review marketplace health at a glance. + - generic [ref=e9]: + - article [ref=e10]: + - heading "Total Offers" [level=2] [ref=e11] + - strong [ref=e12]: "78" + - generic [ref=e13]: Listings currently visible + - article [ref=e14]: + - heading "Open Capacity" [level=2] [ref=e15] + - strong [ref=e16]: 1,120 units + - generic [ref=e17]: GPU / compute units available + - article [ref=e18]: + - heading "Average Price" [level=2] [ref=e19] + - strong [ref=e20]: 14.30 credits + - generic [ref=e21]: Credits per unit per hour + - article [ref=e22]: + - heading "Active Bids" [level=2] [ref=e23] + - strong [ref=e24]: "36" + - generic [ref=e25]: Open bids awaiting match + - generic [ref=e26]: + - article [ref=e27]: + - heading "Available Offers" [level=2] [ref=e28] + - table [ref=e31]: + - rowgroup [ref=e32]: + - row "ID Provider Capacity Price SLA Status" [ref=e33]: + - cell "ID" [ref=e34] + - cell "Provider" [ref=e35] + - cell "Capacity" [ref=e36] + - cell "Price" [ref=e37] + - cell "SLA" [ref=e38] + - cell "Status" [ref=e39] + - rowgroup [ref=e40]: + - row "offer-101 Alpha Pool 250 units 12.50 99.9% Open" [ref=e41]: + - cell "offer-101" [ref=e42] + - cell "Alpha Pool" [ref=e43] + - cell "250 units" [ref=e44] + - cell "12.50" [ref=e45] + - cell "99.9%" [ref=e46] + - cell "Open" [ref=e47]: + - generic [ref=e48]: Open + - row "offer-102 Beta Collective 140 units 15.75 99.5% Open" [ref=e49]: + - cell "offer-102" [ref=e50] + - cell "Beta Collective" [ref=e51] + - cell "140 units" [ref=e52] + - cell "15.75" [ref=e53] + - cell "99.5%" [ref=e54] + - cell "Open" [ref=e55]: + - generic [ref=e56]: Open + - row "offer-103 Gamma Compute 400 units 10.90 99.95% Reserved" [ref=e57]: + - cell "offer-103" [ref=e58] + - cell "Gamma Compute" [ref=e59] + - cell "400 units" [ref=e60] + - cell "10.90" [ref=e61] + - cell "99.95%" [ref=e62] + - cell "Reserved" [ref=e63]: + - generic [ref=e64]: Reserved + - row "offer-104 Delta Grid 90 units 18.25 99.0% Open" [ref=e65]: + - cell "offer-104" [ref=e66] + - cell "Delta Grid" [ref=e67] + - cell "90 units" [ref=e68] + - cell "18.25" [ref=e69] + - cell "99.0%" [ref=e70] + - cell "Open" [ref=e71]: + - generic [ref=e72]: Open + - article [ref=e73]: + - heading "Submit a Bid" [level=2] [ref=e74] + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Preferred provider + - textbox "Preferred provider" [ref=e78] + - generic [ref=e79]: + - generic [ref=e80]: Capacity required (units) + - spinbutton "Capacity required (units)" [ref=e81] + - generic [ref=e82]: + - generic [ref=e83]: Bid price (credits/unit/hr) + - spinbutton "Bid price (credits/unit/hr)" [ref=e84] + - generic [ref=e85]: + - generic [ref=e86]: Notes (optional) + - textbox "Notes (optional)" [ref=e87] + - button "Submit Bid" [ref=e88] [cursor=pointer] + - complementary [ref=e89] +``` \ No newline at end of file diff --git a/apps/explorer-web/test-results/explorer-live-Explorer-liv-b10c9-view-renders-live-summaries-chromium/error-context.md b/apps/explorer-web/test-results/explorer-live-Explorer-liv-b10c9-view-renders-live-summaries-chromium/error-context.md new file mode 100644 index 0000000..0b762a2 --- /dev/null +++ b/apps/explorer-web/test-results/explorer-live-Explorer-liv-b10c9-view-renders-live-summaries-chromium/error-context.md @@ -0,0 +1,91 @@ +# Page snapshot + +```yaml +- generic [ref=e2]: + - main [ref=e3]: + - generic [ref=e4]: + - paragraph [ref=e5]: + - text: "Data mode:" + - strong [ref=e6]: MOCK + - heading "Marketplace Control Center" [level=1] [ref=e7] + - paragraph [ref=e8]: Monitor available offers, submit bids, and review marketplace health at a glance. + - generic [ref=e9]: + - article [ref=e10]: + - heading "Total Offers" [level=2] [ref=e11] + - strong [ref=e12]: "78" + - generic [ref=e13]: Listings currently visible + - article [ref=e14]: + - heading "Open Capacity" [level=2] [ref=e15] + - strong [ref=e16]: 1,120 units + - generic [ref=e17]: GPU / compute units available + - article [ref=e18]: + - heading "Average Price" [level=2] [ref=e19] + - strong [ref=e20]: 14.30 credits + - generic [ref=e21]: Credits per unit per hour + - article [ref=e22]: + - heading "Active Bids" [level=2] [ref=e23] + - strong [ref=e24]: "36" + - generic [ref=e25]: Open bids awaiting match + - generic [ref=e26]: + - article [ref=e27]: + - heading "Available Offers" [level=2] [ref=e28] + - table [ref=e31]: + - rowgroup [ref=e32]: + - row "ID Provider Capacity Price SLA Status" [ref=e33]: + - cell "ID" [ref=e34] + - cell "Provider" [ref=e35] + - cell "Capacity" [ref=e36] + - cell "Price" [ref=e37] + - cell "SLA" [ref=e38] + - cell "Status" [ref=e39] + - rowgroup [ref=e40]: + - row "offer-101 Alpha Pool 250 units 12.50 99.9% Open" [ref=e41]: + - cell "offer-101" [ref=e42] + - cell "Alpha Pool" [ref=e43] + - cell "250 units" [ref=e44] + - cell "12.50" [ref=e45] + - cell "99.9%" [ref=e46] + - cell "Open" [ref=e47]: + - generic [ref=e48]: Open + - row "offer-102 Beta Collective 140 units 15.75 99.5% Open" [ref=e49]: + - cell "offer-102" [ref=e50] + - cell "Beta Collective" [ref=e51] + - cell "140 units" [ref=e52] + - cell "15.75" [ref=e53] + - cell "99.5%" [ref=e54] + - cell "Open" [ref=e55]: + - generic [ref=e56]: Open + - row "offer-103 Gamma Compute 400 units 10.90 99.95% Reserved" [ref=e57]: + - cell "offer-103" [ref=e58] + - cell "Gamma Compute" [ref=e59] + - cell "400 units" [ref=e60] + - cell "10.90" [ref=e61] + - cell "99.95%" [ref=e62] + - cell "Reserved" [ref=e63]: + - generic [ref=e64]: Reserved + - row "offer-104 Delta Grid 90 units 18.25 99.0% Open" [ref=e65]: + - cell "offer-104" [ref=e66] + - cell "Delta Grid" [ref=e67] + - cell "90 units" [ref=e68] + - cell "18.25" [ref=e69] + - cell "99.0%" [ref=e70] + - cell "Open" [ref=e71]: + - generic [ref=e72]: Open + - article [ref=e73]: + - heading "Submit a Bid" [level=2] [ref=e74] + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Preferred provider + - textbox "Preferred provider" [ref=e78] + - generic [ref=e79]: + - generic [ref=e80]: Capacity required (units) + - spinbutton "Capacity required (units)" [ref=e81] + - generic [ref=e82]: + - generic [ref=e83]: Bid price (credits/unit/hr) + - spinbutton "Bid price (credits/unit/hr)" [ref=e84] + - generic [ref=e85]: + - generic [ref=e86]: Notes (optional) + - textbox "Notes (optional)" [ref=e87] + - button "Submit Bid" [ref=e88] [cursor=pointer] + - complementary [ref=e89] +``` \ No newline at end of file diff --git a/apps/explorer-web/test-results/explorer-live-Explorer-liv-f2fe7-locks-table-shows-live-rows-chromium/error-context.md b/apps/explorer-web/test-results/explorer-live-Explorer-liv-f2fe7-locks-table-shows-live-rows-chromium/error-context.md new file mode 100644 index 0000000..0b762a2 --- /dev/null +++ b/apps/explorer-web/test-results/explorer-live-Explorer-liv-f2fe7-locks-table-shows-live-rows-chromium/error-context.md @@ -0,0 +1,91 @@ +# Page snapshot + +```yaml +- generic [ref=e2]: + - main [ref=e3]: + - generic [ref=e4]: + - paragraph [ref=e5]: + - text: "Data mode:" + - strong [ref=e6]: MOCK + - heading "Marketplace Control Center" [level=1] [ref=e7] + - paragraph [ref=e8]: Monitor available offers, submit bids, and review marketplace health at a glance. + - generic [ref=e9]: + - article [ref=e10]: + - heading "Total Offers" [level=2] [ref=e11] + - strong [ref=e12]: "78" + - generic [ref=e13]: Listings currently visible + - article [ref=e14]: + - heading "Open Capacity" [level=2] [ref=e15] + - strong [ref=e16]: 1,120 units + - generic [ref=e17]: GPU / compute units available + - article [ref=e18]: + - heading "Average Price" [level=2] [ref=e19] + - strong [ref=e20]: 14.30 credits + - generic [ref=e21]: Credits per unit per hour + - article [ref=e22]: + - heading "Active Bids" [level=2] [ref=e23] + - strong [ref=e24]: "36" + - generic [ref=e25]: Open bids awaiting match + - generic [ref=e26]: + - article [ref=e27]: + - heading "Available Offers" [level=2] [ref=e28] + - table [ref=e31]: + - rowgroup [ref=e32]: + - row "ID Provider Capacity Price SLA Status" [ref=e33]: + - cell "ID" [ref=e34] + - cell "Provider" [ref=e35] + - cell "Capacity" [ref=e36] + - cell "Price" [ref=e37] + - cell "SLA" [ref=e38] + - cell "Status" [ref=e39] + - rowgroup [ref=e40]: + - row "offer-101 Alpha Pool 250 units 12.50 99.9% Open" [ref=e41]: + - cell "offer-101" [ref=e42] + - cell "Alpha Pool" [ref=e43] + - cell "250 units" [ref=e44] + - cell "12.50" [ref=e45] + - cell "99.9%" [ref=e46] + - cell "Open" [ref=e47]: + - generic [ref=e48]: Open + - row "offer-102 Beta Collective 140 units 15.75 99.5% Open" [ref=e49]: + - cell "offer-102" [ref=e50] + - cell "Beta Collective" [ref=e51] + - cell "140 units" [ref=e52] + - cell "15.75" [ref=e53] + - cell "99.5%" [ref=e54] + - cell "Open" [ref=e55]: + - generic [ref=e56]: Open + - row "offer-103 Gamma Compute 400 units 10.90 99.95% Reserved" [ref=e57]: + - cell "offer-103" [ref=e58] + - cell "Gamma Compute" [ref=e59] + - cell "400 units" [ref=e60] + - cell "10.90" [ref=e61] + - cell "99.95%" [ref=e62] + - cell "Reserved" [ref=e63]: + - generic [ref=e64]: Reserved + - row "offer-104 Delta Grid 90 units 18.25 99.0% Open" [ref=e65]: + - cell "offer-104" [ref=e66] + - cell "Delta Grid" [ref=e67] + - cell "90 units" [ref=e68] + - cell "18.25" [ref=e69] + - cell "99.0%" [ref=e70] + - cell "Open" [ref=e71]: + - generic [ref=e72]: Open + - article [ref=e73]: + - heading "Submit a Bid" [level=2] [ref=e74] + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Preferred provider + - textbox "Preferred provider" [ref=e78] + - generic [ref=e79]: + - generic [ref=e80]: Capacity required (units) + - spinbutton "Capacity required (units)" [ref=e81] + - generic [ref=e82]: + - generic [ref=e83]: Bid price (credits/unit/hr) + - spinbutton "Bid price (credits/unit/hr)" [ref=e84] + - generic [ref=e85]: + - generic [ref=e86]: Notes (optional) + - textbox "Notes (optional)" [ref=e87] + - button "Submit Bid" [ref=e88] [cursor=pointer] + - complementary [ref=e89] +``` \ No newline at end of file diff --git a/apps/explorer-web/tests/e2e/explorer-live.spec.ts b/apps/explorer-web/tests/e2e/explorer-live.spec.ts new file mode 100644 index 0000000..bf9c18a --- /dev/null +++ b/apps/explorer-web/tests/e2e/explorer-live.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Explorer live mode", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("aitbc-explorer:data-mode", "live"); + }); + + await page.route("**/v1/explorer/blocks", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + height: 12345, + hash: "0xabcdef1234567890", + timestamp: new Date("2024-08-22T12:00:00Z").toISOString(), + txCount: 12, + proposer: "validator-1", + }, + ], + next_offset: null, + }), + }); + }); + + await page.route("**/v1/explorer/transactions", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + hash: "0xfeed1234", + block: 12345, + from: "0xAAA", + to: "0xBBB", + value: "0.50", + status: "Succeeded", + }, + ], + next_offset: null, + }), + }); + }); + + await page.route("**/v1/explorer/receipts", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + jobId: "job-1", + items: [ + { + receiptId: "receipt-1", + miner: "miner-1", + coordinator: "coordinator-1", + issuedAt: new Date("2024-08-22T12:00:00Z").toISOString(), + status: "Attested", + }, + ], + }), + }); + }); + + await page.route("**/v1/explorer/addresses", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + address: "0xADDRESS", + balance: "100.0", + txCount: 42, + lastActive: new Date("2024-08-22T10:00:00Z").toISOString(), + }, + ], + next_offset: null, + }), + }); + }); + }); + + test("overview renders live summaries", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator("#overview-block-stats")).toContainText("12345"); + await expect(page.locator("#overview-transaction-stats")).toContainText("Total Mock Tx: 1"); + await expect(page.locator("#overview-receipt-stats")).toContainText("Total Receipts: 1"); + }); + + test("blocks table shows live rows", async ({ page }) => { + await page.goto("/blocks"); + + const rows = page.locator("#blocks-table-body tr"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("12345"); + await expect(rows.first()).toContainText("validator-1"); + }); + + test("transactions table shows live rows", async ({ page }) => { + await page.goto("/transactions"); + + const rows = page.locator("tbody tr"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("0xfeed1234"); + await expect(rows.first()).toContainText("Succeeded"); + }); +}); diff --git a/apps/marketplace-web/.gitignore b/apps/marketplace-web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/marketplace-web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/marketplace-web/README.md b/apps/marketplace-web/README.md index fc601c9..3fed021 100644 --- a/apps/marketplace-web/README.md +++ b/apps/marketplace-web/README.md @@ -1,15 +1,41 @@ # Marketplace Web -## Purpose & Scope +Mock UI for exploring marketplace offers and submitting bids. -Vite-powered vanilla TypeScript app for listing compute offers, placing bids, and showing market analytics. Follow the implementation blueprint in `docs/bootstrap/marketplace_web.md`. +## Development -## Development Setup +```bash +npm install +npm run dev +``` -- Install dependencies with `npm install` once `package.json` is defined. -- Run the dev server via `npm run dev`. -- Build for production with `npm run build` and preview using `npm run preview`. +The dev server listens on `http://localhost:5173/` by default. Adjust via `--host`/`--port` flags in the `systemd` unit or `package.json` script. -## Notes +## Data Modes -Works against mock API responses initially; switch to real coordinator/pool-hub endpoints later via `VITE_API_BASE`. +Marketplace web reuses the explorer pattern of mock vs. live data: + +- Set `VITE_MARKETPLACE_DATA_MODE=mock` (default) to consume JSON fixtures under `public/mock/`. +- Set `VITE_MARKETPLACE_DATA_MODE=live` and point `VITE_MARKETPLACE_API` to the coordinator backend when integration-ready. + +### Feature Flags & Auth + +- `VITE_MARKETPLACE_ENABLE_BIDS` (default `true`) gates whether the bid form submits to the backend. Set to `false` to keep the UI read-only during phased rollouts. +- `VITE_MARKETPLACE_REQUIRE_AUTH` (default `false`) enforces a bearer token session before live bid submissions. Tokens are stored in `localStorage` by `src/lib/auth.ts`; the API helpers automatically attach the `Authorization` header when a session is present. +- Session JSON is expected to include `token` (string) and `expiresAt` (epoch ms). Expired or malformed entries are cleared automatically. + +Document any backend expectations (e.g., coordinator accepting bearer tokens) alongside the environment variables in deployment manifests. + +## Structure + +- `public/mock/offers.json` – sample marketplace offers. +- `public/mock/stats.json` – summary dashboard statistics. +- `src/lib/api.ts` – data-mode-aware fetch helpers. +- `src/main.ts` – renders dashboard, offers table, and bid form. +- `src/style.css` – layout and visual styling. + +## Submitting Bids + +When in mock mode, bid submissions simulate latency and always succeed. + +When in live mode, ensure the coordinator exposes `/v1/marketplace/offers`, `/v1/marketplace/stats`, and `/v1/marketplace/bids` endpoints compatible with the JSON shapes defined in `src/lib/api.ts`. diff --git a/apps/marketplace-web/index.html b/apps/marketplace-web/index.html new file mode 100644 index 0000000..6156281 --- /dev/null +++ b/apps/marketplace-web/index.html @@ -0,0 +1,13 @@ + + + + + + + marketplace-web + + +
+ + + diff --git a/apps/marketplace-web/package.json b/apps/marketplace-web/package.json new file mode 100644 index 0000000..175c68a --- /dev/null +++ b/apps/marketplace-web/package.json @@ -0,0 +1,15 @@ +{ + "name": "marketplace-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^7.1.7" + } +} diff --git a/apps/marketplace-web/public/mock/offers.json b/apps/marketplace-web/public/mock/offers.json new file mode 100644 index 0000000..def2233 --- /dev/null +++ b/apps/marketplace-web/public/mock/offers.json @@ -0,0 +1,36 @@ +{ + "offers": [ + { + "id": "offer-101", + "provider": "Alpha Pool", + "capacity": 250, + "price": 12.5, + "sla": "99.9%", + "status": "Open" + }, + { + "id": "offer-102", + "provider": "Beta Collective", + "capacity": 140, + "price": 15.75, + "sla": "99.5%", + "status": "Open" + }, + { + "id": "offer-103", + "provider": "Gamma Compute", + "capacity": 400, + "price": 10.9, + "sla": "99.95%", + "status": "Reserved" + }, + { + "id": "offer-104", + "provider": "Delta Grid", + "capacity": 90, + "price": 18.25, + "sla": "99.0%", + "status": "Open" + } + ] +} diff --git a/apps/marketplace-web/public/mock/stats.json b/apps/marketplace-web/public/mock/stats.json new file mode 100644 index 0000000..a237649 --- /dev/null +++ b/apps/marketplace-web/public/mock/stats.json @@ -0,0 +1,6 @@ +{ + "totalOffers": 78, + "openCapacity": 1120, + "averagePrice": 14.3, + "activeBids": 36 +} diff --git a/apps/marketplace-web/public/vite.svg b/apps/marketplace-web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/marketplace-web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/marketplace-web/src/counter.ts b/apps/marketplace-web/src/counter.ts new file mode 100644 index 0000000..09e5afd --- /dev/null +++ b/apps/marketplace-web/src/counter.ts @@ -0,0 +1,9 @@ +export function setupCounter(element: HTMLButtonElement) { + let counter = 0 + const setCounter = (count: number) => { + counter = count + element.innerHTML = `count is ${counter}` + } + element.addEventListener('click', () => setCounter(counter + 1)) + setCounter(0) +} diff --git a/apps/marketplace-web/src/lib/api.ts b/apps/marketplace-web/src/lib/api.ts new file mode 100644 index 0000000..2bc7dcb --- /dev/null +++ b/apps/marketplace-web/src/lib/api.ts @@ -0,0 +1,118 @@ +import { loadSession } from "./auth"; + +export type DataMode = "mock" | "live"; + +interface OfferRecord { + id: string; + provider: string; + capacity: number; + price: number; + sla: string; + status: string; +} + +interface OffersResponse { + offers: OfferRecord[]; +} + +export interface MarketplaceStats { + totalOffers: number; + openCapacity: number; + averagePrice: number; + activeBids: number; +} + +export interface MarketplaceOffer extends OfferRecord {} + +const CONFIG = { + dataMode: (import.meta.env?.VITE_MARKETPLACE_DATA_MODE as DataMode) ?? "mock", + mockBase: "/mock", + apiBase: import.meta.env?.VITE_MARKETPLACE_API ?? "http://localhost:8081", + enableBids: + (import.meta.env?.VITE_MARKETPLACE_ENABLE_BIDS ?? "true").toLowerCase() !== + "false", + requireAuth: + (import.meta.env?.VITE_MARKETPLACE_REQUIRE_AUTH ?? "false").toLowerCase() === + "true", +}; + +function buildHeaders(): HeadersInit { + const headers: Record = { + "Cache-Control": "no-cache", + }; + + const session = loadSession(); + if (session) { + headers.Authorization = `Bearer ${session.token}`; + } + + return headers; +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { + ...buildHeaders(), + ...init?.headers, + }, + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + return response.json() as Promise; +} + +export async function fetchMarketplaceStats(): Promise { + if (CONFIG.dataMode === "mock") { + return request(`${CONFIG.mockBase}/stats.json`); + } + + return request(`${CONFIG.apiBase}/v1/marketplace/stats`); +} + +export async function fetchMarketplaceOffers(): Promise { + if (CONFIG.dataMode === "mock") { + const payload = await request(`${CONFIG.mockBase}/offers.json`); + return payload.offers; + } + + return request(`${CONFIG.apiBase}/v1/marketplace/offers`); +} + +export async function submitMarketplaceBid(input: { + provider: string; + capacity: number; + price: number; + notes?: string; +}): Promise { + if (!CONFIG.enableBids) { + throw new Error("Bid submissions are disabled by configuration"); + } + + if (CONFIG.dataMode === "mock") { + await new Promise((resolve) => setTimeout(resolve, 600)); + return; + } + + if (CONFIG.requireAuth && !loadSession()) { + throw new Error("Authentication required to submit bids"); + } + + const response = await fetch(`${CONFIG.apiBase}/v1/marketplace/bids`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildHeaders(), + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to submit bid"); + } +} + +export const MARKETPLACE_CONFIG = CONFIG; diff --git a/apps/marketplace-web/src/lib/auth.ts b/apps/marketplace-web/src/lib/auth.ts new file mode 100644 index 0000000..5e16e14 --- /dev/null +++ b/apps/marketplace-web/src/lib/auth.ts @@ -0,0 +1,33 @@ +export interface MarketplaceSession { + token: string; + expiresAt: number; +} + +const STORAGE_KEY = "marketplace-session"; + +export function saveSession(session: MarketplaceSession): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); +} + +export function loadSession(): MarketplaceSession | null { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + try { + const data = JSON.parse(raw) as MarketplaceSession; + if (typeof data.token === "string" && typeof data.expiresAt === "number") { + if (data.expiresAt > Date.now()) { + return data; + } + clearSession(); + } + } catch (error) { + console.warn("Failed to parse stored marketplace session", error); + } + return null; +} + +export function clearSession(): void { + localStorage.removeItem(STORAGE_KEY); +} diff --git a/apps/marketplace-web/src/main.ts b/apps/marketplace-web/src/main.ts new file mode 100644 index 0000000..8101677 --- /dev/null +++ b/apps/marketplace-web/src/main.ts @@ -0,0 +1,216 @@ +import './style.css'; +import { + fetchMarketplaceOffers, + fetchMarketplaceStats, + submitMarketplaceBid, + MARKETPLACE_CONFIG, +} from './lib/api'; +import type { MarketplaceOffer, MarketplaceStats } from './lib/api'; + +const app = document.querySelector('#app'); + +if (!app) { + throw new Error('Unable to mount marketplace app'); +} + +app.innerHTML = ` +
+ + +
+
+

Total Offers

+ -- + Listings currently visible +
+
+

Open Capacity

+ -- + GPU / compute units available +
+
+

Average Price

+ -- + Credits per unit per hour +
+
+

Active Bids

+ -- + Open bids awaiting match +
+
+ +
+
+

Available Offers

+
+

Fetching marketplace offers…

+
+
+ +
+

Submit a Bid

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +`; + +const selectors = { + totalOffers: document.querySelector('#stat-total-offers'), + openCapacity: document.querySelector('#stat-open-capacity'), + averagePrice: document.querySelector('#stat-average-price'), + activeBids: document.querySelector('#stat-active-bids'), + offersWrapper: document.querySelector('#offers-table-wrapper'), + bidForm: document.querySelector('#bid-form'), + toast: document.querySelector('#toast'), +}; + +function formatNumber(value: number, options: Intl.NumberFormatOptions = {}): string { + return new Intl.NumberFormat(undefined, options).format(value); +} + +function renderStats(stats: MarketplaceStats): void { + selectors.totalOffers!.textContent = formatNumber(stats.totalOffers); + selectors.openCapacity!.textContent = `${formatNumber(stats.openCapacity)} units`; + selectors.averagePrice!.textContent = `${formatNumber(stats.averagePrice, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} credits`; + selectors.activeBids!.textContent = formatNumber(stats.activeBids); +} + +function statusClass(status: string): string { + switch (status.toLowerCase()) { + case 'open': + return 'status-pill status-open'; + case 'reserved': + return 'status-pill status-reserved'; + default: + return 'status-pill'; + } +} + +function renderOffers(offers: MarketplaceOffer[]): void { + if (!selectors.offersWrapper) { + return; + } + + if (offers.length === 0) { + selectors.offersWrapper.innerHTML = '

No offers available right now. Check back soon or submit a bid.

'; + return; + } + + const rows = offers + .map( + (offer) => ` + + ${offer.id} + ${offer.provider} + ${formatNumber(offer.capacity)} units + ${formatNumber(offer.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + ${offer.sla} + ${offer.status} + + `, + ) + .join(''); + + selectors.offersWrapper.innerHTML = ` +
+ + + + + + + + + + + + ${rows} +
IDProviderCapacityPriceSLAStatus
+
+ `; +} + +function showToast(message: string, duration = 2500): void { + if (!selectors.toast) return; + selectors.toast.textContent = message; + selectors.toast.classList.add('visible'); + + window.setTimeout(() => { + selectors.toast?.classList.remove('visible'); + }, duration); +} + +async function loadDashboard(): Promise { + try { + const [stats, offers] = await Promise.all([ + fetchMarketplaceStats(), + fetchMarketplaceOffers(), + ]); + + renderStats(stats); + renderOffers(offers); + } catch (error) { + console.error(error); + if (selectors.offersWrapper) { + selectors.offersWrapper.innerHTML = '

Failed to load offers. Please retry shortly.

'; + } + showToast('Failed to load marketplace data.'); + } +} + +selectors.bidForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + + const formData = new FormData(selectors.bidForm!); + const provider = formData.get('provider')?.toString().trim(); + const capacity = Number(formData.get('capacity')); + const price = Number(formData.get('price')); + const notes = formData.get('notes')?.toString().trim(); + + if (!provider || Number.isNaN(capacity) || Number.isNaN(price)) { + showToast('Please complete the required fields.'); + return; + } + + try { + selectors.bidForm!.querySelector('button')!.setAttribute('disabled', 'disabled'); + await submitMarketplaceBid({ provider, capacity, price, notes }); + selectors.bidForm!.reset(); + showToast('Bid submitted successfully!'); + } catch (error) { + console.error(error); + showToast('Unable to submit bid. Please try again.'); + } finally { + selectors.bidForm!.querySelector('button')!.removeAttribute('disabled'); + } +}); + +loadDashboard(); diff --git a/apps/marketplace-web/src/style.css b/apps/marketplace-web/src/style.css new file mode 100644 index 0000000..10ade8a --- /dev/null +++ b/apps/marketplace-web/src/style.css @@ -0,0 +1,219 @@ +:root { + font-family: "Inter", system-ui, -apple-system, sans-serif; + color: #121212; + background-color: #f7f8fa; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-height: 100vh; + background: linear-gradient(180deg, #f7f8fa 0%, #eef1f6 100%); +} + +#app { + max-width: 1100px; + margin: 0 auto; + padding: 48px 24px 64px; +} + +.page-header { + margin-bottom: 32px; +} + +.page-header h1 { + font-size: 2.4rem; + margin: 0 0 0.5rem; + color: #1d2736; +} + +.page-header p { + margin: 0; + color: #5a6575; +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.stat-card { + background: #ffffff; + border-radius: 16px; + padding: 20px; + box-shadow: 0 12px 24px rgba(18, 24, 32, 0.08); +} + +.stat-card h2 { + margin: 0 0 12px; + font-size: 1rem; + color: #64748b; +} + +.stat-card strong { + font-size: 1.8rem; + color: #1d2736; +} + +.stat-card span { + display: block; + margin-top: 6px; + color: #8895a7; + font-size: 0.9rem; +} + +.panels { + display: grid; + gap: 24px; +} + +.panel { + background: #ffffff; + border-radius: 16px; + padding: 24px; + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08); +} + +.panel h2 { + margin: 0 0 16px; + font-size: 1.4rem; + color: #1d2736; +} + +.offers-table { + width: 100%; + border-collapse: collapse; +} + +.offers-table th, +.offers-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #e5e9f1; +} + +.offers-table th { + color: #64748b; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.offers-table tbody tr:hover { + background-color: rgba(99, 102, 241, 0.08); +} + +.table-responsive { + overflow-x: auto; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; +} + +.status-open { + background-color: rgba(34, 197, 94, 0.12); + color: #15803d; +} + +.status-reserved { + background-color: rgba(59, 130, 246, 0.12); + color: #1d4ed8; +} + +.bid-form { + display: grid; + gap: 16px; +} + +.bid-form label { + font-weight: 600; + color: #374151; + display: block; + margin-bottom: 6px; +} + +.bid-form input, +.bid-form select, +.bid-form textarea { + width: 100%; + border-radius: 10px; + border: 1px solid #d1d9e6; + padding: 10px 12px; + font-size: 1rem; + font-family: inherit; + background-color: #f9fbff; +} + +.bid-form button { + justify-self: flex-start; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: #ffffff; + border: none; + border-radius: 999px; + padding: 10px 20px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: transform 150ms ease, box-shadow 150ms ease; +} + +.bid-form button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 18px rgba(99, 102, 241, 0.3); +} + +.empty-state { + padding: 24px; + text-align: center; + color: #6b7280; + border: 1px dashed #cbd5f5; + border-radius: 12px; + background-color: rgba(99, 102, 241, 0.05); +} + +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 14px 18px; + background: #111827; + color: #ffffff; + border-radius: 12px; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.3); + opacity: 0; + transform: translateY(12px); + transition: opacity 200ms ease, transform 200ms ease; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} + +@media (max-width: 720px) { + #app { + padding: 32px 16px 48px; + } + + .dashboard-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } + + .offers-table th, + .offers-table td { + padding: 10px 12px; + font-size: 0.95rem; + } +} diff --git a/apps/marketplace-web/src/typescript.svg b/apps/marketplace-web/src/typescript.svg new file mode 100644 index 0000000..d91c910 --- /dev/null +++ b/apps/marketplace-web/src/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/marketplace-web/tsconfig.json b/apps/marketplace-web/tsconfig.json new file mode 100644 index 0000000..4ba8dd9 --- /dev/null +++ b/apps/marketplace-web/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/apps/wallet-daemon/src/app/api_rest.py b/apps/wallet-daemon/src/app/api_rest.py index ebf6e85..033761e 100644 --- a/apps/wallet-daemon/src/app/api_rest.py +++ b/apps/wallet-daemon/src/app/api_rest.py @@ -96,6 +96,7 @@ def list_wallets( WalletDescriptor(wallet_id=record.wallet_id, public_key=record.public_key, metadata=metadata) ) + return WalletListResponse(items=descriptors) @router.post("/wallets", response_model=WalletCreateResponse, status_code=status.HTTP_201_CREATED, summary="Create wallet") def create_wallet( @@ -119,7 +120,10 @@ def create_wallet( metadata=request.metadata, ) except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"reason": "password_too_weak", "min_length": 10, "message": str(exc)}, + ) from exc ledger.upsert_wallet(record.wallet_id, record.public_key, record.metadata) ledger.record_event(record.wallet_id, "created", {"metadata": record.metadata}) diff --git a/apps/wallet-daemon/tests/test_wallet_api.py b/apps/wallet-daemon/tests/test_wallet_api.py index b127ee7..b322f69 100644 --- a/apps/wallet-daemon/tests/test_wallet_api.py +++ b/apps/wallet-daemon/tests/test_wallet_api.py @@ -1,13 +1,15 @@ from __future__ import annotations import base64 +from pathlib import Path import pytest from fastapi.testclient import TestClient -from aitbc_chain.app import create_app # noqa: I100 - -from app.deps import get_keystore, get_ledger +from app.deps import get_keystore, get_ledger, get_settings +from app.main import create_app +from app.keystore.service import KeystoreService +from app.ledger_mock import SQLiteLedgerAdapter @pytest.fixture(name="client") @@ -15,12 +17,24 @@ def client_fixture(tmp_path, monkeypatch): # Override ledger path to temporary directory from app.settings import Settings - class TestSettings(Settings): - ledger_db_path = tmp_path / "ledger.db" + test_settings = Settings(LEDGER_DB_PATH=str(tmp_path / "ledger.db")) - monkeypatch.setattr("app.deps.get_settings", lambda: TestSettings()) + monkeypatch.setattr("app.settings.settings", test_settings) + + from app import deps + + deps.get_settings.cache_clear() + deps.get_keystore.cache_clear() + deps.get_ledger.cache_clear() app = create_app() + + keystore = KeystoreService() + ledger = SQLiteLedgerAdapter(Path(test_settings.ledger_db_path)) + + app.dependency_overrides[get_settings] = lambda: test_settings + app.dependency_overrides[get_keystore] = lambda: keystore + app.dependency_overrides[get_ledger] = lambda: ledger return TestClient(app) @@ -79,4 +93,6 @@ def test_wallet_password_rules(client: TestClient): json={"wallet_id": "weak", "password": "short"}, ) assert response.status_code == 400 -*** + body = response.json() + assert body["detail"]["reason"] == "password_too_weak" + assert "min_length" in body["detail"] diff --git a/docs/done.md b/docs/done.md index ba14336..9e939c2 100644 --- a/docs/done.md +++ b/docs/done.md @@ -26,6 +26,10 @@ - Implemented CLI/Python runners and execution pipeline with result reporting. - Added starter tests for runners in `apps/miner-node/tests/test_runners.py`. +## Blockchain Node + +- Added websocket fan-out, disconnect cleanup, and load-test coverage in `apps/blockchain-node/tests/test_websocket.py`, ensuring gossip topics deliver reliably to multiple subscribers. + ## Directory Preparation - Established scaffolds for Python and JavaScript packages in `packages/py/` and `packages/js/`. @@ -42,6 +46,7 @@ - Added `apps/wallet-daemon/src/app/receipts/service.py` providing `ReceiptVerifierService` that fetches and validates receipts via `aitbc_sdk`. - Created unit tests under `apps/wallet-daemon/tests/test_receipts.py` verifying service behavior. - Implemented wallet SDK receipt ingestion + attestation surfacing in `packages/py/aitbc-sdk/src/receipts.py`, including pagination client, signature verification, and failure diagnostics with full pytest coverage. +- Hardened REST API by wiring dependency overrides in `apps/wallet-daemon/tests/test_wallet_api.py`, expanding workflow coverage (create/list/unlock/sign) and enforcing structured password policy errors consumed in CI. ## Explorer Web diff --git a/docs/ports.md b/docs/ports.md new file mode 100644 index 0000000..07db4b1 --- /dev/null +++ b/docs/ports.md @@ -0,0 +1,26 @@ +# Port Allocation Plan + +This document tracks current and planned TCP port assignments across the AITBC devnet stack. Update it whenever new services are introduced or defaults change. + +## Current Usage + +| Port | Service | Location | Notes | +| --- | --- | --- | --- | +| 8080 | Blockchain RPC API (FastAPI) | `apps/blockchain-node/scripts/devnet_up.sh` → `python -m uvicorn aitbc_chain.app:app` | Exposes REST/WebSocket RPC endpoints for blocks, transactions, receipts. | +| 8090 | Mock Coordinator API | `apps/blockchain-node/scripts/devnet_up.sh` → `uvicorn mock_coordinator:app` | Generates synthetic coordinator/miner telemetry consumed by Grafana dashboards. | +| 9090 | Prometheus (planned default) | `apps/blockchain-node/observability/` (targets to be wired) | Scrapes blockchain node + mock coordinator metrics. Ensure firewall allows local-only access. | +| 3000 | Grafana (planned default) | `apps/blockchain-node/observability/grafana-dashboard.json` | Visualizes metrics dashboards; behind devnet Docker compose or local binary. | + +## Reserved / Planned Ports + +- **Coordinator API (production)** – TBD (`8000` suggested). Align with `apps/coordinator-api/README.md` once the service runs outside mock mode. +- **Marketplace Web** – Vite dev server defaults to `5173`; document overrides when deploying behind nginx. +- **Explorer Web** – Vite dev server defaults to `4173`; ensure it does not collide with other tooling on developer machines. +- **Pool Hub API** – Reserve `8100` for the FastAPI service when devnet integration begins. + +## Guidance + +- Avoid reusing the same port across services in devnet scripts to prevent binding conflicts (recent issues occurred when `8080`/`8090` were already in use). +- For production-grade environments, place HTTP services behind a reverse proxy (nginx/Traefik) and update this table with the external vs. internal port mapping. +- When adding new dashboards or exporters, note both the scrape port (Prometheus) and any UI port (Grafana/others). +- If a port is deprecated, strike it through in this table and add a note describing the migration path. diff --git a/docs/roadmap.md b/docs/roadmap.md index 429e42c..7ee37fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,27 @@ This roadmap aggregates high-priority tasks derived from the bootstrap specifications in `docs/bootstrap/` and tracks progress across the monorepo. Update this document as milestones evolve. -## Stage 1 — Core Services (MVP) +## Stage 1 — Upcoming Focus Areas + +- **Blockchain Node Foundations** + - ✅ Bootstrap module layout in `apps/blockchain-node/src/`. + - ✅ Implement SQLModel schemas and RPC stubs aligned with historical/attested receipts. + +- **Explorer Web Enablement** + - ✅ Finish mock integration across all pages and polish styling + mock/live toggle. + - ✅ Begin wiring coordinator endpoints (e.g., `/v1/jobs/{job_id}/receipts`). + +- **Marketplace Web Scaffolding** + - ✅ Scaffold Vite/vanilla frontends consuming coordinator receipt history endpoints and SDK examples. + +- **Pool Hub Services** + - ✅ Initialize FastAPI project, scoring registry, and telemetry ingestion hooks leveraging coordinator/miner metrics. + +- **CI Enhancements** + - ✅ Add blockchain-node tests once available and frontend build/lint checks to `.github/workflows/python-tests.yml` or follow-on workflows. + - ✅ Provide systemd unit + installer scripts under `scripts/` for streamlined deployment. + +## Stage 2 — Core Services (MVP) - **Coordinator API** - ✅ Scaffold FastAPI project (`apps/coordinator-api/src/app/`). @@ -23,10 +43,10 @@ This roadmap aggregates high-priority tasks derived from the bootstrap specifica - Introduced hex/enum validation hooks via Pydantic validators to ensure hash integrity and safe persistence. - ✅ Implement PoA proposer loop with block assembly (`apps/blockchain-node/src/aitbc_chain/consensus/poa.py`). - ✅ Expose REST RPC endpoints for tx submission, balances, receipts (`apps/blockchain-node/src/aitbc_chain/rpc/router.py`). - - ⏳ Deliver WebSocket RPC + P2P gossip layer: - - Stand up WebSocket subscription endpoints (`apps/blockchain-node/src/aitbc_chain/rpc/websocket.py`) mirroring REST payloads. - - Implement pub/sub transport for block + transaction gossip backed by an in-memory broker (Starlette `Broadcast` or Redis) with configurable fan-out. - - Add integration tests and load-test harness ensuring gossip convergence and back-pressure handling. + - ✅ Deliver WebSocket RPC + P2P gossip layer: + - ✅ Stand up WebSocket subscription endpoints (`apps/blockchain-node/src/aitbc_chain/rpc/websocket.py`) mirroring REST payloads. + - ✅ Implement pub/sub transport for block + transaction gossip backed by an in-memory broker (Starlette `Broadcast` or Redis) with configurable fan-out. + - ✅ Add integration tests and load-test harness ensuring gossip convergence and back-pressure handling. - ✅ Ship devnet scripts (`apps/blockchain-node/scripts/`). - ✅ Add observability hooks (JSON logging, Prometheus metrics) and integrate coordinator mock into devnet tooling. - ⏳ Expand observability dashboards + miner mock integration: @@ -46,21 +66,23 @@ This roadmap aggregates high-priority tasks derived from the bootstrap specifica - ✅ Provide REST and JSON-RPC endpoints for wallet management and signing (`api_rest.py`, `api_jsonrpc.py`). - ✅ Add mock ledger adapter with SQLite backend powering event history (`ledger_mock/`). - ✅ Integrate Python receipt verification helpers (`aitbc_sdk`) and expose API/service utilities validating miner + coordinator signatures. + - ✅ Harden REST API workflows (create/list/unlock/sign) with structured password policy enforcement and deterministic pytest coverage in `apps/wallet-daemon/tests/test_wallet_api.py`. - ✅ Implement Wallet SDK receipt ingestion + attestation surfacing: - Added `/v1/jobs/{job_id}/receipts` client helpers with cursor pagination, retry/backoff, and summary reporting (`packages/py/aitbc-sdk/src/receipts.py`). - Reused crypto helpers to validate miner and coordinator signatures, capturing per-key failure reasons for downstream UX. - Surfaced aggregated attestation status (`ReceiptStatus`) and failure diagnostics for SDK + UI consumers; JS helper parity still planned. -## Stage 2 — Pool Hub & Marketplace +## Stage 3 — Pool Hub & Marketplace - **Pool Hub** - ✅ Implement miner registry, scoring engine, and `/v1/match` API with Redis/PostgreSQL backing stores. - ✅ Add observability endpoints (`/v1/health`, `/v1/metrics`) plus Prometheus instrumentation and integration tests. - **Marketplace Web** - - Initialize Vite project with vanilla TypeScript. - - Build offer list, bid form, stats views sourcing mock JSON. - - Provide API abstraction to switch between mock and real backends. + - ✅ Initialize Vite project with vanilla TypeScript (`apps/marketplace-web/`). + - ✅ Build offer list, bid form, and stats cards powered by mock data fixtures (`public/mock/`). + - ✅ Provide API abstraction toggling mock/live mode (`src/lib/api.ts`) and wire coordinator endpoints. + - ⏳ Validate live mode against coordinator `/v1/marketplace/*` responses and add auth feature flags for rollout. - **Explorer Web** - ✅ Initialize Vite + TypeScript project scaffold (`apps/explorer-web/`). @@ -74,42 +96,130 @@ This roadmap aggregates high-priority tasks derived from the bootstrap specifica - Add fallbacks + error surfacing for partial/failed live responses (toast + console diagnostics). - Audit responsive breakpoints (`public/css/layout.css`) and adjust grid/typography for tablet + mobile; add regression checks in Percy/Playwright snapshots. +## Stage 4 — Observability & Production Polish + +- **Observability & Telemetry** + - ⏳ Build Grafana dashboards for PoA consensus health (block intervals, proposer rotation cadence) leveraging `poa_last_block_interval_seconds`, `poa_proposer_rotations_total`, and per-proposer counters. + - ⏳ Surface RPC latency histograms/summaries for critical endpoints (`rpc_get_head`, `rpc_send_tx`, `rpc_submit_receipt`) and add Grafana panels with SLO thresholds. + - ⏳ Ingest miner mock telemetry (job throughput, failure rate) into the shared Prometheus registry and wire panels/alerts that correlate miner health with consensus metrics. + +- **Explorer Web (Live Mode)** + - ⏳ Finalize live `getDataMode() === "live"` workflow: align API payload contracts, render loading/error states, and persist mock/live toggle preference. + - ⏳ Expand responsive testing (tablet/mobile) and add automated visual regression snapshots prior to launch. + - ⏳ Integrate Playwright smoke tests covering overview, blocks, and transactions pages in live mode. + +- **Marketplace Web (Launch Readiness)** + - ✅ Connect mock listings/bids to coordinator data sources and provide feature flags for live mode rollout. + - ✅ Implement auth/session scaffolding for marketplace actions and document API assumptions in `apps/marketplace-web/README.md`. + - ⏳ Add Grafana panels monitoring marketplace API throughput and error rates once endpoints are live. + +- **Operational Hardening** + - ⏳ Extend Alertmanager rules to cover RPC error spikes, proposer stalls, and miner disconnects using the new metrics. + - ⏳ Document dashboard import + alert deployment steps in `docs/run.md` for operators. + - ⏳ Prepare Stage 3 release checklist linking dashboards, alerts, and smoke tests prior to production cutover. + +## Stage 5 — Scaling & Release Readiness + +- **Infrastructure Scaling** + - ⏳ Benchmark blockchain node throughput under sustained load; capture CPU/memory targets and suggest horizontal scaling thresholds. + - ⏳ Build Terraform/Helm templates for dev/staging/prod environments, including Prometheus/Grafana bundles. + - ⏳ Implement autoscaling policies for coordinator, miners, and marketplace services with synthetic traffic tests. + +- **Reliability & Compliance** + - ⏳ Formalize backup/restore procedures for PostgreSQL, Redis, and ledger storage with scheduled jobs. + - ⏳ Complete security hardening review (TLS termination, API auth, secrets management) and document mitigations in `docs/security.md`. + - ⏳ Add chaos testing scripts (network partition, coordinator outage) and track mean-time-to-recovery metrics. + +- **Product Launch Checklist** + - ⏳ Finalize public documentation (API references, onboarding guides) and publish to the docs portal. + - ⏳ Coordinate beta release timeline, including user acceptance testing of explorer/marketplace live modes. + - ⏳ Establish post-launch monitoring playbooks and on-call rotations. + +## Stage 6 — Ecosystem Expansion + +- **Cross-Chain & Interop** + - ⏳ Prototype cross-chain settlement hooks leveraging external bridges; document integration patterns. + - ⏳ Extend SDKs (Python/JS) with pluggable transport abstractions for multi-network support. + - ⏳ Evaluate third-party explorer/analytics integrations and publish partner onboarding guides. + +- **Marketplace Growth** + - ⏳ Launch incentive programs (staking, liquidity mining) and expose telemetry dashboards tracking campaign performance. + - ⏳ Implement governance module (proposal voting, parameter changes) and add API/UX flows to explorer/marketplace. + - ⏳ Provide SLA-backed coordinator/pool hubs with capacity planning and billing instrumentation. + +- **Developer Experience** + - ⏳ Publish advanced tutorials (custom proposers, marketplace extensions) and maintain versioned API docs. + - ⏳ Integrate CI/CD pipelines with canary deployments and blue/green release automation. + - ⏳ Host quarterly architecture reviews capturing lessons learned and feeding into roadmap revisions. + +## Stage 7 — Innovation & Ecosystem Services + +- **Advanced Cryptography & Privacy** + - ⏳ Research zk-proof-based receipt attestation and prototype a privacy-preserving settlement flow. + - ⏳ Add confidential transaction support in coordinator/miner stack with opt-in ciphertext storage. + - ⏳ Publish threat modeling updates and share mitigations with ecosystem partners. + +- **Enterprise Integrations** + - ⏳ Deliver reference connectors for ERP/payment systems and document SLA expectations. + - ⏳ Stand up multi-tenant coordinator infrastructure with per-tenant isolation and billing metrics. + - ⏳ Launch ecosystem certification program (SDK conformance, security best practices) with public registry. + +- **Community & Governance** + - ⏳ Establish open RFC process, publish governance website, and schedule regular community calls. + - ⏳ Sponsor hackathons/accelerators and provide grants for marketplace extensions and analytics tooling. + - ⏳ Track ecosystem KPIs (active marketplaces, cross-chain volume) and feed them into quarterly strategy reviews. + +## Stage 8 — Frontier R&D & Global Expansion + +- **Protocol Evolution** + - ⏳ Launch research consortium exploring next-gen consensus (hybrid PoA/PoS) and finalize whitepapers. + - ⏳ Prototype sharding or rollup architectures to scale throughput beyond current limits. + - ⏳ Standardize interoperability specs with industry bodies and submit proposals for adoption. + +- **Global Rollout** + - ⏳ Establish regional infrastructure hubs (multi-cloud) with localized compliance and data residency guarantees. + - ⏳ Partner with regulators/enterprises to pilot regulated marketplaces and publish compliance playbooks. + - ⏳ Expand localization (UI, documentation, support) covering top target markets. + +- **Long-Term Sustainability** + - ⏳ Create sustainability fund for ecosystem maintenance, bug bounties, and community stewardship. + - ⏳ Define succession planning for core teams, including training programs and contributor pathways. + - ⏳ Publish bi-annual roadmap retrospectives assessing KPI alignment and revising long-term goals. + +## Stage 9 — Moonshot Initiatives + +- **Decentralized Infrastructure** + - ⏳ Transition coordinator/miner roles toward community-governed validator sets with incentive alignment. + - ⏳ Explore decentralized storage/backbone options (IPFS/Filecoin) for ledger and marketplace artifacts. + - ⏳ Prototype fully trustless marketplace settlement leveraging zero-knowledge rollups. + +- **AI & Automation** + - ⏳ Integrate AI-driven monitoring/anomaly detection for proposer health, market liquidity, and fraud detection. + - ⏳ Automate incident response playbooks with ChatOps and policy engines. + - ⏳ Launch research into autonomous agent participation (AI agents bidding/offering in the marketplace) and governance implications. +- **Global Standards Leadership** + - ⏳ chair industry working groups defining receipt/marketplace interoperability standards. + - ⏳ Publish annual transparency reports and sustainability metrics for stakeholders. + - ⏳ Engage with academia and open-source foundations to steward long-term protocol evolution. + +### Stage 10 — Stewardship & Legacy Planning + +- **Open Governance Maturity** + - ⏳ Transition roadmap ownership to community-elected councils with transparent voting and treasury controls. + - ⏳ Codify constitutional documents (mission, values, conflict resolution) and publish public charters. + - ⏳ Implement on-chain governance modules for protocol upgrades and ecosystem-wide decisions. + +- **Educational & Outreach Programs** + - ⏳ Fund university partnerships, research chairs, and developer fellowships focused on decentralized marketplace tech. + - ⏳ Create certification tracks and mentorship programs for new validator/operators. + - ⏳ Launch annual global summit and publish proceedings to share best practices across partners. + +- **Long-Term Preservation** + - ⏳ Archive protocol specs, governance records, and cultural artifacts in decentralized storage with redundancy. + - ⏳ Establish legal/organizational frameworks to ensure continuity across jurisdictions. + - ⏳ Develop end-of-life/transition plans for legacy components, documenting deprecation strategies and migration tooling. + + ## Shared Libraries & Examples +the canonical checklist during implementation. Mark completed tasks with ✅ and add dates or links to relevant PRs as development progresses. -- **Python SDK (`packages/py/aitbc-sdk`)** - - ✅ Implement coordinator receipt client + verification helpers (miner + coordinator attestation support). - - ⏳ Extend helpers to pool hub + wallet APIs and typed models: - - Add REST clients for upcoming Pool Hub endpoints (`/v1/match`, `/v1/miners`) and wallet daemon routes (`/v1/wallets`, `/v1/sign`) with retry/backoff helpers. - - Introduce pydantic/SQLModel-derived typed models mirroring `protocols/api/` and `protocols/receipts/` schemas. - - Provide end-to-end tests + examples validating Pool Hub + wallet flows leveraging the coordinator receipt verification primitives. - -- **JavaScript SDK (`packages/js/aitbc-sdk`)** - - ✅ Provide fetch-based wrapper for web clients with TypeScript definitions and basic auth helpers. - -- **Examples** - - Populate quickstart clients (Python/JS) with working code. - - Add receipt sign/verify samples using finalized schema. - -## Tooling & Operations - -- **Scripts & CI** - - ✅ Populate `scripts/ci/run_python_tests.sh` to run coordinator, SDK, wallet-daemon pytest suites with shared `PYTHONPATH` scaffolding. - - ✅ Add GitHub Actions workflow `.github/workflows/python-tests.yml` invoking the shared script on pushes/PRs targeting `main`. - -- **Configs** - - Author systemd unit files in `configs/systemd/` for each service. - - Provide Nginx snippets in `configs/nginx/` for reverse proxies. - -## Tracking - -Use this roadmap as the canonical checklist during implementation. Mark completed tasks with ✅ and add dates or links to relevant PRs as development progresses. - -## Upcoming Focus Areas - -- **Blockchain Node**: bootstrap module layout (`apps/blockchain-node/src/`), implement SQLModel schemas and RPC stubs aligned with historical/attested receipts. -- **Explorer Web**: finish mock integration across all pages, add styling + mock/live toggle, and begin wiring coordinator endpoints (e.g., `/v1/jobs/{job_id}/receipts`). - - Current focus: reuse new overview metrics scaffolding for blocks/transactions detail views and expand coverage to live data mode. -- **Marketplace Web**: scaffold Vite/vanilla frontends with mock integrations consuming the coordinator receipt history endpoints and SDK examples. -- **Pool Hub**: initialize FastAPI project, scoring registry, and telemetry ingestion hooks leveraging coordinator/miner metrics. -- **CI Enhancements**: add blockchain-node tests once available and frontend build/lint checks to `.github/workflows/python-tests.yml` or follow-on workflows. - - ⏳ Add systemd unit and installer scripts under `scripts/`. diff --git a/docs/run.md b/docs/run.md index 84293ed..66e8e81 100644 --- a/docs/run.md +++ b/docs/run.md @@ -233,6 +233,44 @@ These instructions cover the newly scaffolded services. Install dependencies usi ``` (RPC, consensus, and P2P logic still to be implemented.) +### Observability Dashboards & Alerts + +1. Generate the starter Grafana dashboards (if not already present): + ```bash + cd apps/blockchain-node + PYTHONPATH=src python - <<'PY' +from pathlib import Path +from aitbc_chain.observability.dashboards import generate_default_dashboards + +output_dir = Path("observability/generated_dashboards") +output_dir.mkdir(parents=True, exist_ok=True) +generate_default_dashboards(output_dir) +print("Dashboards written to", output_dir) +PY + ``` +2. Import each JSON file into Grafana (**Dashboards → Import**): + - `apps/blockchain-node/observability/generated_dashboards/coordinator-overview.json` + - `apps/blockchain-node/observability/generated_dashboards/blockchain-node-overview.json` + + Select your Prometheus datasource (pointing at `127.0.0.1:8080` and `127.0.0.1:8090`) during import. +3. Ensure Prometheus scrapes both services. Example snippet from `apps/blockchain-node/observability/prometheus.yml`: + ```yaml + scrape_configs: + - job_name: "blockchain-node" + static_configs: + - targets: ["127.0.0.1:8080"] + + - job_name: "mock-coordinator" + static_configs: + - targets: ["127.0.0.1:8090"] + ``` +4. Deploy the Alertmanager rules in `apps/blockchain-node/observability/alerts.yml` (proposer stalls, miner errors, receipt drop-offs, RPC error spikes). After modifying rule files, reload Prometheus/Alertmanager: + ```bash + systemctl restart prometheus + systemctl restart alertmanager + ``` +5. Validate by briefly stopping `aitbc-coordinator.service`, confirming Grafana panels pause and the new alerts fire, then restart the service. + ## Next Steps - Flesh out remaining logic per task breakdowns in `docs/*.md` (e.g., capability-aware scheduling, artifact uploads). diff --git a/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/PKG-INFO b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/PKG-INFO new file mode 100644 index 0000000..5410ba2 --- /dev/null +++ b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/PKG-INFO @@ -0,0 +1,7 @@ +Metadata-Version: 2.4 +Name: aitbc-crypto +Version: 0.1.0 +Summary: AITBC cryptographic utilities +Requires-Python: >=3.11 +Requires-Dist: pydantic>=2.7.0 +Requires-Dist: pynacl>=1.5.0 diff --git a/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/SOURCES.txt b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/SOURCES.txt new file mode 100644 index 0000000..b06b500 --- /dev/null +++ b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +pyproject.toml +src/__init__.py +src/receipt.py +src/signing.py +src/aitbc_crypto/__init__.py +src/aitbc_crypto/receipt.py +src/aitbc_crypto/signing.py +src/aitbc_crypto.egg-info/PKG-INFO +src/aitbc_crypto.egg-info/SOURCES.txt +src/aitbc_crypto.egg-info/dependency_links.txt +src/aitbc_crypto.egg-info/requires.txt +src/aitbc_crypto.egg-info/top_level.txt +tests/test_receipt_signing.py \ No newline at end of file diff --git a/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/dependency_links.txt b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/requires.txt b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/requires.txt new file mode 100644 index 0000000..a7d0f7c --- /dev/null +++ b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/requires.txt @@ -0,0 +1,2 @@ +pydantic>=2.7.0 +pynacl>=1.5.0 diff --git a/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/top_level.txt b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/top_level.txt new file mode 100644 index 0000000..138ebe7 --- /dev/null +++ b/packages/py/aitbc-crypto/src/aitbc_crypto.egg-info/top_level.txt @@ -0,0 +1,4 @@ +__init__ +aitbc_crypto +receipt +signing diff --git a/scripts/ci/run_python_tests.sh b/scripts/ci/run_python_tests.sh index 151f570..4d3410b 100755 --- a/scripts/ci/run_python_tests.sh +++ b/scripts/ci/run_python_tests.sh @@ -27,4 +27,5 @@ run_pytest() { run_pytest "${PROJECT_ROOT}/apps/coordinator-api/src:${PKG_PATHS}" apps/coordinator-api/tests -q run_pytest "${PKG_PATHS}" packages/py/aitbc-sdk/tests -q run_pytest "${PROJECT_ROOT}/apps/miner-node/src:${PKG_PATHS}" apps/miner-node/tests -q -run_pytest "${PROJECT_ROOT}/apps/wallet-daemon/src:${PKG_PATHS}" apps/wallet-daemon/tests -q +run_pytest "${PROJECT_ROOT}/apps/wallet-daemon/src:${PROJECT_ROOT}/apps/blockchain-node/src:${PKG_PATHS}" apps/wallet-daemon/tests -q +run_pytest "${PROJECT_ROOT}/apps/blockchain-node/src:${PKG_PATHS}" apps/blockchain-node/tests/test_websocket.py -q