| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- #!/usr/bin/env python3
- # This file is part of DarkFi (https://dark.fi)
- #
- # Copyright (C) 2020-2026 Dyne.org foundation
- #
- # This program is free software: you can redistribute it and/or modify
- # it under the terms of the GNU Affero General Public License as
- # published by the Free Software Foundation, either version 3 of the
- # License, or (at your option) any later version.
- #
- # This program is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU Affero General Public License for more details.
- #
- # You should have received a copy of the GNU Affero General Public License
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
- import asyncio
- from datetime import datetime, timezone
- from quart import Quart, render_template, abort, request, redirect, url_for, Response
- from rpc_client import JsonRpcPool, JsonRpcError, RpcUnavailableError
- app = Quart(__name__)
- app.config.update(
- RPC_HOST="127.0.0.1",
- RPC_PORT="22222",
- RPC_MIN_CONNECTIONS=5,
- RPC_MAX_CONNECTIONS=50,
- RPC_RECONNECT_INTERVAL=5.0,
- NETWORK="Testnet",
- )
- # Global pool
- rpc: JsonRpcPool = None
- @app.before_serving
- async def startup():
- global rpc
- rpc = JsonRpcPool(
- host=app.config["RPC_HOST"],
- port=app.config["RPC_PORT"],
- min_connections=app.config["RPC_MIN_CONNECTIONS"],
- max_connections=app.config["RPC_MAX_CONNECTIONS"],
- reconnect_interval=app.config["RPC_RECONNECT_INTERVAL"],
- )
- await rpc.start()
- app.logger.info(f"RPC pool initialized for {app.config['RPC_HOST']}:{app.config['RPC_PORT']}")
- @app.after_serving
- async def shutdown():
- await rpc.close()
- app.logger.info("RPC pool closed")
- @app.errorhandler(JsonRpcError)
- async def handle_rpc_error(error: JsonRpcError):
- app.logger.error(f"RPC Error: {error.code} - {error.message}")
- if error.code == -32600:
- return await render_template(
- "error.html",
- network=app.config["NETWORK"],
- error_code="404",
- error="The requested resource was not found"
- ), 404
- return await render_template(
- "error.html",
- network=app.config["NETWORK"],
- error_code="500",
- error=error.message
- ), 500
- @app.errorhandler(RpcUnavailableError)
- async def handle_rpc_unavailable(error: RpcUnavailableError):
- app.logger.error(f"RPC Unavailable: {error}")
- return await render_template(
- "error.html",
- network=app.config["NETWORK"],
- error_code="503",
- error="Blockchain node is currently unavailable. Please try again later."
- ), 503
- @app.errorhandler(ConnectionError)
- async def handle_connection_error(error):
- app.logger.error(f"Connection Error: {error}")
- return await render_template(
- "error.html",
- network=app.config["NETWORK"],
- error_code="503",
- error="Service temporarily unavailable"
- ), 503
- @app.errorhandler(404)
- async def handle_not_found(error):
- return await render_template(
- "error.html",
- network=app.config["NETWORK"],
- error_code="404",
- error="Page not found"
- ), 404
- def format_hashrate(hashrate: float) -> str:
- """Format hashrate with appropriate unit."""
- if hashrate >= 1e12:
- return f"{hashrate / 1e12:.2f} TH/s"
- elif hashrate >= 1e9:
- return f"{hashrate / 1e9:.2f} GH/s"
- elif hashrate >= 1e6:
- return f"{hashrate / 1e6:.2f} MH/s"
- elif hashrate >= 1e3:
- return f"{hashrate / 1e3:.2f} KH/s"
- else:
- return f"{hashrate:.2f} H/s"
- def format_bytes(size: int) -> str:
- """Format byte size with appropriate unit."""
- if size >= 1024 * 1024:
- return f"{size / (1024 * 1024):.2f} MB"
- elif size >= 1024:
- return f"{size / 1024:.2f} KB"
- else:
- return f"{size} bytes"
- @app.route("/")
- async def index():
- current_difficulty = await rpc.call("current_difficulty", params=[])
- current_height = await rpc.call("current_height", params=[])
- latest_blocks = await rpc.call("latest_blocks", params=[20])
- hashrate = await rpc.call("get_hashrate", params=[])
- for block in latest_blocks:
- dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
- block["timestamp"] = dt.strftime("%H:%M UTC %d %b %Y")
- return await render_template(
- "index.html",
- network=app.config["NETWORK"],
- current_difficulty=current_difficulty[0],
- current_height=current_height,
- hashrate=format_hashrate(hashrate),
- latest_blocks=latest_blocks,
- )
- @app.route("/blocks")
- async def list_blocks():
- """List all blocks with pagination"""
- BLOCKS_PER_PAGE = 100
- # Get page number from query param (1-indexed for users)
- page = request.args.get("page", 1, type=int)
- if page < 1:
- page = 1
- current_difficulty = await rpc.call("current_difficulty", params=[])
- current_height = await rpc.call("current_height", params=[])
- hashrate = await rpc.call("get_hashrate", params=[])
- total = current_height + 1 # blocks 0 to current_height
- # Calculate range: newest first
- offset = (page - 1) * BLOCKS_PER_PAGE
- start_height = current_height - offset
- end_height = max(0, start_height - BLOCKS_PER_PAGE + 1)
- # Build list of heights to fetch
- heights = [h for h in range(start_height, end_height - 1, -1) if h >= 0]
- async def fetch_block(height):
- try:
- block = await rpc.call("get_block", params=[height])
- dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
- return {
- "height": int(block["height"]),
- "size": int(block["size"]),
- "n_txs": len(block["txs"]),
- "timestamp": dt.strftime("%H:%M UTC %d %b %Y"),
- "powtype": block["powtype"],
- "hash": block["hash"],
- }
- except JsonRpcError:
- return None
- # Fetch all blocks in parallel
- results = await asyncio.gather(*[fetch_block(h) for h in heights])
- blocks = [b for b in results if b is not None]
- # Calculate pagination info
- total_pages = (total + BLOCKS_PER_PAGE - 1) // BLOCKS_PER_PAGE
- has_prev = page > 1
- has_next = page < total_pages
- return await render_template(
- "blocks.html",
- network=app.config["NETWORK"],
- current_difficulty=current_difficulty[0],
- current_height=current_height,
- hashrate=format_hashrate(hashrate),
- blocks=blocks,
- total=total,
- page=page,
- total_pages=total_pages,
- has_prev=has_prev,
- has_next=has_next,
- blocks_per_page=BLOCKS_PER_PAGE,
- )
- @app.route("/block/<int:block_height>")
- async def get_block_by_height(block_height: int):
- if block_height < 0:
- abort(404)
- current_difficulty = await rpc.call("current_difficulty", params=[])
- current_height = await rpc.call("current_height", params=[])
- hashrate = await rpc.call("get_hashrate", params=[])
- block = await rpc.call("get_block", params=[block_height])
- dt = datetime.fromtimestamp(block["timestamp"], tz=timezone.utc)
- block["timestamp"] = dt.strftime("%H:%M UTC %d %b %Y")
- block["n_txs"] = len(block["txs"])
- return await render_template(
- "block.html",
- network=app.config["NETWORK"],
- current_difficulty=current_difficulty[0],
- current_height=current_height,
- hashrate=format_hashrate(hashrate),
- block=block,
- monero_hash=block["monero_hash"],
- )
- @app.route("/tx/<tx_hash>")
- async def get_tx_by_hash(tx_hash: str):
- # Validate hex string
- if not all(c in '0123456789abcdefABCDEF' for c in tx_hash):
- abort(404)
- current_difficulty = await rpc.call("current_difficulty", params=[])
- current_height = await rpc.call("current_height", params=[])
- hashrate = await rpc.call("get_hashrate", params=[])
- tx = await rpc.call("get_tx", params=[tx_hash])
- return await render_template(
- "tx.html",
- network=app.config["NETWORK"],
- current_difficulty=current_difficulty[0],
- current_height=current_height,
- hashrate=format_hashrate(hashrate),
- tx=tx,
- )
- @app.route("/search")
- async def search():
- """Search for blocks by height/hash or transactions by hash."""
- query = request.args.get("q", "").strip()
- if not query:
- return redirect(url_for("index"))
- # Try to interpret as block height (integer)
- if query.isdigit():
- return redirect(url_for("get_block_by_height", block_height=int(query)))
- # Check if it looks like a hex hash
- if all(c in '0123456789abcdefABCDEF' for c in query):
- # Use the search RPC to determine if it's a block or tx hash
- try:
- result = await rpc.call("search", params=[query])
- if result["type"] == "block":
- return redirect(f"/block/{result['height']}")
- elif result["type"] == "tx":
- return redirect(url_for("get_tx_by_hash", tx_hash=query))
- except JsonRpcError:
- pass
- # Try as contract ID (base58)
- try:
- contract = await rpc.call("get_contract", params=[query])
- return redirect(url_for("get_contract", contract_id=query))
- except JsonRpcError:
- pass
- # Nothing found
- return await render_template(
- "error.html",
- network=app.config["NETWORK"],
- error_code="Not Found",
- error=f"No block, transaction, or contract found for: {query}"
- ), 404
- @app.route("/contract/<contract_id>")
- async def get_contract(contract_id: str):
- current_difficulty = await rpc.call("current_difficulty", params=[])
- current_height = await rpc.call("current_height", params=[])
- hashrate = await rpc.call("get_hashrate", params=[])
- contract = await rpc.call("get_contract", params=[contract_id])
- contract["wasm_size_formatted"] = format_bytes(int(contract["wasm_size"]))
- return await render_template(
- "contract.html",
- network=app.config["NETWORK"],
- current_difficulty=current_difficulty[0],
- current_height=current_height,
- hashrate=format_hashrate(hashrate),
- contract=contract,
- )
- @app.route("/contracts")
- async def list_contracts():
- current_difficulty = await rpc.call("current_difficulty", params=[])
- current_height = await rpc.call("current_height", params=[])
- hashrate = await rpc.call("get_hashrate", params=[])
- contracts = await rpc.call("list_contracts", params=[])
- contract_count = await rpc.call("contract_count", params=[])
- for contract in contracts:
- contract["wasm_size_formatted"] = format_bytes(int(contract["wasm_size"]))
- return await render_template(
- "contracts.html",
- network=app.config["NETWORK"],
- current_difficulty=current_difficulty[0],
- current_height=current_height,
- hashrate=format_hashrate(hashrate),
- contracts=contracts,
- contract_count=contract_count,
- )
- @app.route("/stats")
- async def stats():
- current_difficulty = await rpc.call("current_difficulty", params=[])
- current_height = await rpc.call("current_height", params=[])
- hashrate = await rpc.call("get_hashrate", params=[])
- stats_data = await rpc.call("get_stats", params=[])
- return await render_template(
- "stats.html",
- network=app.config["NETWORK"],
- current_difficulty=current_difficulty[0],
- current_height=current_height,
- hashrate=format_hashrate(hashrate),
- stats=stats_data,
- )
- @app.route("/stats/daily_tx_chart.png")
- async def daily_tx_chart():
- """Generate daily average transactions chart as PNG using matplotlib."""
- import io
- import matplotlib
- matplotlib.use('Agg') # Non-interactive backend
- import matplotlib.pyplot as plt
- import matplotlib.dates as mdates
- stats_data = await rpc.call("get_stats", params=[])
- daily_stats = stats_data.get("daily_stats", [])
- # Filter to last 90 days
- if daily_stats:
- max_day = max(d["day"] for d in daily_stats)
- daily_stats = [d for d in daily_stats if d["day"] >= max_day - 90]
- # Create figure with dark theme
- plt.style.use('dark_background')
- fig, ax = plt.subplots(figsize=(12, 4), dpi=100)
- fig.patch.set_facecolor('#0d1117')
- ax.set_facecolor('#0d1117')
- if daily_stats:
- # Convert day numbers to dates
- dates = [datetime.fromtimestamp(d["day"] * 86400, tz=timezone.utc) for d in daily_stats]
- values = [d["avg_tx"] for d in daily_stats]
- ax.fill_between(dates, values, alpha=0.3, color='#6366f1')
- ax.plot(dates, values, color='#6366f1', linewidth=2)
- # Format x-axis
- ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d'))
- ax.xaxis.set_major_locator(mdates.DayLocator(interval=7))
- plt.xticks(rotation=45, ha='right')
- ax.set_ylabel('Avg TX per Block', color='#9ca3af')
- ax.tick_params(colors='#9ca3af')
- ax.spines['bottom'].set_color('#30363d')
- ax.spines['left'].set_color('#30363d')
- ax.spines['top'].set_visible(False)
- ax.spines['right'].set_visible(False)
- ax.grid(True, alpha=0.2, color='#30363d')
- else:
- ax.text(0.5, 0.5, 'No data available', ha='center', va='center',
- transform=ax.transAxes, color='#9ca3af', fontsize=14)
- ax.set_xlim(0, 1)
- ax.set_ylim(0, 1)
- plt.tight_layout()
- # Save to bytes buffer
- buf = io.BytesIO()
- plt.savefig(buf, format='png', facecolor='#0d1117', edgecolor='none')
- plt.close(fig)
- buf.seek(0)
- return Response(buf.getvalue(), mimetype='image/png')
- if __name__ == "__main__":
- app.run(host="127.0.0.1", port=5000, debug=True)
|