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
This commit is contained in:
3
.github/workflows/python-tests.yml
vendored
3
.github/workflows/python-tests.yml
vendored
@ -32,3 +32,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
chmod +x scripts/ci/run_python_tests.sh
|
chmod +x scripts/ci/run_python_tests.sh
|
||||||
./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
|
||||||
|
|||||||
6
.windsurf/workflows/git.md
Normal file
6
.windsurf/workflows/git.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
description: do the git thing
|
||||||
|
auto_execution_mode: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
do the git thing
|
||||||
@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
description: docs/roadmap.md
|
|
||||||
auto_execution_mode: 3
|
|
||||||
---
|
|
||||||
|
|
||||||
Check docs/roadmap.md and carry out the next recommended step.
|
|
||||||
7
.windsurf/workflows/runscriptdebug.md
Normal file
7
.windsurf/workflows/runscriptdebug.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: run script and debug
|
||||||
|
auto_execution_mode: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
run script and debug
|
||||||
|
rerun script
|
||||||
6
.windsurf/workflows/stillsameissue.md
Normal file
6
.windsurf/workflows/stillsameissue.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
description: still same issue
|
||||||
|
auto_execution_mode: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
still same issue
|
||||||
23
apps/blockchain-node/data/devnet/genesis.json
Normal file
23
apps/blockchain-node/data/devnet/genesis.json
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ This directory contains Prometheus and Grafana assets for the devnet environment
|
|||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `prometheus.yml` – Scrapes both blockchain node and mock coordinator/miner metrics.
|
- `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.
|
- `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.
|
- `gossip-recording-rules.yml` – Prometheus recording rules that derive queue/subscriber gauges and publication rates from gossip metrics.
|
||||||
|
|
||||||
|
|||||||
@ -41,3 +41,13 @@ groups:
|
|||||||
summary: "No receipts attested in 5 minutes"
|
summary: "No receipts attested in 5 minutes"
|
||||||
description: |
|
description: |
|
||||||
Receipt attestations ceased during the last five minutes. Inspect coordinator connectivity.
|
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.
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -352,6 +352,80 @@
|
|||||||
],
|
],
|
||||||
"title": "Gossip Publication Rate by Topic",
|
"title": "Gossip Publication Rate by Topic",
|
||||||
"type": "timeseries"
|
"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",
|
"refresh": "10s",
|
||||||
|
|||||||
0
apps/blockchain-node/scripts/devnet_up.sh
Normal file → Executable file
0
apps/blockchain-node/scripts/devnet_up.sh
Normal file → Executable file
@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import random
|
import random
|
||||||
import time
|
from collections import deque
|
||||||
from typing import Dict
|
from typing import Deque, Dict, List
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import PlainTextResponse
|
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")
|
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]] = {
|
MOCK_JOBS: Dict[str, Dict[str, str]] = {
|
||||||
"job_1": {"status": "complete", "price": "50000", "compute_units": 2500},
|
"job_1": {"status": "complete", "price": "50000", "compute_units": 2500},
|
||||||
"job_2": {"status": "complete", "price": "25000", "compute_units": 1200},
|
"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:
|
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.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")
|
@app.on_event("startup")
|
||||||
async def _startup() -> None:
|
async def _startup() -> None:
|
||||||
|
global _simulation_task
|
||||||
_simulate_miner_metrics()
|
_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")
|
@app.get("/health")
|
||||||
|
|||||||
@ -4,12 +4,21 @@ import asyncio
|
|||||||
import hashlib
|
import hashlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import re
|
||||||
from typing import Callable, ContextManager, Optional
|
from typing import Callable, ContextManager, Optional
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from ..logging import get_logger
|
from ..logging import get_logger
|
||||||
from ..metrics import metrics_registry
|
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 ..models import Block
|
||||||
from ..gossip import gossip_broker
|
from ..gossip import gossip_broker
|
||||||
|
|
||||||
@ -33,6 +42,7 @@ class PoAProposer:
|
|||||||
self._logger = get_logger(__name__)
|
self._logger = get_logger(__name__)
|
||||||
self._stop_event = asyncio.Event()
|
self._stop_event = asyncio.Event()
|
||||||
self._task: Optional[asyncio.Task[None]] = None
|
self._task: Optional[asyncio.Task[None]] = None
|
||||||
|
self._last_proposer_id: Optional[str] = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self._task is not None:
|
if self._task is not None:
|
||||||
@ -104,6 +114,13 @@ class PoAProposer:
|
|||||||
metrics_registry.set_gauge("chain_head_height", float(next_height))
|
metrics_registry.set_gauge("chain_head_height", float(next_height))
|
||||||
if interval_seconds is not None and interval_seconds >= 0:
|
if interval_seconds is not None and interval_seconds >= 0:
|
||||||
metrics_registry.observe("block_interval_seconds", interval_seconds)
|
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(
|
asyncio.create_task(
|
||||||
gossip_broker.publish(
|
gossip_broker.publish(
|
||||||
|
|||||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from sqlalchemy import Column
|
from sqlalchemy import Column
|
||||||
@ -34,8 +34,8 @@ class Block(SQLModel, table=True):
|
|||||||
tx_count: int = 0
|
tx_count: int = 0
|
||||||
state_root: Optional[str] = None
|
state_root: Optional[str] = None
|
||||||
|
|
||||||
transactions: List["Transaction"] = Relationship(back_populates="block")
|
transactions: list["Transaction"] = Relationship(back_populates="block")
|
||||||
receipts: List["Receipt"] = Relationship(back_populates="block")
|
receipts: list["Receipt"] = Relationship(back_populates="block")
|
||||||
|
|
||||||
@field_validator("hash", mode="before")
|
@field_validator("hash", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -69,7 +69,7 @@ class Transaction(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow, index=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")
|
@field_validator("tx_hash", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -101,7 +101,7 @@ class Receipt(SQLModel, table=True):
|
|||||||
minted_amount: Optional[int] = None
|
minted_amount: Optional[int] = None
|
||||||
recorded_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
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")
|
@field_validator("receipt_id", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -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",
|
||||||
|
]
|
||||||
267
apps/blockchain-node/src/aitbc_chain/observability/dashboards.py
Normal file
267
apps/blockchain-node/src/aitbc_chain/observability/dashboards.py
Normal file
@ -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)
|
||||||
@ -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)
|
||||||
@ -1,8 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlmodel import SQLModel, Session, create_engine
|
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
|
from aitbc_chain.models import Block, Transaction, Receipt # noqa: F401 - ensure models imported for metadata
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
39
apps/blockchain-node/tests/test_observability_dashboards.py
Normal file
39
apps/blockchain-node/tests/test_observability_dashboards.py
Normal file
@ -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"]
|
||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import ExitStack
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@ -10,8 +11,6 @@ from aitbc_chain.gossip import gossip_broker
|
|||||||
|
|
||||||
def _publish(topic: str, message: dict) -> None:
|
def _publish(topic: str, message: dict) -> None:
|
||||||
asyncio.run(gossip_broker.publish(topic, message))
|
asyncio.run(gossip_broker.publish(topic, message))
|
||||||
|
|
||||||
|
|
||||||
def test_blocks_websocket_stream() -> None:
|
def test_blocks_websocket_stream() -> None:
|
||||||
client = TestClient(create_app())
|
client = TestClient(create_app())
|
||||||
|
|
||||||
@ -28,19 +27,100 @@ def test_blocks_websocket_stream() -> None:
|
|||||||
assert message == payload
|
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())
|
client = TestClient(create_app())
|
||||||
|
|
||||||
with client.websocket_connect("/rpc/ws/transactions") as websocket:
|
with client.websocket_connect("/rpc/ws/transactions") as websocket:
|
||||||
payload = {
|
payload = {
|
||||||
"tx_hash": "0x" + "a" * 64,
|
"tx_hash": "0x" + "b" * 64,
|
||||||
"sender": "alice",
|
"sender": "alice",
|
||||||
"recipient": "bob",
|
"recipient": "carol",
|
||||||
"payload": {"amount": 1},
|
"payload": {"amount": 2},
|
||||||
"nonce": 1,
|
"nonce": 7,
|
||||||
"fee": 0,
|
"fee": 1,
|
||||||
"type": "TRANSFER",
|
"type": "TRANSFER",
|
||||||
}
|
}
|
||||||
_publish("transactions", payload)
|
_publish("transactions", payload)
|
||||||
message = websocket.receive_json()
|
assert websocket.receive_json() == payload
|
||||||
assert message == 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@ -4,6 +4,27 @@
|
|||||||
|
|
||||||
FastAPI service that accepts client compute jobs, matches miners, and tracks job lifecycle for the AITBC network.
|
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
|
## Development Setup
|
||||||
|
|
||||||
1. Create a virtual environment in `apps/coordinator-api/.venv`.
|
1. Create a virtual environment in `apps/coordinator-api/.venv`.
|
||||||
|
|||||||
@ -3,5 +3,13 @@
|
|||||||
from .job import Job
|
from .job import Job
|
||||||
from .miner import Miner
|
from .miner import Miner
|
||||||
from .job_receipt import JobReceipt
|
from .job_receipt import JobReceipt
|
||||||
|
from .marketplace import MarketplaceOffer, MarketplaceBid, OfferStatus
|
||||||
|
|
||||||
__all__ = ["Job", "Miner", "JobReceipt"]
|
__all__ = [
|
||||||
|
"Job",
|
||||||
|
"Miner",
|
||||||
|
"JobReceipt",
|
||||||
|
"MarketplaceOffer",
|
||||||
|
"MarketplaceBid",
|
||||||
|
"OfferStatus",
|
||||||
|
]
|
||||||
|
|||||||
36
apps/coordinator-api/src/app/domain/marketplace.py
Normal file
36
apps/coordinator-api/src/app/domain/marketplace.py
Normal file
@ -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)
|
||||||
@ -2,7 +2,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .routers import client, miner, admin
|
from .routers import client, miner, admin, marketplace, explorer
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@ -20,9 +20,11 @@ def create_app() -> FastAPI:
|
|||||||
allow_headers=["*"]
|
allow_headers=["*"]
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(client.router, prefix="/v1")
|
app.include_router(client, prefix="/v1")
|
||||||
app.include_router(miner.router, prefix="/v1")
|
app.include_router(miner, prefix="/v1")
|
||||||
app.include_router(admin.router, 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")
|
@app.get("/v1/health", tags=["health"], summary="Service healthcheck")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
class JobState(str, Enum):
|
class JobState(str, Enum):
|
||||||
@ -76,3 +76,97 @@ class JobFailSubmit(BaseModel):
|
|||||||
error_code: str
|
error_code: str
|
||||||
error_message: str
|
error_message: str
|
||||||
metrics: Dict[str, Any] = Field(default_factory=dict)
|
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]
|
||||||
|
|||||||
@ -1 +1,9 @@
|
|||||||
"""Router modules for the coordinator API."""
|
"""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"]
|
||||||
|
|||||||
63
apps/coordinator-api/src/app/routers/explorer.py
Normal file
63
apps/coordinator-api/src/app/routers/explorer.py
Normal file
@ -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)
|
||||||
57
apps/coordinator-api/src/app/routers/marketplace.py
Normal file
57
apps/coordinator-api/src/app/routers/marketplace.py
Normal file
@ -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}
|
||||||
@ -2,5 +2,7 @@
|
|||||||
|
|
||||||
from .jobs import JobService
|
from .jobs import JobService
|
||||||
from .miners import MinerService
|
from .miners import MinerService
|
||||||
|
from .marketplace import MarketplaceService
|
||||||
|
from .explorer import ExplorerService
|
||||||
|
|
||||||
__all__ = ["JobService", "MinerService"]
|
__all__ = ["JobService", "MinerService", "MarketplaceService", "ExplorerService"]
|
||||||
|
|||||||
182
apps/coordinator-api/src/app/services/explorer.py
Normal file
182
apps/coordinator-api/src/app/services/explorer.py
Normal file
@ -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)
|
||||||
83
apps/coordinator-api/src/app/services/marketplace.py
Normal file
83
apps/coordinator-api/src/app/services/marketplace.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
@ -8,7 +8,7 @@ from sqlalchemy.engine import Engine
|
|||||||
from sqlmodel import Session, SQLModel, create_engine
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..domain import Job, Miner
|
from ..domain import Job, Miner, MarketplaceOffer, MarketplaceBid
|
||||||
|
|
||||||
_engine: Engine | None = None
|
_engine: Engine | None = None
|
||||||
|
|
||||||
|
|||||||
113
apps/coordinator-api/tests/test_marketplace.py
Normal file
113
apps/coordinator-api/tests/test_marketplace.py
Normal file
@ -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"}
|
||||||
@ -6,7 +6,6 @@ Static web explorer for the AITBC blockchain node, displaying blocks, transactio
|
|||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
- Install dependencies:
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
@ -14,145 +13,33 @@ Static web explorer for the AITBC blockchain node, displaying blocks, transactio
|
|||||||
```bash
|
```bash
|
||||||
npm run dev
|
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.
|
- Configuration lives in `src/config.ts` and can be overridden with environment variables.
|
||||||
- Use `VITE_DATA_MODE` to choose between `mock` (default) and `live`.
|
- 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:
|
- Example `.env` snippet:
|
||||||
```bash
|
```bash
|
||||||
VITE_DATA_MODE=live
|
VITE_DATA_MODE=live
|
||||||
VITE_COORDINATOR_API=https://coordinator.dev.internal
|
VITE_COORDINATOR_API=https://coordinator.dev.internal
|
||||||
```
|
```
|
||||||
With live mode enabled, the SPA will request `/v1/<resource>` routes from the coordinator instead of the bundled mock JSON.
|
|
||||||
|
|
||||||
## Next Steps
|
## Feature Flags & Auth
|
||||||
|
|
||||||
- Build out responsive styling and navigation interactions.
|
- Document any backend expectations (e.g., coordinator accepting bearer tokens) alongside the environment variables in deployment manifests.
|
||||||
- Extend the data layer to support coordinator authentication and pagination when live endpoints are ready.
|
|
||||||
- Document coordinator API assumptions once the backend contracts stabilize.
|
|
||||||
|
|
||||||
## Coordinator API Contracts (Draft)
|
## End-to-End Tests
|
||||||
|
|
||||||
- **Blocks** (`GET /v1/blocks?limit=&offset=`)
|
- Install browsers after `npm install` by running `npx playwright install`.
|
||||||
- Expected payload:
|
- Launch the dev server (or point `EXPLORER_BASE_URL` at an already running instance) and run:
|
||||||
```json
|
```bash
|
||||||
{
|
npm run test:e2e
|
||||||
"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.
|
- Tests automatically persist live mode and stub coordinator responses to verify overview, blocks, and transactions views.
|
||||||
|
|
||||||
- **Transactions** (`GET /v1/transactions?limit=&offset=`)
|
## Playwright
|
||||||
- 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.
|
|
||||||
|
|
||||||
- **Addresses** (`GET /v1/addresses/{address}`)
|
- Run `npm run test:e2e` to execute the end-to-end tests.
|
||||||
- Expected payload:
|
- The tests will automatically persist live mode and stub coordinator responses to verify overview, blocks, and transactions views.
|
||||||
```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.
|
|
||||||
|
|||||||
@ -5,10 +5,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0",
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
23
apps/explorer-web/playwright.config.ts
Normal file
23
apps/explorer-web/playwright.config.ts
Normal file
@ -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"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@ -29,12 +29,52 @@
|
|||||||
flex: 1 1 45%;
|
flex: 1 1 45%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addresses__input-group,
|
.addresses__input-group,
|
||||||
.receipts__input-group {
|
.receipts__input-group {
|
||||||
flex-direction: column;
|
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 {
|
.toast-container {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@ -169,6 +209,18 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
text-align: center;
|
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 {
|
.section-header {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
BlockListResponse,
|
BlockListResponse,
|
||||||
TransactionListResponse,
|
TransactionListResponse,
|
||||||
AddressDetailResponse,
|
AddressDetailResponse,
|
||||||
|
AddressListResponse,
|
||||||
ReceiptListResponse,
|
ReceiptListResponse,
|
||||||
BlockSummary,
|
BlockSummary,
|
||||||
TransactionSummary,
|
TransactionSummary,
|
||||||
@ -11,7 +12,33 @@ import type {
|
|||||||
ReceiptSummary,
|
ReceiptSummary,
|
||||||
} from "./models.ts";
|
} 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 {
|
export function getDataMode(): DataMode {
|
||||||
return currentMode;
|
return currentMode;
|
||||||
@ -19,6 +46,14 @@ export function getDataMode(): DataMode {
|
|||||||
|
|
||||||
export function setDataMode(mode: DataMode): void {
|
export function setDataMode(mode: DataMode): void {
|
||||||
currentMode = mode;
|
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<BlockSummary[]> {
|
export async function fetchBlocks(): Promise<BlockSummary[]> {
|
||||||
@ -28,15 +63,15 @@ export async function fetchBlocks(): Promise<BlockSummary[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/blocks`);
|
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/blocks`);
|
||||||
if (!response.ok) {
|
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;
|
const data = (await response.json()) as BlockListResponse;
|
||||||
return data.items;
|
return data.items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Explorer] Failed to fetch live block data", error);
|
console.error("[Explorer] Failed to fetch live block data", error);
|
||||||
notifyError("Unable to load live block data. Displaying placeholders.");
|
notifyError("Unable to load live block data from coordinator. Showing placeholders.");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -48,15 +83,15 @@ export async function fetchTransactions(): Promise<TransactionSummary[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/transactions`);
|
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/transactions`);
|
||||||
if (!response.ok) {
|
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;
|
const data = (await response.json()) as TransactionListResponse;
|
||||||
return data.items;
|
return data.items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Explorer] Failed to fetch live transaction data", error);
|
console.error("[Explorer] Failed to fetch live transaction data", error);
|
||||||
notifyError("Unable to load live transaction data. Displaying placeholders.");
|
notifyError("Unable to load transactions from coordinator. Showing placeholders.");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,15 +103,15 @@ export async function fetchAddresses(): Promise<AddressSummary[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/addresses`);
|
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/addresses`);
|
||||||
if (!response.ok) {
|
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[];
|
const data = (await response.json()) as AddressListResponse;
|
||||||
return Array.isArray(data) ? data : data.items;
|
return data.items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Explorer] Failed to fetch live address data", error);
|
console.error("[Explorer] Failed to fetch live address data", error);
|
||||||
notifyError("Unable to load live address data. Displaying placeholders.");
|
notifyError("Unable to load address summaries from coordinator. Showing placeholders.");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,15 +123,15 @@ export async function fetchReceipts(): Promise<ReceiptSummary[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/receipts`);
|
const response = await fetch(`${CONFIG.apiBaseUrl}/v1/explorer/receipts`);
|
||||||
if (!response.ok) {
|
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;
|
const data = (await response.json()) as ReceiptListResponse;
|
||||||
return data.items;
|
return data.items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Explorer] Failed to fetch live receipt data", error);
|
console.error("[Explorer] Failed to fetch live receipt data", error);
|
||||||
notifyError("Unable to load live receipt data. Displaying placeholders.");
|
notifyError("Unable to load receipts from coordinator. Showing placeholders.");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
apps/explorer-web/test-results/.last-run.json
Normal file
8
apps/explorer-web/test-results/.last-run.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"78c83d26793c923d7fe5-1c82705bd81364a8b68d",
|
||||||
|
"78c83d26793c923d7fe5-d8983ad99256a494df4f",
|
||||||
|
"78c83d26793c923d7fe5-a5eb02c7b1bcc34f643e"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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]
|
||||||
|
```
|
||||||
@ -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]
|
||||||
|
```
|
||||||
@ -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]
|
||||||
|
```
|
||||||
111
apps/explorer-web/tests/e2e/explorer-live.spec.ts
Normal file
111
apps/explorer-web/tests/e2e/explorer-live.spec.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/marketplace-web/.gitignore
vendored
Normal file
24
apps/marketplace-web/.gitignore
vendored
Normal file
@ -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?
|
||||||
@ -1,15 +1,41 @@
|
|||||||
# Marketplace Web
|
# 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.
|
The dev server listens on `http://localhost:5173/` by default. Adjust via `--host`/`--port` flags in the `systemd` unit or `package.json` script.
|
||||||
- Run the dev server via `npm run dev`.
|
|
||||||
- Build for production with `npm run build` and preview using `npm run preview`.
|
|
||||||
|
|
||||||
## 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`.
|
||||||
|
|||||||
13
apps/marketplace-web/index.html
Normal file
13
apps/marketplace-web/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>marketplace-web</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
apps/marketplace-web/package.json
Normal file
15
apps/marketplace-web/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/marketplace-web/public/mock/offers.json
Normal file
36
apps/marketplace-web/public/mock/offers.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
apps/marketplace-web/public/mock/stats.json
Normal file
6
apps/marketplace-web/public/mock/stats.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"totalOffers": 78,
|
||||||
|
"openCapacity": 1120,
|
||||||
|
"averagePrice": 14.3,
|
||||||
|
"activeBids": 36
|
||||||
|
}
|
||||||
1
apps/marketplace-web/public/vite.svg
Normal file
1
apps/marketplace-web/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
9
apps/marketplace-web/src/counter.ts
Normal file
9
apps/marketplace-web/src/counter.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
118
apps/marketplace-web/src/lib/api.ts
Normal file
118
apps/marketplace-web/src/lib/api.ts
Normal file
@ -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<string, string> = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = loadSession();
|
||||||
|
if (session) {
|
||||||
|
headers.Authorization = `Bearer ${session.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMarketplaceStats(): Promise<MarketplaceStats> {
|
||||||
|
if (CONFIG.dataMode === "mock") {
|
||||||
|
return request<MarketplaceStats>(`${CONFIG.mockBase}/stats.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<MarketplaceStats>(`${CONFIG.apiBase}/v1/marketplace/stats`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMarketplaceOffers(): Promise<MarketplaceOffer[]> {
|
||||||
|
if (CONFIG.dataMode === "mock") {
|
||||||
|
const payload = await request<OffersResponse>(`${CONFIG.mockBase}/offers.json`);
|
||||||
|
return payload.offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<MarketplaceOffer[]>(`${CONFIG.apiBase}/v1/marketplace/offers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitMarketplaceBid(input: {
|
||||||
|
provider: string;
|
||||||
|
capacity: number;
|
||||||
|
price: number;
|
||||||
|
notes?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
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;
|
||||||
33
apps/marketplace-web/src/lib/auth.ts
Normal file
33
apps/marketplace-web/src/lib/auth.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
216
apps/marketplace-web/src/main.ts
Normal file
216
apps/marketplace-web/src/main.ts
Normal file
@ -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<HTMLDivElement>('#app');
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('Unable to mount marketplace app');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<main>
|
||||||
|
<header class="page-header">
|
||||||
|
<p>Data mode: <strong>${MARKETPLACE_CONFIG.dataMode.toUpperCase()}</strong></p>
|
||||||
|
<h1>Marketplace Control Center</h1>
|
||||||
|
<p>Monitor available offers, submit bids, and review marketplace health at a glance.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="dashboard-grid" id="stats-panel">
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Total Offers</h2>
|
||||||
|
<strong id="stat-total-offers">--</strong>
|
||||||
|
<span>Listings currently visible</span>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Open Capacity</h2>
|
||||||
|
<strong id="stat-open-capacity">--</strong>
|
||||||
|
<span>GPU / compute units available</span>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Average Price</h2>
|
||||||
|
<strong id="stat-average-price">--</strong>
|
||||||
|
<span>Credits per unit per hour</span>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Active Bids</h2>
|
||||||
|
<strong id="stat-active-bids">--</strong>
|
||||||
|
<span>Open bids awaiting match</span>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panels">
|
||||||
|
<article class="panel" id="offers-panel">
|
||||||
|
<h2>Available Offers</h2>
|
||||||
|
<div id="offers-table-wrapper" class="table-wrapper">
|
||||||
|
<p class="empty-state">Fetching marketplace offers…</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<h2>Submit a Bid</h2>
|
||||||
|
<form class="bid-form" id="bid-form">
|
||||||
|
<div>
|
||||||
|
<label for="bid-provider">Preferred provider</label>
|
||||||
|
<input id="bid-provider" name="provider" placeholder="Alpha Pool" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bid-capacity">Capacity required (units)</label>
|
||||||
|
<input id="bid-capacity" name="capacity" type="number" min="1" step="1" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bid-price">Bid price (credits/unit/hr)</label>
|
||||||
|
<input id="bid-price" name="price" type="number" min="0" step="0.01" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bid-notes">Notes (optional)</label>
|
||||||
|
<textarea id="bid-notes" name="notes" rows="3" placeholder="Add constraints, time windows, etc."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Submit Bid</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<aside id="toast" class="toast"></aside>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const selectors = {
|
||||||
|
totalOffers: document.querySelector<HTMLSpanElement>('#stat-total-offers'),
|
||||||
|
openCapacity: document.querySelector<HTMLSpanElement>('#stat-open-capacity'),
|
||||||
|
averagePrice: document.querySelector<HTMLSpanElement>('#stat-average-price'),
|
||||||
|
activeBids: document.querySelector<HTMLSpanElement>('#stat-active-bids'),
|
||||||
|
offersWrapper: document.querySelector<HTMLDivElement>('#offers-table-wrapper'),
|
||||||
|
bidForm: document.querySelector<HTMLFormElement>('#bid-form'),
|
||||||
|
toast: document.querySelector<HTMLDivElement>('#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 = '<p class="empty-state">No offers available right now. Check back soon or submit a bid.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = offers
|
||||||
|
.map(
|
||||||
|
(offer) => `
|
||||||
|
<tr>
|
||||||
|
<td>${offer.id}</td>
|
||||||
|
<td>${offer.provider}</td>
|
||||||
|
<td>${formatNumber(offer.capacity)} units</td>
|
||||||
|
<td>${formatNumber(offer.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||||
|
<td>${offer.sla}</td>
|
||||||
|
<td><span class="${statusClass(offer.status)}">${offer.status}</span></td>
|
||||||
|
</tr>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
selectors.offersWrapper.innerHTML = `
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="offers-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Capacity</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>SLA</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const [stats, offers] = await Promise.all([
|
||||||
|
fetchMarketplaceStats(),
|
||||||
|
fetchMarketplaceOffers(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderStats(stats);
|
||||||
|
renderOffers(offers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (selectors.offersWrapper) {
|
||||||
|
selectors.offersWrapper.innerHTML = '<p class="empty-state">Failed to load offers. Please retry shortly.</p>';
|
||||||
|
}
|
||||||
|
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();
|
||||||
219
apps/marketplace-web/src/style.css
Normal file
219
apps/marketplace-web/src/style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/marketplace-web/src/typescript.svg
Normal file
1
apps/marketplace-web/src/typescript.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
26
apps/marketplace-web/tsconfig.json
Normal file
26
apps/marketplace-web/tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
@ -96,6 +96,7 @@ def list_wallets(
|
|||||||
WalletDescriptor(wallet_id=record.wallet_id, public_key=record.public_key, metadata=metadata)
|
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")
|
@router.post("/wallets", response_model=WalletCreateResponse, status_code=status.HTTP_201_CREATED, summary="Create wallet")
|
||||||
def create_wallet(
|
def create_wallet(
|
||||||
@ -119,7 +120,10 @@ def create_wallet(
|
|||||||
metadata=request.metadata,
|
metadata=request.metadata,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
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.upsert_wallet(record.wallet_id, record.public_key, record.metadata)
|
||||||
ledger.record_event(record.wallet_id, "created", {"metadata": record.metadata})
|
ledger.record_event(record.wallet_id, "created", {"metadata": record.metadata})
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from aitbc_chain.app import create_app # noqa: I100
|
from app.deps import get_keystore, get_ledger, get_settings
|
||||||
|
from app.main import create_app
|
||||||
from app.deps import get_keystore, get_ledger
|
from app.keystore.service import KeystoreService
|
||||||
|
from app.ledger_mock import SQLiteLedgerAdapter
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="client")
|
@pytest.fixture(name="client")
|
||||||
@ -15,12 +17,24 @@ def client_fixture(tmp_path, monkeypatch):
|
|||||||
# Override ledger path to temporary directory
|
# Override ledger path to temporary directory
|
||||||
from app.settings import Settings
|
from app.settings import Settings
|
||||||
|
|
||||||
class TestSettings(Settings):
|
test_settings = Settings(LEDGER_DB_PATH=str(tmp_path / "ledger.db"))
|
||||||
ledger_db_path = 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()
|
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)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@ -79,4 +93,6 @@ def test_wallet_password_rules(client: TestClient):
|
|||||||
json={"wallet_id": "weak", "password": "short"},
|
json={"wallet_id": "weak", "password": "short"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
***
|
body = response.json()
|
||||||
|
assert body["detail"]["reason"] == "password_too_weak"
|
||||||
|
assert "min_length" in body["detail"]
|
||||||
|
|||||||
@ -26,6 +26,10 @@
|
|||||||
- Implemented CLI/Python runners and execution pipeline with result reporting.
|
- Implemented CLI/Python runners and execution pipeline with result reporting.
|
||||||
- Added starter tests for runners in `apps/miner-node/tests/test_runners.py`.
|
- 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
|
## Directory Preparation
|
||||||
|
|
||||||
- Established scaffolds for Python and JavaScript packages in `packages/py/` and `packages/js/`.
|
- 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`.
|
- 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.
|
- 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.
|
- 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
|
## Explorer Web
|
||||||
|
|
||||||
|
|||||||
26
docs/ports.md
Normal file
26
docs/ports.md
Normal file
@ -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.
|
||||||
202
docs/roadmap.md
202
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.
|
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**
|
- **Coordinator API**
|
||||||
- ✅ Scaffold FastAPI project (`apps/coordinator-api/src/app/`).
|
- ✅ 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.
|
- 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`).
|
- ✅ 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`).
|
- ✅ Expose REST RPC endpoints for tx submission, balances, receipts (`apps/blockchain-node/src/aitbc_chain/rpc/router.py`).
|
||||||
- ⏳ Deliver WebSocket RPC + P2P gossip layer:
|
- ✅ Deliver WebSocket RPC + P2P gossip layer:
|
||||||
- Stand up WebSocket subscription endpoints (`apps/blockchain-node/src/aitbc_chain/rpc/websocket.py`) mirroring REST payloads.
|
- ✅ 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.
|
- ✅ 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.
|
- ✅ Add integration tests and load-test harness ensuring gossip convergence and back-pressure handling.
|
||||||
- ✅ Ship devnet scripts (`apps/blockchain-node/scripts/`).
|
- ✅ Ship devnet scripts (`apps/blockchain-node/scripts/`).
|
||||||
- ✅ Add observability hooks (JSON logging, Prometheus metrics) and integrate coordinator mock into devnet tooling.
|
- ✅ Add observability hooks (JSON logging, Prometheus metrics) and integrate coordinator mock into devnet tooling.
|
||||||
- ⏳ Expand observability dashboards + miner mock integration:
|
- ⏳ 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`).
|
- ✅ 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/`).
|
- ✅ 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.
|
- ✅ 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:
|
- ✅ 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`).
|
- 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.
|
- 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.
|
- 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**
|
- **Pool Hub**
|
||||||
- ✅ Implement miner registry, scoring engine, and `/v1/match` API with Redis/PostgreSQL backing stores.
|
- ✅ 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.
|
- ✅ Add observability endpoints (`/v1/health`, `/v1/metrics`) plus Prometheus instrumentation and integration tests.
|
||||||
|
|
||||||
- **Marketplace Web**
|
- **Marketplace Web**
|
||||||
- Initialize Vite project with vanilla TypeScript.
|
- ✅ Initialize Vite project with vanilla TypeScript (`apps/marketplace-web/`).
|
||||||
- Build offer list, bid form, stats views sourcing mock JSON.
|
- ✅ Build offer list, bid form, and stats cards powered by mock data fixtures (`public/mock/`).
|
||||||
- Provide API abstraction to switch between mock and real backends.
|
- ✅ 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**
|
- **Explorer Web**
|
||||||
- ✅ Initialize Vite + TypeScript project scaffold (`apps/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).
|
- 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.
|
- 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
|
## 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/`.
|
|
||||||
|
|||||||
38
docs/run.md
38
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.)
|
(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
|
## Next Steps
|
||||||
|
|
||||||
- Flesh out remaining logic per task breakdowns in `docs/*.md` (e.g., capability-aware scheduling, artifact uploads).
|
- Flesh out remaining logic per task breakdowns in `docs/*.md` (e.g., capability-aware scheduling, artifact uploads).
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
pydantic>=2.7.0
|
||||||
|
pynacl>=1.5.0
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
__init__
|
||||||
|
aitbc_crypto
|
||||||
|
receipt
|
||||||
|
signing
|
||||||
@ -27,4 +27,5 @@ run_pytest() {
|
|||||||
run_pytest "${PROJECT_ROOT}/apps/coordinator-api/src:${PKG_PATHS}" apps/coordinator-api/tests -q
|
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 "${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/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
|
||||||
|
|||||||
Reference in New Issue
Block a user