diff --git a/node/hall_of_rust.py b/node/hall_of_rust.py index 8f6361d8..73beb271 100644 --- a/node/hall_of_rust.py +++ b/node/hall_of_rust.py @@ -390,6 +390,84 @@ def get_rust_badge(score): else: return "Fresh Metal" + + +@hall_bp.route('/api/hall_of_fame/machine', methods=['GET']) +def api_hall_of_fame_machine(): + """Machine profile endpoint for Hall of Fame detail page.""" + machine_id = (request.args.get('id') or '').strip() + if not machine_id: + return jsonify({'error': 'missing id'}), 400 + + try: + from flask import current_app + db_path = current_app.config.get('DB_PATH', '/root/rustchain/rustchain_v2.db') + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + c.execute("SELECT * FROM hall_of_rust WHERE fingerprint_hash = ?", (machine_id,)) + row = c.fetchone() + if not row: + conn.close() + return jsonify({'error': 'machine not found'}), 404 + + machine = dict(row) + machine['badge'] = get_rust_badge(float(machine.get('rust_score') or 0)) + mfg = machine.get('manufacture_year') + machine['age_years'] = max(0, 2026 - int(mfg)) if mfg else None + + # Last 30 days timeline from rust score history (best-effort) + now = int(time.time()) + start_ts = now - 30 * 86400 + c.execute( + """ + SELECT date(calculated_at, 'unixepoch') AS day, + MAX(rust_score) AS rust_score, + COUNT(*) AS samples + FROM rust_score_history + WHERE fingerprint_hash = ? AND calculated_at >= ? + GROUP BY day + ORDER BY day ASC + """, + (machine_id, start_ts) + ) + timeline = [ + {'date': r[0], 'rust_score': r[1], 'samples': r[2]} + for r in c.fetchall() + ] + + # Reward participation (best-effort) from enrollments + pending ledger credits + miner_pk = machine.get('miner_id') or '' + c.execute("SELECT COUNT(*) FROM epoch_enroll WHERE miner_pk = ?", (miner_pk,)) + enrolled_epochs = c.fetchone()[0] or 0 + + c.execute( + """ + SELECT COUNT(*), COALESCE(SUM(amount_i64),0) + FROM pending_ledger + WHERE to_miner = ? AND status = 'confirmed' + """, + (miner_pk,) + ) + reward_count, reward_sum_i64 = c.fetchone() + + reward_participation = { + 'enrolled_epochs': int(enrolled_epochs), + 'confirmed_reward_events': int(reward_count or 0), + 'confirmed_reward_rtc': round((reward_sum_i64 or 0) / 1_000_000.0, 6), + } + + conn.close() + return jsonify({ + 'machine': machine, + 'attestation_timeline_30d': timeline, + 'reward_participation': reward_participation, + 'generated_at': now, + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + def register_hall_endpoints(app, db_path): """Register Hall of Rust endpoints with Flask app.""" app.config['DB_PATH'] = db_path diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 07930290..f6afcbde 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -101,6 +101,8 @@ def generate_latest(): return b"# Prometheus not available" REPO_ROOT = os.path.abspath(os.path.join(_BASE_DIR, "..")) if os.path.basename(_BASE_DIR) == "node" else _BASE_DIR LIGHTCLIENT_DIR = os.path.join(REPO_ROOT, "web", "light-client") MUSEUM_DIR = os.path.join(REPO_ROOT, "web", "museum") +HOF_DIR = os.path.join(REPO_ROOT, "web", "hall-of-fame") +DASHBOARD_DIR = os.path.join(REPO_ROOT, "tools", "miner_dashboard") # Register Hall of Rust blueprint (tables initialized after DB_PATH is set) try: @@ -1681,6 +1683,21 @@ def museum_assets(filename: str): return _send_from_directory(MUSEUM_DIR, filename) + +@app.route("/hall-of-fame/machine.html", methods=["GET"]) +def hall_of_fame_machine_page(): + """Hall of Fame machine detail page.""" + from flask import send_from_directory as _send_from_directory + + return _send_from_directory(HOF_DIR, "machine.html") + + +@app.route("/dashboard", methods=["GET"]) +def miner_dashboard_page(): + """Personal miner dashboard single-page UI.""" + from flask import send_from_directory as _send_from_directory + return _send_from_directory(DASHBOARD_DIR, "index.html") + # ============= ATTESTATION ENDPOINTS ============= @app.route('/attest/challenge', methods=['POST']) @@ -3174,6 +3191,81 @@ def api_badge(miner_id: str): }) + + +@app.route('/api/miner_dashboard/', methods=['GET']) +def api_miner_dashboard(miner_id): + """Aggregated miner dashboard data with reward history (last 20 epochs).""" + try: + with sqlite3.connect(DB_PATH) as c: + c.row_factory = sqlite3.Row + # current balance from balances table with column-name fallback + bal_rtc = 0.0 + try: + row = c.execute("SELECT balance_urtc AS amount_i64 FROM balances WHERE wallet = ?", (miner_id,)).fetchone() + if row and row['amount_i64'] is not None: + bal_rtc = (row['amount_i64'] / 1_000_000.0) + except Exception: + row = None + + if bal_rtc == 0.0: + # production schema fallback: amount_i64 + miner_id + row2 = c.execute("SELECT amount_i64 FROM balances WHERE miner_id = ?", (miner_id,)).fetchone() + if row2 and row2['amount_i64'] is not None: + bal_rtc = (row2['amount_i64'] / 1_000_000.0) + + # total earned & reward history from confirmed pending_ledger credits + total_row = c.execute("SELECT COALESCE(SUM(amount_i64),0) AS s, COUNT(*) AS cnt FROM pending_ledger WHERE to_miner = ? AND status = 'confirmed'", (miner_id,)).fetchone() + total_earned = (total_row['s'] or 0) / 1_000_000.0 + reward_events = int(total_row['cnt'] or 0) + + hist = c.execute(""" + SELECT epoch, amount_i64, tx_hash, confirmed_at + FROM pending_ledger + WHERE to_miner = ? AND status = 'confirmed' + ORDER BY epoch DESC, confirmed_at DESC + LIMIT 20 + """, (miner_id,)).fetchall() + reward_history = [{ + 'epoch': int(r['epoch'] or 0), + 'amount_rtc': round((r['amount_i64'] or 0)/1_000_000.0, 6), + 'tx_hash': r['tx_hash'], + 'confirmed_at': int(r['confirmed_at'] or 0), + } for r in hist] + + # epoch participation count + ep_row = c.execute("SELECT COUNT(*) AS n FROM epoch_enroll WHERE miner_pk = ?", (miner_id,)).fetchone() + epoch_participation = int(ep_row['n'] or 0) + + # last 24h attest timeline if table exists + has_hist = c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='miner_attest_history'").fetchone() is not None + timeline = [] + if has_hist: + now_ts = int(time.time()) + start = now_ts - 86400 + rows = c.execute(""" + SELECT CAST((ts_ok/3600) AS INTEGER) AS bucket, COUNT(*) AS n + FROM miner_attest_history + WHERE miner = ? AND ts_ok >= ? + GROUP BY bucket + ORDER BY bucket ASC + """, (miner_id, start)).fetchall() + timeline = [{'hour_bucket': int(r['bucket']), 'count': int(r['n'])} for r in rows] + + return jsonify({ + 'ok': True, + 'miner_id': miner_id, + 'balance_rtc': round(bal_rtc, 6), + 'total_earned_rtc': round(total_earned, 6), + 'reward_events': reward_events, + 'epoch_participation': epoch_participation, + 'reward_history': reward_history, + 'attest_timeline_24h': timeline, + 'generated_at': int(time.time()), + }) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}), 500 + @app.route("/api/miner//attestations", methods=["GET"]) def api_miner_attestations(miner_id: str): """Best-effort attestation history for a single miner (museum detail view).""" diff --git a/tools/miner_dashboard/index.html b/tools/miner_dashboard/index.html new file mode 100644 index 00000000..41b7ac87 --- /dev/null +++ b/tools/miner_dashboard/index.html @@ -0,0 +1,43 @@ +RustChain Miner Dashboard

RustChain Miner Dashboard (personal stats page)

Supports share URL: ?miner=<id>
Current Balance
Epoch Participation (est.)
Total Earned (est.)
Epoch Countdown
Hardware
Rust Score
Manufacture Year
Attestation Freshness (24h)
Attestation Timeline (last 24h, hourly)
HourStatusSignal
Fleet View (operator machines)
MachineArchLast AttestEntropy
Earnings Performance (last 20 epochs)
Reward History (last 20 epochs)
EpochStatusAmountNotes
Public API currently provides limited per-epoch payout detail. This panel shows deterministic estimates from available live data.
diff --git a/web/hall-of-fame/machine.html b/web/hall-of-fame/machine.html new file mode 100644 index 00000000..ff918798 --- /dev/null +++ b/web/hall-of-fame/machine.html @@ -0,0 +1,107 @@ + + + + + + RustChain Hall of Fame · Machine Profile + + + +
+
+

Hall of Fame · Machine Profile

+ ← Back to leaderboard +
+
Loading machine profile…
+ + + +
+ + +