ovtools 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/assets/common/bond-inspect.sh +368 -0
- package/assets/common/config.sh +263 -0
- package/assets/common/extract-data.sh +197 -0
- package/assets/common/harden-host.sh +37 -0
- package/assets/common/install-deps.sh +93 -0
- package/assets/common/node-enroll.sh +65 -0
- package/assets/common/node-setup.sh +150 -0
- package/assets/common/node-start.sh +90 -0
- package/assets/common/node-stop.sh +18 -0
- package/assets/common/node-upgrade.sh +45 -0
- package/assets/common/pre-run.sh +497 -0
- package/assets/common/promote-validator.sh +104 -0
- package/assets/deploy/10-prepare-host.sh +98 -0
- package/assets/deploy/20-install-toolchain.sh +93 -0
- package/assets/deploy/30-setup-node.sh +82 -0
- package/assets/deploy/40-start-node.sh +75 -0
- package/assets/deploy/50-verify.sh +259 -0
- package/assets/deploy/60-enroll.sh +304 -0
- package/assets/deploy/70-promote.sh +152 -0
- package/assets/deploy/lib.sh +398 -0
- package/assets/manage/10-bond-increase.sh +149 -0
- package/assets/manage/10-diagnose-node.sh +169 -0
- package/assets/manage/20-exit-validator.sh +259 -0
- package/assets/manage/25-recover-node.sh +291 -0
- package/assets/manage/30-status.sh +64 -0
- package/assets/manage/40-exit-withdraw.sh +133 -0
- package/assets/manage/bond_inc.sh +318 -0
- package/assets/manage/dashboard_monitor/app.js +1544 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/base.css +113 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/content.css +133 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/controls.css +229 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/data.css +374 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/icons.css +35 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/motion.css +57 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/shell.css +51 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/sidebar.css +242 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/components/topbar.css +138 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/fonts.css +30 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/synteq-ui-v2.css +25 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/theme-dark.css +38 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/theme-light.css +36 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/css/tokens.css +61 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-Medium.ttf +0 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-Regular.ttf +0 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-SemiBold.ttf +0 -0
- package/assets/manage/dashboard_monitor/assets/v2.2/js/synteq-ui-v2.js +869 -0
- package/assets/manage/dashboard_monitor/deploy-dashboard.sh +230 -0
- package/assets/manage/dashboard_monitor/favicon.svg +13 -0
- package/assets/manage/dashboard_monitor/index.html +1197 -0
- package/assets/manage/dashboard_monitor/nginx-octra.conf +76 -0
- package/assets/manage/dashboard_monitor/octra-dashboard.service +29 -0
- package/assets/manage/dashboard_monitor/octra-node-prune.cron +12 -0
- package/assets/manage/dashboard_monitor/octra-node-prune.service +16 -0
- package/assets/manage/dashboard_monitor/octra-node-prune.timer +12 -0
- package/assets/manage/dashboard_monitor/prune-node-junk.sh +329 -0
- package/assets/manage/dashboard_monitor/server.py +1887 -0
- package/assets/manage/dashboard_monitor/ssl-certbot.sh +90 -0
- package/assets/manage/dashboard_monitor/styles.css +327 -0
- package/assets/manage/lib.sh +347 -0
- package/assets/manifest.json +143 -0
- package/assets/migrate/00-backup-identity.sh +67 -0
- package/assets/migrate/01-prepare-target.sh +145 -0
- package/assets/migrate/02-authorize-pull.sh +43 -0
- package/assets/migrate/03-pull-state.sh +102 -0
- package/assets/migrate/04-stop-source.sh +85 -0
- package/assets/migrate/05-finalize-target.sh +140 -0
- package/assets/migrate/06-start-target.sh +57 -0
- package/assets/migrate/07-verify.sh +237 -0
- package/assets/migrate/08-rollback.sh +54 -0
- package/assets/migrate/lib.sh +149 -0
- package/assets/upgrade/10-preflight.sh +120 -0
- package/assets/upgrade/20-upgrade.sh +142 -0
- package/assets/upgrade/30-manual-upgrade.sh +129 -0
- package/assets/upgrade/40-storage-prune.sh +65 -0
- package/assets/upgrade/50-status.sh +58 -0
- package/assets/upgrade/lib.sh +232 -0
- package/bin/ovtools.js +32 -0
- package/dist/api/checks.d.ts +6 -0
- package/dist/api/checks.d.ts.map +1 -0
- package/dist/api/checks.js +173 -0
- package/dist/api/checks.js.map +1 -0
- package/dist/api/hostkeys.d.ts +10 -0
- package/dist/api/hostkeys.d.ts.map +1 -0
- package/dist/api/hostkeys.js +58 -0
- package/dist/api/hostkeys.js.map +1 -0
- package/dist/api/jobs.d.ts +33 -0
- package/dist/api/jobs.d.ts.map +1 -0
- package/dist/api/jobs.js +483 -0
- package/dist/api/jobs.js.map +1 -0
- package/dist/api/nodes.d.ts +16 -0
- package/dist/api/nodes.d.ts.map +1 -0
- package/dist/api/nodes.js +126 -0
- package/dist/api/nodes.js.map +1 -0
- package/dist/api/server.d.ts +3 -0
- package/dist/api/server.d.ts.map +1 -0
- package/dist/api/server.js +443 -0
- package/dist/api/server.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +301 -0
- package/dist/cli.js.map +1 -0
- package/dist/constants.d.ts +76 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +150 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestrator/base.d.ts +52 -0
- package/dist/orchestrator/base.d.ts.map +1 -0
- package/dist/orchestrator/base.js +219 -0
- package/dist/orchestrator/base.js.map +1 -0
- package/dist/orchestrator/deploy.d.ts +33 -0
- package/dist/orchestrator/deploy.d.ts.map +1 -0
- package/dist/orchestrator/deploy.js +313 -0
- package/dist/orchestrator/deploy.js.map +1 -0
- package/dist/orchestrator/manage.d.ts +28 -0
- package/dist/orchestrator/manage.d.ts.map +1 -0
- package/dist/orchestrator/manage.js +267 -0
- package/dist/orchestrator/manage.js.map +1 -0
- package/dist/orchestrator/migrate.d.ts +31 -0
- package/dist/orchestrator/migrate.d.ts.map +1 -0
- package/dist/orchestrator/migrate.js +442 -0
- package/dist/orchestrator/migrate.js.map +1 -0
- package/dist/ssh/client.d.ts +39 -0
- package/dist/ssh/client.d.ts.map +1 -0
- package/dist/ssh/client.js +418 -0
- package/dist/ssh/client.js.map +1 -0
- package/dist/test/core.test.d.ts +2 -0
- package/dist/test/core.test.d.ts.map +1 -0
- package/dist/test/core.test.js +188 -0
- package/dist/test/core.test.js.map +1 -0
- package/dist/types.d.ts +267 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/assets.d.ts +28 -0
- package/dist/utils/assets.d.ts.map +1 -0
- package/dist/utils/assets.js +583 -0
- package/dist/utils/assets.js.map +1 -0
- package/dist/utils/browser.d.ts +2 -0
- package/dist/utils/browser.d.ts.map +1 -0
- package/dist/utils/browser.js +23 -0
- package/dist/utils/browser.js.map +1 -0
- package/dist/utils/dashboard.d.ts +16 -0
- package/dist/utils/dashboard.d.ts.map +1 -0
- package/dist/utils/dashboard.js +177 -0
- package/dist/utils/dashboard.js.map +1 -0
- package/dist/utils/helpers.d.ts +29 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +204 -0
- package/dist/utils/helpers.js.map +1 -0
- package/dist/utils/scripts.d.ts +60 -0
- package/dist/utils/scripts.d.ts.map +1 -0
- package/dist/utils/scripts.js +174 -0
- package/dist/utils/scripts.js.map +1 -0
- package/dist/utils/state.d.ts +12 -0
- package/dist/utils/state.d.ts.map +1 -0
- package/dist/utils/state.js +44 -0
- package/dist/utils/state.js.map +1 -0
- package/dist/utils/tar.d.ts +11 -0
- package/dist/utils/tar.d.ts.map +1 -0
- package/dist/utils/tar.js +93 -0
- package/dist/utils/tar.js.map +1 -0
- package/dist/utils/update.d.ts +9 -0
- package/dist/utils/update.d.ts.map +1 -0
- package/dist/utils/update.js +134 -0
- package/dist/utils/update.js.map +1 -0
- package/dist/utils/websocket.d.ts +10 -0
- package/dist/utils/websocket.d.ts.map +1 -0
- package/dist/utils/websocket.js +203 -0
- package/dist/utils/websocket.js.map +1 -0
- package/package.json +55 -4
- package/public/app.js +2712 -0
- package/public/assets/v2.2/css/base.css +113 -0
- package/public/assets/v2.2/css/components/content.css +133 -0
- package/public/assets/v2.2/css/components/controls.css +229 -0
- package/public/assets/v2.2/css/components/data.css +373 -0
- package/public/assets/v2.2/css/components/icons.css +35 -0
- package/public/assets/v2.2/css/components/motion.css +57 -0
- package/public/assets/v2.2/css/components/shell.css +51 -0
- package/public/assets/v2.2/css/components/sidebar.css +242 -0
- package/public/assets/v2.2/css/components/topbar.css +138 -0
- package/public/assets/v2.2/css/fonts.css +30 -0
- package/public/assets/v2.2/css/synteq-ui-v2.css +25 -0
- package/public/assets/v2.2/css/theme-dark.css +38 -0
- package/public/assets/v2.2/css/theme-light.css +36 -0
- package/public/assets/v2.2/css/tokens.css +61 -0
- package/public/assets/v2.2/fonts/GoogleSansCode-Medium.ttf +0 -0
- package/public/assets/v2.2/fonts/GoogleSansCode-Regular.ttf +0 -0
- package/public/assets/v2.2/fonts/GoogleSansCode-SemiBold.ttf +0 -0
- package/public/assets/v2.2/js/synteq-ui-v2.js +869 -0
- package/public/favicon.svg +7 -0
- package/public/index.html +1080 -0
- package/public/styles.css +552 -0
|
@@ -0,0 +1,1887 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Octra Lite Node - Live Monitoring Backend (v2)
|
|
4
|
+
==============================================
|
|
5
|
+
Single-file, dependency-free HTTP backend that serves the dashboard and
|
|
6
|
+
aggregates live node telemetry from:
|
|
7
|
+
|
|
8
|
+
* the node JSON-RPC API (127.0.0.1:<OCTRA_API_PORT>)
|
|
9
|
+
* /proc (cpu, memory, network, load, uptime)
|
|
10
|
+
* pm2 (process state + log files)
|
|
11
|
+
* node.env / SOURCE_COMMIT (identity + build)
|
|
12
|
+
|
|
13
|
+
Endpoints
|
|
14
|
+
---------
|
|
15
|
+
GET /api/status -> full snapshot (node, consensus, peers, system, wallet, history)
|
|
16
|
+
GET /api/logs -> parsed log tail (?lines=120&level=WARN,ERR)
|
|
17
|
+
GET /api/wallet -> wallet / stake / reward block only
|
|
18
|
+
GET /api/ping -> minimal liveness probe used by the frontend
|
|
19
|
+
|
|
20
|
+
The process is intentionally read-only: it never restarts or mutates the node.
|
|
21
|
+
Its only side effect is the wallet reward index under /var/lib/octra/dashboard/
|
|
22
|
+
(balance baseline + reward counters folded from the node's operator log).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import base64
|
|
27
|
+
import calendar
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import socket
|
|
32
|
+
import subprocess
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
import unicodedata
|
|
36
|
+
import urllib.request
|
|
37
|
+
from collections import deque
|
|
38
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from urllib.parse import urlparse, parse_qs
|
|
41
|
+
|
|
42
|
+
# -- Constants ------------------------------------------------------
|
|
43
|
+
DASHBOARD_DIR = Path(__file__).resolve().parent
|
|
44
|
+
NODE_SRC_DIR = Path("/opt/octra/libv_litecore")
|
|
45
|
+
NODE_DATA_ROOT = Path("/var/lib/octra")
|
|
46
|
+
NODE_ENV_PATH = NODE_SRC_DIR / ".keys" / "validator" / "node.env"
|
|
47
|
+
if not NODE_ENV_PATH.exists():
|
|
48
|
+
NODE_ENV_PATH = NODE_SRC_DIR / "config" / "node.env"
|
|
49
|
+
|
|
50
|
+
DEFAULT_PORT = 9090
|
|
51
|
+
MAX_HISTORY = 240 # 20 min @ 5 s samples
|
|
52
|
+
MAX_LOG_LINES = 400
|
|
53
|
+
SYSTEM_TTL = 1.5 # seconds - sample cache shared by all clients
|
|
54
|
+
STALE_PEER_S = 600 # peer record older than this is considered stale
|
|
55
|
+
FRESH_PEER_S = 120 # records younger than this drive the lag statistics
|
|
56
|
+
|
|
57
|
+
# -- Wallet / reward tracking ---------------------------------------
|
|
58
|
+
TOKEN_DECIMALS = 6 # ledger units: 1 OCTRA = 1_000_000 raw
|
|
59
|
+
TOKEN_UNIT = 10 ** TOKEN_DECIMALS
|
|
60
|
+
LOG_ADDR_PREFIX = 12 # the operator log prints addresses as 12 chars + ".."
|
|
61
|
+
WALLET_RPC_TTL = 8.0 # seconds - balance/enrollment cache shared by clients
|
|
62
|
+
WALLET_DEEP_TTL = 300.0 # seconds - octra_account detail is slow, so cache it long
|
|
63
|
+
WALLET_DEEP_RETRY = 30.0 # seconds between retries when the detail query fails
|
|
64
|
+
WALLET_SCAN_TTL = 10.0 # seconds between log-index passes when caught up
|
|
65
|
+
WALLET_CATCHUP = 1.0 # faster passes while the log index is still behind
|
|
66
|
+
WALLET_SCAN_BYTES = 8 * 1024 * 1024 # bounded work per pass
|
|
67
|
+
WALLET_RECENT_MAX = 16 # epoch reward rows kept for the table
|
|
68
|
+
WALLET_STATE_ENV = "OCTRA_WALLET_STATE"
|
|
69
|
+
WALLET_STATE_DIRS = (
|
|
70
|
+
Path("/var/lib/octra/dashboard"),
|
|
71
|
+
DASHBOARD_DIR / "state",
|
|
72
|
+
Path("/tmp"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# -- In-memory ring buffers -----------------------------------------
|
|
76
|
+
_cpu_history = deque(maxlen=MAX_HISTORY)
|
|
77
|
+
_net_history = deque(maxlen=MAX_HISTORY)
|
|
78
|
+
_epoch_history = deque(maxlen=MAX_HISTORY) # [ts, head_epoch]
|
|
79
|
+
_avail_history = deque(maxlen=2000) # 1 = healthy sample, 0 = not
|
|
80
|
+
|
|
81
|
+
_prev_cpu = None
|
|
82
|
+
_prev_net = None
|
|
83
|
+
_prev_diskio = None
|
|
84
|
+
_prev_time = None
|
|
85
|
+
_last_system = {"ts": 0.0, "data": None}
|
|
86
|
+
_log_cache = {"ts": 0.0, "lines": 0, "data": []}
|
|
87
|
+
_wallet_rpc = {"ts": 0.0, "address": "", "data": None}
|
|
88
|
+
_wallet_deep = {"ts": 0.0, "attempt": 0.0, "address": "", "data": None, "running": False}
|
|
89
|
+
_wallet_scanning = False
|
|
90
|
+
_wallet_state_file = {"path": None}
|
|
91
|
+
|
|
92
|
+
_start_time = time.time()
|
|
93
|
+
_lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -- Small helpers --------------------------------------------------
|
|
97
|
+
def parse_env(path):
|
|
98
|
+
result = {}
|
|
99
|
+
if not Path(path).is_file():
|
|
100
|
+
return result
|
|
101
|
+
try:
|
|
102
|
+
text = Path(path).read_text()
|
|
103
|
+
except Exception:
|
|
104
|
+
return result
|
|
105
|
+
for line in text.splitlines():
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
108
|
+
continue
|
|
109
|
+
k, v = line.split("=", 1)
|
|
110
|
+
result[k.strip()] = v.strip().strip('"')
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_api_port():
|
|
115
|
+
port = os.environ.get("OCTRA_API_PORT")
|
|
116
|
+
if port:
|
|
117
|
+
try:
|
|
118
|
+
return int(port)
|
|
119
|
+
except ValueError:
|
|
120
|
+
pass
|
|
121
|
+
env = parse_env(NODE_ENV_PATH)
|
|
122
|
+
try:
|
|
123
|
+
return int(env.get("OCTRA_API_PORT", "8080"))
|
|
124
|
+
except ValueError:
|
|
125
|
+
return 8080
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def os_release():
|
|
129
|
+
try:
|
|
130
|
+
with open("/etc/os-release") as f:
|
|
131
|
+
for line in f:
|
|
132
|
+
if line.startswith("PRETTY_NAME="):
|
|
133
|
+
return line.split("=", 1)[1].strip().strip('"')
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
return "unknown"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def human_age(seconds):
|
|
140
|
+
seconds = max(0, int(seconds))
|
|
141
|
+
d, rem = divmod(seconds, 86400)
|
|
142
|
+
h, rem = divmod(rem, 3600)
|
|
143
|
+
m, s = divmod(rem, 60)
|
|
144
|
+
if d:
|
|
145
|
+
return f"{d}d {h}h"
|
|
146
|
+
if h:
|
|
147
|
+
return f"{h}h {m}m"
|
|
148
|
+
if m:
|
|
149
|
+
return f"{m}m {s}s"
|
|
150
|
+
return f"{s}s"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _num(value, default=0.0):
|
|
154
|
+
try:
|
|
155
|
+
return float(value)
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
return default
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _int(value, default=0):
|
|
161
|
+
try:
|
|
162
|
+
return int(float(value))
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
return default
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# -- Text hygiene ---------------------------------------------------
|
|
168
|
+
# Everything that reaches the browser passes through ascii_text(): the node
|
|
169
|
+
# abbreviates a peer id with U+2026, an HTTP/proxy layer can re-encode it, and
|
|
170
|
+
# the address then showed up in the peer table as "octEQAFbEs<mojibake>pniHXQ"
|
|
171
|
+
# (2026-11-06). The monitor repairs the double-encoding, transliterates the
|
|
172
|
+
# glyph and drops what is left, so no /api/* string can carry a character
|
|
173
|
+
# outside ASCII - independent of how many layers mangled it on the way in.
|
|
174
|
+
_ASCII_GLYPHS = {
|
|
175
|
+
0x2014: "-", 0x2013: "-", 0x2012: "-", 0x2015: "-", 0x2212: "-",
|
|
176
|
+
0x2026: "...", 0x00B7: "-", 0x2022: "*", 0x2027: "*",
|
|
177
|
+
0x2192: "->", 0x2190: "<-", 0x2191: "^", 0x2193: "v",
|
|
178
|
+
0x2196: "up", 0x2197: "up", 0x2198: "down", 0x2199: "down",
|
|
179
|
+
0x21C4: "<->", 0x21D2: "=>",
|
|
180
|
+
0x2018: "'", 0x2019: "'", 0x201C: '"', 0x201D: '"',
|
|
181
|
+
0x00AB: '"', 0x00BB: '"', 0x2039: "'", 0x203A: "'",
|
|
182
|
+
0x2248: "~", 0x2261: "=", 0x2260: "!=", 0x2265: ">=", 0x2264: "<=",
|
|
183
|
+
0x00D7: "x", 0x00F7: "/", 0x2044: "/",
|
|
184
|
+
0x0394: "delta", 0x2206: "delta",
|
|
185
|
+
0x2713: "ok", 0x2714: "ok", 0x2717: "x", 0x2718: "x",
|
|
186
|
+
0x25B2: "^", 0x25BC: "v", 0x25C0: "<", 0x25B6: ">",
|
|
187
|
+
0x25CF: "*", 0x25CB: "o", 0x25A0: "*", 0x25A1: "o",
|
|
188
|
+
0x26A0: "!", 0x00A0: " ", 0x200B: "", 0xFEFF: "",
|
|
189
|
+
}
|
|
190
|
+
for _box in range(0x2500, 0x2580): # box drawing
|
|
191
|
+
_ASCII_GLYPHS.setdefault(_box, "|" if _box in (0x2502, 0x2503) else "-")
|
|
192
|
+
del _box
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _cp1252_bytes(text):
|
|
196
|
+
"""The bytes a mojibake string came from, or None when that is hopeless."""
|
|
197
|
+
try:
|
|
198
|
+
return text.encode("cp1252")
|
|
199
|
+
except UnicodeEncodeError:
|
|
200
|
+
pass
|
|
201
|
+
out = bytearray()
|
|
202
|
+
for ch in text:
|
|
203
|
+
try:
|
|
204
|
+
out += ch.encode("cp1252")
|
|
205
|
+
except UnicodeEncodeError:
|
|
206
|
+
return None
|
|
207
|
+
return bytes(out)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _non_ascii(text):
|
|
211
|
+
return sum(1 for ch in text if ord(ch) > 127)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def unmojibake(text, rounds=4):
|
|
215
|
+
"""Undo 'UTF-8 decoded as cp1252', once per round, while it actually helps."""
|
|
216
|
+
for _ in range(rounds):
|
|
217
|
+
before = _non_ascii(text)
|
|
218
|
+
if before == 0:
|
|
219
|
+
break
|
|
220
|
+
raw = _cp1252_bytes(text)
|
|
221
|
+
if raw is None:
|
|
222
|
+
break
|
|
223
|
+
try:
|
|
224
|
+
fixed = raw.decode("utf-8")
|
|
225
|
+
except UnicodeDecodeError:
|
|
226
|
+
break
|
|
227
|
+
if _non_ascii(fixed) >= before:
|
|
228
|
+
break
|
|
229
|
+
text = fixed
|
|
230
|
+
return text
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def ascii_text(value, repair=True):
|
|
234
|
+
"""A plain-ASCII view of a string that came from the node or a log file."""
|
|
235
|
+
if value is None:
|
|
236
|
+
return ""
|
|
237
|
+
text = value if isinstance(value, str) else str(value)
|
|
238
|
+
if repair:
|
|
239
|
+
text = unmojibake(text)
|
|
240
|
+
out = []
|
|
241
|
+
for ch in text:
|
|
242
|
+
code = ord(ch)
|
|
243
|
+
if code < 128:
|
|
244
|
+
out.append(ch)
|
|
245
|
+
continue
|
|
246
|
+
mapped = _ASCII_GLYPHS.get(code)
|
|
247
|
+
if mapped is None:
|
|
248
|
+
mapped = unicodedata.normalize("NFKD", ch).encode("ascii", "ignore").decode("ascii")
|
|
249
|
+
out.append(mapped)
|
|
250
|
+
return "".join(out)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def ascii_deep(value):
|
|
254
|
+
"""ascii_text() applied to every string of a JSON-shaped object."""
|
|
255
|
+
if isinstance(value, str):
|
|
256
|
+
return ascii_text(value)
|
|
257
|
+
if isinstance(value, dict):
|
|
258
|
+
return dict((ascii_text(k), ascii_deep(v)) for k, v in value.items())
|
|
259
|
+
if isinstance(value, (list, tuple)):
|
|
260
|
+
return [ascii_deep(v) for v in value]
|
|
261
|
+
return value
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def peer_label(value):
|
|
265
|
+
"""Display form of a peer id, whatever corruption reached us.
|
|
266
|
+
|
|
267
|
+
A node id is base58, so every non-alphanumeric run is damage: "octEQAFbEs
|
|
268
|
+
<mojibake>pniHXQ" becomes "octEQAFbEs...pniHXQ" instead of a string full of
|
|
269
|
+
lookalike letters. A clean id is abbreviated head + "..." + tail as before.
|
|
270
|
+
"""
|
|
271
|
+
text = unmojibake(value if isinstance(value, str) else str(value or ""))
|
|
272
|
+
if "..." in text: # the node already abbreviated it
|
|
273
|
+
return ascii_text(text)
|
|
274
|
+
parts = re.findall(r"[A-Za-z0-9]+", text)
|
|
275
|
+
if not parts:
|
|
276
|
+
return ""
|
|
277
|
+
if len(parts) > 1:
|
|
278
|
+
return parts[0][:10] + "..." + parts[-1][-6:]
|
|
279
|
+
joined = parts[0]
|
|
280
|
+
return (joined[:10] + "..." + joined[-6:]) if len(joined) > 20 else joined
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# -- Node RPC ------------------------------------------------------
|
|
284
|
+
def rpc_get_status(api_port, timeout=3):
|
|
285
|
+
try:
|
|
286
|
+
req = urllib.request.Request(
|
|
287
|
+
f"http://127.0.0.1:{api_port}/status",
|
|
288
|
+
headers={"Accept": "application/json"},
|
|
289
|
+
)
|
|
290
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
291
|
+
return json.loads(r.read())
|
|
292
|
+
except Exception:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def rpc_call_method(api_port, method, params=None, timeout=4):
|
|
297
|
+
body = json.dumps(
|
|
298
|
+
{"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []}
|
|
299
|
+
).encode()
|
|
300
|
+
req = urllib.request.Request(
|
|
301
|
+
f"http://127.0.0.1:{api_port}/rpc",
|
|
302
|
+
data=body,
|
|
303
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
304
|
+
method="POST",
|
|
305
|
+
)
|
|
306
|
+
try:
|
|
307
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
308
|
+
return json.loads(r.read()).get("result")
|
|
309
|
+
except Exception:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# -- /proc readers -------------------------------------------------
|
|
314
|
+
def read_cpu():
|
|
315
|
+
with open("/proc/stat") as f:
|
|
316
|
+
line = f.readline()
|
|
317
|
+
vals = [int(x) for x in line.split()[1:]]
|
|
318
|
+
total = sum(vals)
|
|
319
|
+
idle = vals[3] + (vals[4] if len(vals) > 4 else 0)
|
|
320
|
+
return total, idle
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def read_meminfo():
|
|
324
|
+
result = {}
|
|
325
|
+
with open("/proc/meminfo") as f:
|
|
326
|
+
for line in f:
|
|
327
|
+
if ":" not in line:
|
|
328
|
+
continue
|
|
329
|
+
key, rest = line.split(":", 1)
|
|
330
|
+
parts = rest.strip().split()
|
|
331
|
+
if not parts:
|
|
332
|
+
continue
|
|
333
|
+
val = int(parts[0])
|
|
334
|
+
if len(parts) > 1 and parts[1] == "kB":
|
|
335
|
+
val *= 1024
|
|
336
|
+
result[key] = val
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def read_netdev():
|
|
341
|
+
rx = tx = 0
|
|
342
|
+
with open("/proc/net/dev") as f:
|
|
343
|
+
for line in f:
|
|
344
|
+
if ":" not in line:
|
|
345
|
+
continue
|
|
346
|
+
iface, rest = line.split(":", 1)
|
|
347
|
+
name = iface.strip()
|
|
348
|
+
if name == "lo" or name.startswith(("veth", "docker", "br-", "tailscale")):
|
|
349
|
+
continue
|
|
350
|
+
p = rest.split()
|
|
351
|
+
if len(p) < 9:
|
|
352
|
+
continue
|
|
353
|
+
rx += int(p[0])
|
|
354
|
+
tx += int(p[8])
|
|
355
|
+
return rx, tx
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def read_diskio():
|
|
359
|
+
"""Summed sector read/write counters across physical block devices."""
|
|
360
|
+
read_s = write_s = 0
|
|
361
|
+
try:
|
|
362
|
+
with open("/proc/diskstats") as f:
|
|
363
|
+
for line in f:
|
|
364
|
+
p = line.split()
|
|
365
|
+
if len(p) < 10:
|
|
366
|
+
continue
|
|
367
|
+
name = p[2]
|
|
368
|
+
if name.startswith(("loop", "ram", "dm-", "sr")):
|
|
369
|
+
continue
|
|
370
|
+
# skip partitions (sda1, nvme0n1p1 ...) but keep sda / nvme0n1
|
|
371
|
+
if re.match(r"^(sd[a-z]|vd[a-z]|hd[a-z]|nvme\d+n\d+|mmcblk\d+)p?\d+$", name):
|
|
372
|
+
continue
|
|
373
|
+
read_s += int(p[5])
|
|
374
|
+
write_s += int(p[9])
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
return read_s * 512, write_s * 512
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def read_loadavg():
|
|
381
|
+
try:
|
|
382
|
+
with open("/proc/loadavg") as f:
|
|
383
|
+
p = f.read().split()
|
|
384
|
+
return [float(p[0]), float(p[1]), float(p[2])]
|
|
385
|
+
except Exception:
|
|
386
|
+
return [0.0, 0.0, 0.0]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def read_uptime_secs():
|
|
390
|
+
try:
|
|
391
|
+
with open("/proc/uptime") as f:
|
|
392
|
+
return float(f.read().split()[0])
|
|
393
|
+
except Exception:
|
|
394
|
+
return 0.0
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def disk_usage(path):
|
|
398
|
+
st = os.statvfs(str(path))
|
|
399
|
+
b = st.f_frsize
|
|
400
|
+
used = (st.f_blocks - st.f_bfree) * b
|
|
401
|
+
free = st.f_bavail * b
|
|
402
|
+
# Report the filesystem the way `df` does for a non-root view:
|
|
403
|
+
# reserved blocks are not part of the usable capacity.
|
|
404
|
+
return used + free, used, free
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# -- System metrics ------------------------------------------------
|
|
408
|
+
def system_info():
|
|
409
|
+
"""Sampled system telemetry, cached for SYSTEM_TTL seconds so several
|
|
410
|
+
dashboard clients never corrupt the rate calculations."""
|
|
411
|
+
now = time.time()
|
|
412
|
+
with _lock:
|
|
413
|
+
if _last_system["data"] and (now - _last_system["ts"]) < SYSTEM_TTL:
|
|
414
|
+
return dict(_last_system["data"])
|
|
415
|
+
|
|
416
|
+
global _prev_cpu, _prev_net, _prev_diskio, _prev_time
|
|
417
|
+
|
|
418
|
+
cpu_total, cpu_idle = read_cpu()
|
|
419
|
+
rx_bytes, tx_bytes = read_netdev()
|
|
420
|
+
disk_rb, disk_wb = read_diskio()
|
|
421
|
+
load = read_loadavg()
|
|
422
|
+
|
|
423
|
+
with _lock:
|
|
424
|
+
if _prev_cpu is not None and _prev_time is not None:
|
|
425
|
+
dt = now - _prev_time
|
|
426
|
+
d_total = cpu_total - _prev_cpu[0]
|
|
427
|
+
d_idle = cpu_idle - _prev_cpu[1]
|
|
428
|
+
cpu_pct = (1.0 - d_idle / d_total) * 100.0 if d_total > 0 else 0.0
|
|
429
|
+
if dt > 0:
|
|
430
|
+
in_mbps = (rx_bytes - _prev_net[0]) * 8 / dt / 1_000_000
|
|
431
|
+
out_mbps = (tx_bytes - _prev_net[1]) * 8 / dt / 1_000_000
|
|
432
|
+
disk_r = (disk_rb - _prev_diskio[0]) / dt / 1_048_576
|
|
433
|
+
disk_w = (disk_wb - _prev_diskio[1]) / dt / 1_048_576
|
|
434
|
+
else:
|
|
435
|
+
in_mbps = out_mbps = disk_r = disk_w = 0.0
|
|
436
|
+
else:
|
|
437
|
+
cpu_pct = in_mbps = out_mbps = disk_r = disk_w = 0.0
|
|
438
|
+
|
|
439
|
+
# The very first sample only establishes a baseline - do not chart it.
|
|
440
|
+
if _prev_cpu is not None:
|
|
441
|
+
_cpu_history.append(round(cpu_pct, 1))
|
|
442
|
+
_net_history.append([round(max(0.0, in_mbps), 2), round(max(0.0, out_mbps), 2)])
|
|
443
|
+
_prev_cpu = (cpu_total, cpu_idle)
|
|
444
|
+
_prev_net = (rx_bytes, tx_bytes)
|
|
445
|
+
_prev_diskio = (disk_rb, disk_wb)
|
|
446
|
+
_prev_time = now
|
|
447
|
+
|
|
448
|
+
cpu_hist = list(_cpu_history)
|
|
449
|
+
net_hist = list(_net_history)
|
|
450
|
+
|
|
451
|
+
mem = read_meminfo()
|
|
452
|
+
mem_total = mem.get("MemTotal", 0)
|
|
453
|
+
mem_avail = mem.get("MemAvailable", mem_total - mem.get("MemFree", 0))
|
|
454
|
+
mem_used = mem_total - mem_avail
|
|
455
|
+
swap_total = mem.get("SwapTotal", 0)
|
|
456
|
+
swap_used = swap_total - mem.get("SwapFree", 0)
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
data_total, data_used, data_free = disk_usage(NODE_DATA_ROOT)
|
|
460
|
+
except Exception:
|
|
461
|
+
data_total = data_used = data_free = 0
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
root_total, root_used, _ = disk_usage("/")
|
|
465
|
+
except Exception:
|
|
466
|
+
root_total = root_used = 0
|
|
467
|
+
|
|
468
|
+
cores = os.cpu_count() or 1
|
|
469
|
+
uptime_secs = read_uptime_secs()
|
|
470
|
+
|
|
471
|
+
info = {
|
|
472
|
+
"hostname": socket.gethostname(),
|
|
473
|
+
"os": os_release(),
|
|
474
|
+
"kernel": os.uname().release,
|
|
475
|
+
"arch": os.uname().machine,
|
|
476
|
+
"cpu_cores": cores,
|
|
477
|
+
"cpu_percent": round(cpu_pct, 1),
|
|
478
|
+
"cpu_history": cpu_hist,
|
|
479
|
+
"load_avg": load,
|
|
480
|
+
"load_percent": round(min(999.0, load[0] / cores * 100), 1),
|
|
481
|
+
"mem_total_gb": round(mem_total / 1e9, 1),
|
|
482
|
+
"mem_used_gb": round(mem_used / 1e9, 1),
|
|
483
|
+
"mem_avail_gb": round(mem_avail / 1e9, 1),
|
|
484
|
+
"mem_percent": round((mem_used / mem_total * 100) if mem_total else 0, 1),
|
|
485
|
+
"swap_total_gb": round(swap_total / 1e9, 1),
|
|
486
|
+
"swap_used_gb": round(swap_used / 1e9, 1),
|
|
487
|
+
"swap_percent": round((swap_used / swap_total * 100) if swap_total else 0, 1),
|
|
488
|
+
"disk_total_gb": round(data_total / 1e9, 1),
|
|
489
|
+
"disk_used_gb": round(data_used / 1e9, 1),
|
|
490
|
+
"disk_free_gb": round(data_free / 1e9, 1),
|
|
491
|
+
"disk_percent": round((data_used / data_total * 100) if data_total else 0, 1),
|
|
492
|
+
"disk_root_total_gb": round(root_total / 1e9, 1),
|
|
493
|
+
"disk_root_used_gb": round(root_used / 1e9, 1),
|
|
494
|
+
"disk_root_percent": round((root_used / root_total * 100) if root_total else 0, 1),
|
|
495
|
+
"disk_read_mbps": round(max(0.0, disk_r), 2),
|
|
496
|
+
"disk_write_mbps": round(max(0.0, disk_w), 2),
|
|
497
|
+
"net_in_mbps": round(max(0.0, in_mbps), 2),
|
|
498
|
+
"net_out_mbps": round(max(0.0, out_mbps), 2),
|
|
499
|
+
"net_history": net_hist,
|
|
500
|
+
"uptime_secs": int(uptime_secs),
|
|
501
|
+
"uptime": human_age(uptime_secs),
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
with _lock:
|
|
505
|
+
_last_system["ts"] = now
|
|
506
|
+
_last_system["data"] = info
|
|
507
|
+
return dict(info)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# -- PM2 process info ----------------------------------------------
|
|
511
|
+
def pm2_info():
|
|
512
|
+
node_env = parse_env(NODE_ENV_PATH)
|
|
513
|
+
pm2_name = node_env.get("OCTRA_OPERATOR_PM2_NAME", "octra-validator-01")
|
|
514
|
+
|
|
515
|
+
def _parse(stdout):
|
|
516
|
+
try:
|
|
517
|
+
entries = json.loads(stdout)
|
|
518
|
+
entry = next((e for e in entries if e.get("name") == pm2_name), None)
|
|
519
|
+
if not entry:
|
|
520
|
+
entry = next(
|
|
521
|
+
(e for e in entries if "octra" in (e.get("name") or "").lower()),
|
|
522
|
+
None,
|
|
523
|
+
)
|
|
524
|
+
if not entry:
|
|
525
|
+
return {"status": "offline", "name": pm2_name}
|
|
526
|
+
env = entry.get("pm2_env", {}) or {}
|
|
527
|
+
mon = entry.get("monit", {}) or {}
|
|
528
|
+
started_ms = env.get("pm_uptime") or 0
|
|
529
|
+
uptime_secs = int((time.time() * 1000 - started_ms) / 1000) if started_ms else 0
|
|
530
|
+
return {
|
|
531
|
+
"status": env.get("status", "unknown"),
|
|
532
|
+
"name": entry.get("name", pm2_name),
|
|
533
|
+
"pid": entry.get("pid", 0),
|
|
534
|
+
"uptime": human_age(uptime_secs) if uptime_secs else "unknown",
|
|
535
|
+
"uptime_secs": uptime_secs,
|
|
536
|
+
"restarts": env.get("restart_time", 0),
|
|
537
|
+
"cpu": round(_num(mon.get("cpu")), 1),
|
|
538
|
+
"memory_mb": round(_num(mon.get("memory")) / (1024 * 1024), 1),
|
|
539
|
+
"exec_mode": env.get("exec_mode", ""),
|
|
540
|
+
"instances": env.get("instances", 1),
|
|
541
|
+
"node_version": env.get("node_version") or env.get("exec_interpreter") or "",
|
|
542
|
+
}
|
|
543
|
+
except Exception:
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
for cmd in (
|
|
547
|
+
["su", "-", "octra", "-c", "pm2 jlist --silent"],
|
|
548
|
+
["pm2", "jlist", "--silent"],
|
|
549
|
+
):
|
|
550
|
+
try:
|
|
551
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
|
552
|
+
result = _parse(r.stdout)
|
|
553
|
+
if result:
|
|
554
|
+
return result
|
|
555
|
+
except Exception:
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
return {"status": "unavailable", "name": pm2_name}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# -- Log tail + parsing --------------------------------------------
|
|
562
|
+
LOG_RE = re.compile(
|
|
563
|
+
r"^(?P<ts>\d{4}-\d{2}-\d{2}T\S+)\s+"
|
|
564
|
+
r"(?P<level>TRACE|DEBUG|INFO|WARN|WARNING|ERROR|ERR|FATAL)\b\s*(?P<rest>.*)$"
|
|
565
|
+
)
|
|
566
|
+
FIELD_RE = re.compile(r"(\w+)\s*=\s*([^\s]+)")
|
|
567
|
+
|
|
568
|
+
LEVEL_MAP = {
|
|
569
|
+
"TRACE": "trace",
|
|
570
|
+
"DEBUG": "debug",
|
|
571
|
+
"INFO": "info",
|
|
572
|
+
"WARN": "warn",
|
|
573
|
+
"WARNING": "warn",
|
|
574
|
+
"ERROR": "error",
|
|
575
|
+
"ERR": "error",
|
|
576
|
+
"FATAL": "fatal",
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def parse_log_line(line, src="stdout"):
|
|
581
|
+
line = line.rstrip("\n")
|
|
582
|
+
entry = {
|
|
583
|
+
"src": src,
|
|
584
|
+
"msg": line,
|
|
585
|
+
"level": "info",
|
|
586
|
+
"ts": "",
|
|
587
|
+
"component": "",
|
|
588
|
+
"event": "",
|
|
589
|
+
"detail": line,
|
|
590
|
+
"fields": {},
|
|
591
|
+
}
|
|
592
|
+
m = LOG_RE.match(line)
|
|
593
|
+
if m:
|
|
594
|
+
entry["ts"] = m.group("ts")
|
|
595
|
+
entry["level"] = LEVEL_MAP.get(m.group("level"), "info")
|
|
596
|
+
rest = m.group("rest")
|
|
597
|
+
fields = {}
|
|
598
|
+
for k, v in FIELD_RE.findall(rest):
|
|
599
|
+
fields[k] = v
|
|
600
|
+
entry["fields"] = fields
|
|
601
|
+
entry["component"] = fields.get("component", "")
|
|
602
|
+
entry["event"] = fields.get("event", "")
|
|
603
|
+
entry["detail"] = rest
|
|
604
|
+
# Facts worth surfacing directly in the UI
|
|
605
|
+
for key in ("epoch", "height", "round", "conn", "peer_class", "reason", "vote_epoch"):
|
|
606
|
+
if key in fields:
|
|
607
|
+
entry[key] = fields[key]
|
|
608
|
+
else:
|
|
609
|
+
low = line.lower()
|
|
610
|
+
if "error" in low or "fatal" in low:
|
|
611
|
+
entry["level"] = "error"
|
|
612
|
+
elif "warn" in low:
|
|
613
|
+
entry["level"] = "warn"
|
|
614
|
+
return entry
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def pm2_log_paths():
|
|
618
|
+
node_env = parse_env(NODE_ENV_PATH)
|
|
619
|
+
pm2_name = node_env.get("OCTRA_OPERATOR_PM2_NAME", "octra-validator-01")
|
|
620
|
+
return [
|
|
621
|
+
Path(f"/root/.pm2/logs/{pm2_name}-out.log"),
|
|
622
|
+
Path(f"/home/octra/.pm2/logs/{pm2_name}-out.log"),
|
|
623
|
+
Path(f"/root/.pm2/logs/{pm2_name}-error.log"),
|
|
624
|
+
Path(f"/home/octra/.pm2/logs/{pm2_name}-error.log"),
|
|
625
|
+
]
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def pm2_log_tail(lines=MAX_LOG_LINES):
|
|
629
|
+
"""Return the last N parsed log lines (oldest -> newest)."""
|
|
630
|
+
collected = []
|
|
631
|
+
for log_path in pm2_log_paths():
|
|
632
|
+
if not log_path.is_file():
|
|
633
|
+
continue
|
|
634
|
+
try:
|
|
635
|
+
r = subprocess.run(
|
|
636
|
+
["tail", "-n", str(lines), str(log_path)],
|
|
637
|
+
capture_output=True,
|
|
638
|
+
text=True,
|
|
639
|
+
timeout=4,
|
|
640
|
+
)
|
|
641
|
+
src = "stderr" if "error" in log_path.name else "stdout"
|
|
642
|
+
raw = [ln for ln in r.stdout.splitlines() if ln.strip()]
|
|
643
|
+
collected = [parse_log_line(ln, src=src) for ln in raw]
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
if collected:
|
|
647
|
+
break
|
|
648
|
+
|
|
649
|
+
if not collected:
|
|
650
|
+
node_env = parse_env(NODE_ENV_PATH)
|
|
651
|
+
pm2_name = node_env.get("OCTRA_OPERATOR_PM2_NAME", "octra-validator-01")
|
|
652
|
+
try:
|
|
653
|
+
r = subprocess.run(
|
|
654
|
+
["su", "-", "octra", "-c",
|
|
655
|
+
f"pm2 logs {pm2_name} --lines {lines} --nostream --raw 2>&1"],
|
|
656
|
+
capture_output=True, text=True, timeout=8,
|
|
657
|
+
)
|
|
658
|
+
raw = [ln for ln in r.stdout.splitlines() if ln.strip()]
|
|
659
|
+
collected = [parse_log_line(ln, src="pm2") for ln in raw[-lines:]]
|
|
660
|
+
except Exception:
|
|
661
|
+
pass
|
|
662
|
+
|
|
663
|
+
return collected[-lines:]
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def pm2_log_tail_cached(lines=200):
|
|
667
|
+
"""Log tail shared by /api/status and /api/logs (2 s cache)."""
|
|
668
|
+
now = time.time()
|
|
669
|
+
with _lock:
|
|
670
|
+
if _log_cache["data"] and (now - _log_cache["ts"]) < 2.0 and _log_cache["lines"] >= lines:
|
|
671
|
+
return _log_cache["data"][-lines:]
|
|
672
|
+
size = max(lines, 200)
|
|
673
|
+
data = pm2_log_tail(size)
|
|
674
|
+
with _lock:
|
|
675
|
+
_log_cache.update({"ts": now, "lines": size, "data": data})
|
|
676
|
+
return data[-lines:]
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def log_summary(entries):
|
|
680
|
+
"""Counts per level + the newest notable event for the health panel."""
|
|
681
|
+
counts = {"trace": 0, "debug": 0, "info": 0, "warn": 0, "error": 0, "fatal": 0}
|
|
682
|
+
last_warn = None
|
|
683
|
+
last_error = None
|
|
684
|
+
for e in entries: # oldest -> newest
|
|
685
|
+
lvl = e.get("level", "info")
|
|
686
|
+
counts[lvl] = counts.get(lvl, 0) + 1
|
|
687
|
+
if lvl == "warn":
|
|
688
|
+
last_warn = e
|
|
689
|
+
elif lvl in ("error", "fatal"):
|
|
690
|
+
last_error = e
|
|
691
|
+
return {
|
|
692
|
+
"counts": counts,
|
|
693
|
+
"total": len(entries),
|
|
694
|
+
"last_warn": last_warn,
|
|
695
|
+
"last_error": last_error,
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# -- Derived: consensus / peers / epoch rate -----------------------
|
|
700
|
+
def _vote_block(votes):
|
|
701
|
+
"""Normalise one prevote/precommit bucket into comparable numbers."""
|
|
702
|
+
voters = 0
|
|
703
|
+
weight = 0
|
|
704
|
+
proposals = 0
|
|
705
|
+
rows = []
|
|
706
|
+
if isinstance(votes, list):
|
|
707
|
+
for v in votes:
|
|
708
|
+
if not isinstance(v, dict):
|
|
709
|
+
continue
|
|
710
|
+
voters += _int(v.get("voters"))
|
|
711
|
+
weight += _int(v.get("weight"))
|
|
712
|
+
proposals += 1
|
|
713
|
+
rows.append({
|
|
714
|
+
"proposal_id": str(v.get("proposal_id") or ""),
|
|
715
|
+
"voters": _int(v.get("voters")),
|
|
716
|
+
"weight": _int(v.get("weight")),
|
|
717
|
+
})
|
|
718
|
+
rows.sort(key=lambda r: r["weight"], reverse=True)
|
|
719
|
+
return {"voters": voters, "weight": weight, "proposals": proposals, "rows": rows}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def consensus_block(peers_result):
|
|
723
|
+
"""Consensus round details derived from octra_consensusPeerStates."""
|
|
724
|
+
if not peers_result:
|
|
725
|
+
return {
|
|
726
|
+
"available": False,
|
|
727
|
+
"voting": "unavailable",
|
|
728
|
+
"voting_reason": "",
|
|
729
|
+
"step": "unknown",
|
|
730
|
+
"round": None,
|
|
731
|
+
"epoch": None,
|
|
732
|
+
"agreed": None,
|
|
733
|
+
"participants": {"total": 0, "propose": 0, "prevote": 0, "precommit": 0, "newest_age_sec": None},
|
|
734
|
+
"quorum": {"prevotes": 0, "precommits": 0, "quorum": 0,
|
|
735
|
+
"prevote_pct": 0.0, "precommit_pct": 0.0,
|
|
736
|
+
"prevote_weight_pct": 0.0, "precommit_weight_pct": 0.0,
|
|
737
|
+
"quorum_weight": 0},
|
|
738
|
+
"proposals": [],
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
round_state = peers_result.get("round_state") or {}
|
|
742
|
+
votes = peers_result.get("round_votes") or {}
|
|
743
|
+
quorum = _int(votes.get("quorum"))
|
|
744
|
+
quorum_weight = _int(votes.get("quorum_weight"))
|
|
745
|
+
|
|
746
|
+
prevotes = _vote_block(votes.get("prevotes"))
|
|
747
|
+
precommits = _vote_block(votes.get("precommits"))
|
|
748
|
+
|
|
749
|
+
participants = peers_result.get("round_peers") or []
|
|
750
|
+
steps = {"propose": 0, "prevote": 0, "precommit": 0}
|
|
751
|
+
newest_age = None
|
|
752
|
+
if isinstance(participants, list):
|
|
753
|
+
for p in participants:
|
|
754
|
+
if not isinstance(p, dict):
|
|
755
|
+
continue
|
|
756
|
+
step = str(p.get("step") or "").lower()
|
|
757
|
+
if step in steps:
|
|
758
|
+
steps[step] += 1
|
|
759
|
+
age = _num(p.get("age_sec"), -1)
|
|
760
|
+
if age >= 0 and (newest_age is None or age < newest_age):
|
|
761
|
+
newest_age = age
|
|
762
|
+
|
|
763
|
+
voting = peers_result.get("voting")
|
|
764
|
+
if voting is True:
|
|
765
|
+
voting_state = "enabled"
|
|
766
|
+
elif voting is False:
|
|
767
|
+
voting_state = "disabled"
|
|
768
|
+
else:
|
|
769
|
+
voting_state = "unknown"
|
|
770
|
+
|
|
771
|
+
def pct(value, total):
|
|
772
|
+
return round(min(100.0, value / total * 100), 1) if total else 0.0
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
"available": True,
|
|
776
|
+
"voting": voting_state,
|
|
777
|
+
"voting_reason": peers_result.get("voting_reason") or "",
|
|
778
|
+
"step": str(round_state.get("step") or "unknown"),
|
|
779
|
+
"round": _int(round_state.get("round"), -1),
|
|
780
|
+
"epoch": _int(round_state.get("epoch_id")),
|
|
781
|
+
"agreed": peers_result.get("round_agreed"),
|
|
782
|
+
"participants": {
|
|
783
|
+
"total": len(participants) if isinstance(participants, list) else 0,
|
|
784
|
+
"propose": steps["propose"],
|
|
785
|
+
"prevote": steps["prevote"],
|
|
786
|
+
"precommit": steps["precommit"],
|
|
787
|
+
"newest_age_sec": round(newest_age, 1) if newest_age is not None else None,
|
|
788
|
+
},
|
|
789
|
+
"quorum": {
|
|
790
|
+
"prevotes": prevotes["voters"],
|
|
791
|
+
"precommits": precommits["voters"],
|
|
792
|
+
"prevote_proposals": prevotes["proposals"],
|
|
793
|
+
"quorum": quorum,
|
|
794
|
+
"quorum_weight": quorum_weight,
|
|
795
|
+
"prevote_weight": prevotes["weight"],
|
|
796
|
+
"precommit_weight": precommits["weight"],
|
|
797
|
+
"prevote_pct": pct(prevotes["voters"], quorum),
|
|
798
|
+
"precommit_pct": pct(precommits["voters"], quorum),
|
|
799
|
+
"prevote_weight_pct": pct(prevotes["weight"], quorum_weight),
|
|
800
|
+
"precommit_weight_pct": pct(precommits["weight"], quorum_weight),
|
|
801
|
+
},
|
|
802
|
+
"proposals": prevotes["rows"][:8],
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def peers_block(peers_result, local_head, epoch_interval=None):
|
|
807
|
+
"""Peer mesh health: lag vs record age (drift), stale records, bans."""
|
|
808
|
+
interval = epoch_interval if isinstance(epoch_interval, (int, float)) and epoch_interval > 0 else 0.0
|
|
809
|
+
block = {
|
|
810
|
+
"available": bool(peers_result),
|
|
811
|
+
"connected": 0,
|
|
812
|
+
"known": 0,
|
|
813
|
+
"public_sources": 0,
|
|
814
|
+
"consensus_peers": 0,
|
|
815
|
+
"network_head": local_head,
|
|
816
|
+
"lag_max": 0,
|
|
817
|
+
"drift_max": 0,
|
|
818
|
+
"drift_median": 0,
|
|
819
|
+
"behind_count": 0,
|
|
820
|
+
"synced_count": 0,
|
|
821
|
+
"ahead_count": 0,
|
|
822
|
+
"stale_count": 0,
|
|
823
|
+
"banned_count": 0,
|
|
824
|
+
"paused_count": 0,
|
|
825
|
+
"top": [],
|
|
826
|
+
}
|
|
827
|
+
if not peers_result:
|
|
828
|
+
return block
|
|
829
|
+
|
|
830
|
+
diag = peers_result.get("p2p_diagnostics") or {}
|
|
831
|
+
records = peers_result.get("peers") or []
|
|
832
|
+
scores = peers_result.get("scores") or []
|
|
833
|
+
|
|
834
|
+
block["connected"] = _int(diag.get("connected"))
|
|
835
|
+
block["known"] = _int(diag.get("known"))
|
|
836
|
+
block["public_sources"] = _int(diag.get("public_sources"))
|
|
837
|
+
block["consensus_peers"] = len(records) if isinstance(records, list) else 0
|
|
838
|
+
|
|
839
|
+
fresh_lags = []
|
|
840
|
+
heads = []
|
|
841
|
+
stale = 0
|
|
842
|
+
rows = []
|
|
843
|
+
|
|
844
|
+
for rec in records if isinstance(records, list) else []:
|
|
845
|
+
if not isinstance(rec, dict):
|
|
846
|
+
continue
|
|
847
|
+
head = _int(rec.get("head_epoch"))
|
|
848
|
+
if head <= 0:
|
|
849
|
+
continue
|
|
850
|
+
age = _num(rec.get("age_sec"), 0.0)
|
|
851
|
+
lag = max(0, local_head - head)
|
|
852
|
+
heads.append(head)
|
|
853
|
+
|
|
854
|
+
rec_stale = age > STALE_PEER_S
|
|
855
|
+
recent = age <= FRESH_PEER_S
|
|
856
|
+
expected = (age / interval) if interval else 0.0
|
|
857
|
+
drift = max(0, int(round(lag - expected)))
|
|
858
|
+
if rec_stale:
|
|
859
|
+
stale += 1
|
|
860
|
+
elif recent:
|
|
861
|
+
fresh_lags.append(drift)
|
|
862
|
+
if drift > 3:
|
|
863
|
+
block["behind_count"] += 1
|
|
864
|
+
elif drift <= 1:
|
|
865
|
+
block["synced_count"] += 1
|
|
866
|
+
|
|
867
|
+
if head > local_head:
|
|
868
|
+
block["ahead_count"] += 1
|
|
869
|
+
|
|
870
|
+
raw_addr = str(rec.get("responder_addr") or "")
|
|
871
|
+
rows.append({
|
|
872
|
+
"addr": ascii_text(raw_addr),
|
|
873
|
+
"short": peer_label(raw_addr),
|
|
874
|
+
"head_epoch": head,
|
|
875
|
+
"lag": lag,
|
|
876
|
+
"drift": drift,
|
|
877
|
+
"age_sec": round(age, 1),
|
|
878
|
+
"stale": rec_stale,
|
|
879
|
+
"recent": recent,
|
|
880
|
+
"source": ascii_text(rec.get("source") or ""),
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
block["stale_count"] = stale
|
|
884
|
+
block["network_head"] = max(heads) if heads else local_head
|
|
885
|
+
block["lag_max"] = max(0, local_head - block["network_head"])
|
|
886
|
+
if fresh_lags:
|
|
887
|
+
ordered = sorted(fresh_lags)
|
|
888
|
+
mid = len(ordered) // 2
|
|
889
|
+
block["drift_max"] = ordered[-1]
|
|
890
|
+
block["drift_median"] = (
|
|
891
|
+
ordered[mid] if len(ordered) % 2 else round((ordered[mid - 1] + ordered[mid]) / 2, 1)
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
banned = paused = 0
|
|
895
|
+
for sc in scores if isinstance(scores, list) else []:
|
|
896
|
+
if not isinstance(sc, dict):
|
|
897
|
+
continue
|
|
898
|
+
if _num(sc.get("ban_remaining_sec")) > 0:
|
|
899
|
+
banned += 1
|
|
900
|
+
if _num(sc.get("pause_remaining_sec")) > 0:
|
|
901
|
+
paused += 1
|
|
902
|
+
block["banned_count"] = banned
|
|
903
|
+
block["paused_count"] = paused
|
|
904
|
+
|
|
905
|
+
# Early-warning view: recent records with the biggest lag first, then stale
|
|
906
|
+
rows.sort(key=lambda r: (0 if r["recent"] else (2 if r["stale"] else 1), -r["lag"], r["age_sec"]))
|
|
907
|
+
block["top"] = rows[:8]
|
|
908
|
+
return block
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def epoch_block(local_head):
|
|
912
|
+
"""Epoch progress rate, average epoch interval and stall detection."""
|
|
913
|
+
now = time.time()
|
|
914
|
+
with _lock:
|
|
915
|
+
if not _epoch_history or _epoch_history[-1][1] != local_head:
|
|
916
|
+
_epoch_history.append([round(now, 3), local_head])
|
|
917
|
+
all_points = list(_epoch_history)
|
|
918
|
+
|
|
919
|
+
points = [p for p in all_points if now - p[0] <= 300]
|
|
920
|
+
rate = 0.0
|
|
921
|
+
interval = None
|
|
922
|
+
if len(points) >= 2:
|
|
923
|
+
dt = points[-1][0] - points[0][0]
|
|
924
|
+
d_epoch = points[-1][1] - points[0][1]
|
|
925
|
+
if dt > 0:
|
|
926
|
+
rate = round(d_epoch / dt * 60, 2)
|
|
927
|
+
if d_epoch > 0:
|
|
928
|
+
interval = round(dt / d_epoch, 1)
|
|
929
|
+
|
|
930
|
+
last_change_age = round(max(0.0, now - all_points[-1][0]), 1) if all_points else 0.0
|
|
931
|
+
stalled = bool(all_points) and last_change_age > max(60.0, (interval or 20.0) * 4)
|
|
932
|
+
|
|
933
|
+
return {
|
|
934
|
+
"rate_per_min": rate,
|
|
935
|
+
"interval_sec": interval,
|
|
936
|
+
"last_change_age_sec": last_change_age,
|
|
937
|
+
"stalled": stalled,
|
|
938
|
+
"window_sec": 300,
|
|
939
|
+
"history": [[round(p[0], 1), p[1]] for p in all_points[-120:]],
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def availability_block():
|
|
944
|
+
with _lock:
|
|
945
|
+
samples = list(_avail_history)
|
|
946
|
+
ok = sum(samples)
|
|
947
|
+
pct = round(ok / len(samples) * 100, 1) if samples else 100.0
|
|
948
|
+
return {"samples": len(samples), "healthy": ok, "availability_pct": pct}
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
# -- Wallet: balance, stake and reward attribution -----------------
|
|
952
|
+
#
|
|
953
|
+
# Rewards are credited to the ledger balance at every epoch, so a plain
|
|
954
|
+
# "balance now - balance at setup" delta also moves when the operator sends
|
|
955
|
+
# transactions. The dashboard therefore keeps an additive index instead:
|
|
956
|
+
# every `component = epoch event = reward` line in the node's operator log
|
|
957
|
+
# carries the epoch pot (`each` for a validator, `proposer_total` for the
|
|
958
|
+
# proposer) and the proposer address prefix. Folding those lines gives exact
|
|
959
|
+
# per-epoch attribution plus a local proposer counter, independent of any
|
|
960
|
+
# wallet transfers. The balance baseline captured at setup remains available
|
|
961
|
+
# as a cross-check (and covers epochs that already rotated out of the log).
|
|
962
|
+
def token_amount(raw):
|
|
963
|
+
"""Ledger raw units -> OCTRA amount (6 decimals)."""
|
|
964
|
+
try:
|
|
965
|
+
return round(int(raw) / TOKEN_UNIT, TOKEN_DECIMALS)
|
|
966
|
+
except (TypeError, ValueError):
|
|
967
|
+
return 0.0
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def iso_utc(ts):
|
|
971
|
+
try:
|
|
972
|
+
ts = float(ts)
|
|
973
|
+
except (TypeError, ValueError):
|
|
974
|
+
return ""
|
|
975
|
+
if ts <= 0:
|
|
976
|
+
return ""
|
|
977
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def epoch_seconds(iso):
|
|
981
|
+
"""`2026-10-29T07:23:37.123Z` -> seconds since the epoch (0 when invalid)."""
|
|
982
|
+
text = str(iso or "").strip()
|
|
983
|
+
if len(text) < 19:
|
|
984
|
+
return 0.0
|
|
985
|
+
try:
|
|
986
|
+
return float(calendar.timegm(time.strptime(text[:19], "%Y-%m-%dT%H:%M:%S")))
|
|
987
|
+
except (ValueError, TypeError):
|
|
988
|
+
return 0.0
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _pick_state_path():
|
|
992
|
+
override = os.environ.get(WALLET_STATE_ENV)
|
|
993
|
+
if override:
|
|
994
|
+
return Path(override)
|
|
995
|
+
for folder in WALLET_STATE_DIRS:
|
|
996
|
+
try:
|
|
997
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
998
|
+
if os.access(str(folder), os.W_OK):
|
|
999
|
+
return folder / "wallet_state.json"
|
|
1000
|
+
except OSError:
|
|
1001
|
+
continue
|
|
1002
|
+
return DASHBOARD_DIR / "wallet_state.json"
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def wallet_state_path():
|
|
1006
|
+
if _wallet_state_file["path"] is None:
|
|
1007
|
+
_wallet_state_file["path"] = _pick_state_path()
|
|
1008
|
+
return _wallet_state_file["path"]
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def empty_tracking():
|
|
1012
|
+
return {
|
|
1013
|
+
"log_path": "",
|
|
1014
|
+
"offset": 0,
|
|
1015
|
+
"tail": "",
|
|
1016
|
+
"pending_epoch": 0,
|
|
1017
|
+
"log_size": 0,
|
|
1018
|
+
"first_epoch": 0,
|
|
1019
|
+
"last_epoch": 0,
|
|
1020
|
+
"proposals": 0,
|
|
1021
|
+
"reward_cursor": 0,
|
|
1022
|
+
"reward_raw": 0,
|
|
1023
|
+
"reward_epochs": 0,
|
|
1024
|
+
"proposer_raw": 0,
|
|
1025
|
+
"validator_raw": 0,
|
|
1026
|
+
"last_reward_epoch": 0,
|
|
1027
|
+
"last_reward_raw": 0,
|
|
1028
|
+
"last_reward_at": "",
|
|
1029
|
+
"last_reward_proposer": False,
|
|
1030
|
+
"first_reward_at": "",
|
|
1031
|
+
"started_ts": 0.0,
|
|
1032
|
+
"updated_ts": 0.0,
|
|
1033
|
+
"recent": [],
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def wallet_state_load():
|
|
1038
|
+
try:
|
|
1039
|
+
state = json.loads(wallet_state_path().read_text())
|
|
1040
|
+
except Exception:
|
|
1041
|
+
return {}
|
|
1042
|
+
return state if isinstance(state, dict) else {}
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def wallet_state_save(state):
|
|
1046
|
+
path = wallet_state_path()
|
|
1047
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
1048
|
+
try:
|
|
1049
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1050
|
+
tmp.write_text(json.dumps(state, indent=2, sort_keys=True))
|
|
1051
|
+
os.replace(str(tmp), str(path))
|
|
1052
|
+
return True
|
|
1053
|
+
except OSError:
|
|
1054
|
+
return False
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def wallet_state_init(address, account, epoch, source="setup", force=False):
|
|
1058
|
+
"""Capture the balance baseline. Returns (state, created).
|
|
1059
|
+
|
|
1060
|
+
`force` re-snapshots the balance but keeps the existing log index, so the
|
|
1061
|
+
proposer counter and reward totals survive a manual re-baseline.
|
|
1062
|
+
"""
|
|
1063
|
+
state = wallet_state_load()
|
|
1064
|
+
same_wallet = state.get("address") == address
|
|
1065
|
+
baseline = state.get("baseline") or {}
|
|
1066
|
+
if same_wallet and baseline.get("balance_raw") is not None and not force:
|
|
1067
|
+
return state, False
|
|
1068
|
+
|
|
1069
|
+
balance_raw = _int((account or {}).get("balance_raw"))
|
|
1070
|
+
tracking = state.get("tracking") if same_wallet else None
|
|
1071
|
+
fresh = {
|
|
1072
|
+
"version": 1,
|
|
1073
|
+
"address": address,
|
|
1074
|
+
"baseline": {
|
|
1075
|
+
"balance_raw": balance_raw,
|
|
1076
|
+
"balance": token_amount(balance_raw),
|
|
1077
|
+
"nonce": _int((account or {}).get("nonce")),
|
|
1078
|
+
"epoch": _int(epoch),
|
|
1079
|
+
"ts": round(time.time(), 3),
|
|
1080
|
+
"source": source,
|
|
1081
|
+
},
|
|
1082
|
+
"tracking": tracking if isinstance(tracking, dict) and tracking else empty_tracking(),
|
|
1083
|
+
}
|
|
1084
|
+
wallet_state_save(fresh)
|
|
1085
|
+
return fresh, True
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def node_log_path():
|
|
1089
|
+
"""Locate the node's operator log (pm2 writes the node output there)."""
|
|
1090
|
+
override = os.environ.get("OCTRA_NODE_LOG")
|
|
1091
|
+
candidates = []
|
|
1092
|
+
if override:
|
|
1093
|
+
candidates.append(Path(override))
|
|
1094
|
+
name = parse_env(NODE_ENV_PATH).get("OCTRA_OPERATOR_PM2_NAME", "octra-validator-01")
|
|
1095
|
+
candidates.extend([
|
|
1096
|
+
NODE_SRC_DIR / "data" / "operator_logs" / "node.log",
|
|
1097
|
+
Path(f"/home/octra/.pm2/logs/{name}-out.log"),
|
|
1098
|
+
Path(f"/root/.pm2/logs/{name}-out.log"),
|
|
1099
|
+
])
|
|
1100
|
+
for path in candidates:
|
|
1101
|
+
try:
|
|
1102
|
+
if path.is_file():
|
|
1103
|
+
return path
|
|
1104
|
+
except OSError:
|
|
1105
|
+
continue
|
|
1106
|
+
return None
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
LOG_FIELD_RE = re.compile(r"(\w+) = (\S+)")
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def fold_log_block(text, tracking, address, eligible_from=0):
|
|
1113
|
+
"""Fold a block of operator-log lines into `tracking` (in place).
|
|
1114
|
+
|
|
1115
|
+
Only `component = epoch` lines are inspected:
|
|
1116
|
+
|
|
1117
|
+
* `component = epoch proposer ... epoch = N addr = ...` -> epoch window + cursor
|
|
1118
|
+
* `component = epoch event = reward ... proposer = ...` -> reward attribution
|
|
1119
|
+
|
|
1120
|
+
The node truncates addresses to 12 chars + "..", which is enough to
|
|
1121
|
+
recognise the local validator. Rewards are attributed as
|
|
1122
|
+
`proposer_total + each` when the local wallet proposed the epoch (the
|
|
1123
|
+
proposer also receives its validator share) and `each` otherwise.
|
|
1124
|
+
"""
|
|
1125
|
+
prefix = address[:LOG_ADDR_PREFIX]
|
|
1126
|
+
cursor = _int(tracking.get("reward_cursor"))
|
|
1127
|
+
pending = _int(tracking.get("pending_epoch"))
|
|
1128
|
+
first = _int(tracking.get("first_epoch"))
|
|
1129
|
+
last = _int(tracking.get("last_epoch"))
|
|
1130
|
+
recent = list(tracking.get("recent") or [])
|
|
1131
|
+
first_at = str(tracking.get("first_reward_at") or "")
|
|
1132
|
+
changed = False
|
|
1133
|
+
|
|
1134
|
+
for line in text.splitlines():
|
|
1135
|
+
if "component = epoch" not in line:
|
|
1136
|
+
continue
|
|
1137
|
+
fields = dict(LOG_FIELD_RE.findall(line))
|
|
1138
|
+
epoch = _int(fields.get("epoch"), -1)
|
|
1139
|
+
if epoch > 0:
|
|
1140
|
+
pending = epoch
|
|
1141
|
+
changed = True
|
|
1142
|
+
|
|
1143
|
+
if fields.get("event") != "reward":
|
|
1144
|
+
continue
|
|
1145
|
+
if pending <= 0 or pending <= cursor:
|
|
1146
|
+
continue
|
|
1147
|
+
cursor = pending
|
|
1148
|
+
if eligible_from and pending < eligible_from:
|
|
1149
|
+
continue
|
|
1150
|
+
if _int(fields.get("validators")) <= 0:
|
|
1151
|
+
continue
|
|
1152
|
+
each = _int(fields.get("each"))
|
|
1153
|
+
proposer_total = _int(fields.get("proposer_total"))
|
|
1154
|
+
# The node prints addresses truncated as "<12 chars>.." - trailing dots
|
|
1155
|
+
# carry no meaning for a full address, so drop them before comparing.
|
|
1156
|
+
is_proposer = str(fields.get("proposer") or "").rstrip(".") == prefix
|
|
1157
|
+
amount = (proposer_total + each) if is_proposer else each
|
|
1158
|
+
if amount <= 0:
|
|
1159
|
+
continue
|
|
1160
|
+
|
|
1161
|
+
tracking["reward_raw"] = _int(tracking.get("reward_raw")) + amount
|
|
1162
|
+
tracking["reward_epochs"] = _int(tracking.get("reward_epochs")) + 1
|
|
1163
|
+
if is_proposer:
|
|
1164
|
+
tracking["proposals"] = _int(tracking.get("proposals")) + 1
|
|
1165
|
+
tracking["proposer_raw"] = _int(tracking.get("proposer_raw")) + amount
|
|
1166
|
+
else:
|
|
1167
|
+
tracking["validator_raw"] = _int(tracking.get("validator_raw")) + amount
|
|
1168
|
+
tracking["last_reward_epoch"] = pending
|
|
1169
|
+
tracking["last_reward_raw"] = amount
|
|
1170
|
+
tracking["last_reward_at"] = line[:24]
|
|
1171
|
+
tracking["last_reward_proposer"] = is_proposer
|
|
1172
|
+
if not first_at:
|
|
1173
|
+
first_at = line[:24]
|
|
1174
|
+
tracking["first_reward_at"] = first_at
|
|
1175
|
+
if first <= 0:
|
|
1176
|
+
first = pending
|
|
1177
|
+
if pending > last:
|
|
1178
|
+
last = pending
|
|
1179
|
+
recent.append([pending, "proposer" if is_proposer else "validator", amount, line[:24]])
|
|
1180
|
+
if len(recent) > WALLET_RECENT_MAX:
|
|
1181
|
+
recent = recent[-WALLET_RECENT_MAX:]
|
|
1182
|
+
changed = True
|
|
1183
|
+
|
|
1184
|
+
if changed:
|
|
1185
|
+
tracking["pending_epoch"] = pending
|
|
1186
|
+
tracking["reward_cursor"] = cursor
|
|
1187
|
+
tracking["first_epoch"] = first
|
|
1188
|
+
tracking["last_epoch"] = last
|
|
1189
|
+
tracking["recent"] = recent
|
|
1190
|
+
return changed
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def wallet_scan_log(address, eligible_from=0):
|
|
1194
|
+
"""Index one bounded slice of the operator log. Returns True when it ran."""
|
|
1195
|
+
global _wallet_scanning
|
|
1196
|
+
path = node_log_path()
|
|
1197
|
+
if path is None:
|
|
1198
|
+
return False
|
|
1199
|
+
with _lock:
|
|
1200
|
+
if _wallet_scanning:
|
|
1201
|
+
return False
|
|
1202
|
+
_wallet_scanning = True
|
|
1203
|
+
try:
|
|
1204
|
+
tracking = dict(wallet_state_load().get("tracking") or empty_tracking())
|
|
1205
|
+
try:
|
|
1206
|
+
size = path.stat().st_size
|
|
1207
|
+
except OSError:
|
|
1208
|
+
return False
|
|
1209
|
+
offset = _int(tracking.get("offset"))
|
|
1210
|
+
# A small overshoot is normal: the node appends between stat and read.
|
|
1211
|
+
if offset > 0 and size + 4096 < offset:
|
|
1212
|
+
offset = 0 # log rotated or truncated -> re-index
|
|
1213
|
+
elif offset > 0:
|
|
1214
|
+
# Guard against pm2 rotating the log behind our back: compare the
|
|
1215
|
+
# bytes we already consumed with what is on disk now.
|
|
1216
|
+
try:
|
|
1217
|
+
expected = base64.b64decode(str(tracking.get("tail") or ""))
|
|
1218
|
+
except (ValueError, TypeError):
|
|
1219
|
+
expected = b""
|
|
1220
|
+
if expected:
|
|
1221
|
+
try:
|
|
1222
|
+
with open(path, "rb") as handle:
|
|
1223
|
+
handle.seek(max(0, offset - len(expected)))
|
|
1224
|
+
actual = handle.read(len(expected))
|
|
1225
|
+
if actual != expected:
|
|
1226
|
+
offset = 0
|
|
1227
|
+
except OSError:
|
|
1228
|
+
return False
|
|
1229
|
+
now = time.time()
|
|
1230
|
+
if size <= offset:
|
|
1231
|
+
tracking["log_path"] = str(path)
|
|
1232
|
+
tracking["log_size"] = max(size, offset)
|
|
1233
|
+
tracking["updated_ts"] = round(now, 3)
|
|
1234
|
+
tracking["started_ts"] = tracking.get("started_ts") or round(now, 3)
|
|
1235
|
+
state = wallet_state_load()
|
|
1236
|
+
state["tracking"] = tracking
|
|
1237
|
+
wallet_state_save(state)
|
|
1238
|
+
return True
|
|
1239
|
+
|
|
1240
|
+
try:
|
|
1241
|
+
with open(path, "rb") as handle:
|
|
1242
|
+
handle.seek(offset)
|
|
1243
|
+
chunk = handle.read(WALLET_SCAN_BYTES)
|
|
1244
|
+
except OSError:
|
|
1245
|
+
return False
|
|
1246
|
+
cut = chunk.rfind(b"\n")
|
|
1247
|
+
if cut < 0:
|
|
1248
|
+
return False # no complete line yet
|
|
1249
|
+
block = chunk[: cut + 1]
|
|
1250
|
+
|
|
1251
|
+
fold_log_block(block.decode("utf-8", "replace"), tracking, address, eligible_from)
|
|
1252
|
+
|
|
1253
|
+
with _lock:
|
|
1254
|
+
state = wallet_state_load()
|
|
1255
|
+
merged = state.get("tracking")
|
|
1256
|
+
if not isinstance(merged, dict):
|
|
1257
|
+
merged = empty_tracking()
|
|
1258
|
+
for key in ("pending_epoch", "first_epoch", "last_epoch", "proposals",
|
|
1259
|
+
"reward_cursor", "reward_raw", "reward_epochs", "proposer_raw",
|
|
1260
|
+
"validator_raw", "last_reward_epoch", "last_reward_raw",
|
|
1261
|
+
"last_reward_at", "last_reward_proposer", "first_reward_at"):
|
|
1262
|
+
merged[key] = tracking.get(key, merged.get(key))
|
|
1263
|
+
merged["recent"] = tracking.get("recent") or merged.get("recent") or []
|
|
1264
|
+
merged["offset"] = offset + len(block)
|
|
1265
|
+
merged["tail"] = base64.b64encode(block[-32:]).decode("ascii")
|
|
1266
|
+
merged["log_path"] = str(path)
|
|
1267
|
+
merged["log_size"] = max(size, offset + len(block))
|
|
1268
|
+
merged["updated_ts"] = round(time.time(), 3)
|
|
1269
|
+
if not merged.get("started_ts"):
|
|
1270
|
+
merged["started_ts"] = round(time.time(), 3)
|
|
1271
|
+
state["tracking"] = merged
|
|
1272
|
+
wallet_state_save(state)
|
|
1273
|
+
return True
|
|
1274
|
+
finally:
|
|
1275
|
+
with _lock:
|
|
1276
|
+
_wallet_scanning = False
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def wallet_rpc_snapshot(api_port, address):
|
|
1280
|
+
"""Account + validator enrollment, shared by all clients for a few seconds.
|
|
1281
|
+
|
|
1282
|
+
Only the two cheap reads are synchronous: `octra_balance` (~0.1 s) and
|
|
1283
|
+
`octra_validatorEnrollment` (~0.1 s). `octra_account` costs 10-20 s on a
|
|
1284
|
+
busy node because it walks the tx history, so its extra fields (tx count,
|
|
1285
|
+
encrypted-balance flag) come from a background thread instead - see
|
|
1286
|
+
`wallet_deep_account`.
|
|
1287
|
+
"""
|
|
1288
|
+
now = time.time()
|
|
1289
|
+
with _lock:
|
|
1290
|
+
cached = _wallet_rpc["data"]
|
|
1291
|
+
if cached and _wallet_rpc["address"] == address and (now - _wallet_rpc["ts"]) < WALLET_RPC_TTL:
|
|
1292
|
+
return cached
|
|
1293
|
+
balance = rpc_call_method(api_port, "octra_balance", [address], timeout=6)
|
|
1294
|
+
enrollment = rpc_call_method(api_port, "octra_validatorEnrollment", [address], timeout=6)
|
|
1295
|
+
detail = wallet_deep_account(api_port, address)
|
|
1296
|
+
data = {
|
|
1297
|
+
"balance": balance if isinstance(balance, dict) else None,
|
|
1298
|
+
"enrollment": enrollment if isinstance(enrollment, dict) else None,
|
|
1299
|
+
"detail": detail if isinstance(detail, dict) else None,
|
|
1300
|
+
"ts": round(time.time(), 3),
|
|
1301
|
+
}
|
|
1302
|
+
with _lock:
|
|
1303
|
+
_wallet_rpc.update({"ts": now, "address": address, "data": data})
|
|
1304
|
+
return data
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def wallet_deep_account(api_port, address):
|
|
1308
|
+
"""`octra_account` detail, fetched in the background with a long TTL.
|
|
1309
|
+
|
|
1310
|
+
Returns the last good sample (or None while the first one is still being
|
|
1311
|
+
collected) so a poll never blocks on the slow history query.
|
|
1312
|
+
"""
|
|
1313
|
+
now = time.time()
|
|
1314
|
+
with _lock:
|
|
1315
|
+
same = _wallet_deep["address"] == address
|
|
1316
|
+
data = _wallet_deep["data"] if same else None
|
|
1317
|
+
if data is not None and (now - _wallet_deep["ts"]) < WALLET_DEEP_TTL:
|
|
1318
|
+
return data
|
|
1319
|
+
if _wallet_deep["running"] or (now - _wallet_deep["attempt"]) < WALLET_DEEP_RETRY:
|
|
1320
|
+
return data
|
|
1321
|
+
_wallet_deep["address"] = address
|
|
1322
|
+
_wallet_deep["attempt"] = now
|
|
1323
|
+
_wallet_deep["running"] = True
|
|
1324
|
+
|
|
1325
|
+
def worker():
|
|
1326
|
+
result = rpc_call_method(api_port, "octra_account", [address], timeout=60)
|
|
1327
|
+
with _lock:
|
|
1328
|
+
if isinstance(result, dict):
|
|
1329
|
+
_wallet_deep["data"] = result
|
|
1330
|
+
_wallet_deep["ts"] = time.time()
|
|
1331
|
+
_wallet_deep["running"] = False
|
|
1332
|
+
|
|
1333
|
+
threading.Thread(target=worker, name="wallet-account", daemon=True).start()
|
|
1334
|
+
return data
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def wallet_scan_due(tracking):
|
|
1339
|
+
caught_up = (_int(tracking.get("log_size")) > 0 and
|
|
1340
|
+
_int(tracking.get("offset")) >= _int(tracking.get("log_size")))
|
|
1341
|
+
ttl = WALLET_SCAN_TTL if caught_up else WALLET_CATCHUP
|
|
1342
|
+
return (time.time() - _num(tracking.get("updated_ts"))) >= ttl
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def wallet_skeleton(address=""):
|
|
1346
|
+
"""Full payload shaped up front so the UI never guards for missing keys."""
|
|
1347
|
+
return {
|
|
1348
|
+
"available": False,
|
|
1349
|
+
"address": address,
|
|
1350
|
+
"reason": "Wallet address is not configured on this node",
|
|
1351
|
+
"balance": 0.0,
|
|
1352
|
+
"balance_raw": "0",
|
|
1353
|
+
"holdings": 0.0,
|
|
1354
|
+
"nonce": 0,
|
|
1355
|
+
"pending_nonce": 0,
|
|
1356
|
+
"tx_count": 0,
|
|
1357
|
+
"tx_count_known": False,
|
|
1358
|
+
"detail_known": False,
|
|
1359
|
+
"has_public_key": False,
|
|
1360
|
+
"encrypted_balance": False,
|
|
1361
|
+
"bond": 0.0,
|
|
1362
|
+
"bond_raw": "0",
|
|
1363
|
+
"state": "unknown",
|
|
1364
|
+
"bonded_epoch": 0,
|
|
1365
|
+
"ready_epoch": 0,
|
|
1366
|
+
"exit_epoch": 0,
|
|
1367
|
+
"epochs_in_set": 0,
|
|
1368
|
+
"membership": {"active": False, "scheduled": False,
|
|
1369
|
+
"activate_epoch": 0, "next_set_epoch": 0},
|
|
1370
|
+
"duty": {"automatic": False, "required_span": 0, "max_gap": 0, "last_pulse": None},
|
|
1371
|
+
"rewards": {
|
|
1372
|
+
"total": 0.0, "total_raw": "0", "epochs": 0,
|
|
1373
|
+
"proposer": 0.0, "proposer_raw": "0", "proposer_epochs": 0,
|
|
1374
|
+
"validator": 0.0, "validator_raw": "0",
|
|
1375
|
+
"avg_per_epoch": 0.0, "est_per_day": 0.0, "epochs_per_day": 0.0,
|
|
1376
|
+
"share_pct": 0.0,
|
|
1377
|
+
"last_epoch": 0, "last_amount": 0.0, "last_role": "",
|
|
1378
|
+
"last_at": "", "epochs_since": None, "recent": [],
|
|
1379
|
+
},
|
|
1380
|
+
"baseline": {
|
|
1381
|
+
"balance": 0.0, "balance_raw": "0", "epoch": 0,
|
|
1382
|
+
"at": "", "age_sec": None, "source": "",
|
|
1383
|
+
"delta": 0.0, "delta_raw": "0",
|
|
1384
|
+
},
|
|
1385
|
+
"tracking": {
|
|
1386
|
+
"log_path": "", "state_file": "", "indexed_bytes": 0, "log_size": 0,
|
|
1387
|
+
"progress_pct": 100.0, "first_epoch": 0, "last_epoch": 0,
|
|
1388
|
+
"span_epochs": 0, "since": "", "updated": "",
|
|
1389
|
+
},
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def wallet_block(api_port, node):
|
|
1394
|
+
"""Balance, stake, proposer count and reward attribution for the wallet."""
|
|
1395
|
+
address = node.get("address") or ""
|
|
1396
|
+
head_epoch = _int(node.get("head_epoch"))
|
|
1397
|
+
block = wallet_skeleton("" if address == "unknown" else address)
|
|
1398
|
+
if not address or address == "unknown":
|
|
1399
|
+
return block
|
|
1400
|
+
|
|
1401
|
+
snap = wallet_rpc_snapshot(api_port, address)
|
|
1402
|
+
account = snap.get("balance") or {}
|
|
1403
|
+
enrollment = snap.get("enrollment") or {}
|
|
1404
|
+
detail = snap.get("detail") or {}
|
|
1405
|
+
if not account and not enrollment:
|
|
1406
|
+
block["reason"] = "node RPC did not answer octra_balance / octra_validatorEnrollment"
|
|
1407
|
+
return block
|
|
1408
|
+
|
|
1409
|
+
balance_raw = _int(account.get("balance_raw"))
|
|
1410
|
+
bond_raw = _int(enrollment.get("bond"))
|
|
1411
|
+
bonded_epoch = _int(enrollment.get("bonded_epoch"))
|
|
1412
|
+
membership = enrollment.get("membership") or {}
|
|
1413
|
+
duty = enrollment.get("duty") or {}
|
|
1414
|
+
|
|
1415
|
+
state = wallet_state_load()
|
|
1416
|
+
if state.get("address") != address or not state.get("baseline"):
|
|
1417
|
+
state, _ = wallet_state_init(address, account, head_epoch,
|
|
1418
|
+
source="first dashboard observation")
|
|
1419
|
+
tracking = dict(state.get("tracking") or empty_tracking())
|
|
1420
|
+
if wallet_scan_due(tracking) and wallet_scan_log(address, bonded_epoch):
|
|
1421
|
+
state = wallet_state_load()
|
|
1422
|
+
tracking = dict(state.get("tracking") or empty_tracking())
|
|
1423
|
+
|
|
1424
|
+
baseline = state.get("baseline") or {}
|
|
1425
|
+
baseline_raw = _int(baseline.get("balance_raw"))
|
|
1426
|
+
|
|
1427
|
+
reward_raw = _int(tracking.get("reward_raw"))
|
|
1428
|
+
reward_epochs = _int(tracking.get("reward_epochs"))
|
|
1429
|
+
proposals = _int(tracking.get("proposals"))
|
|
1430
|
+
first_epoch = _int(tracking.get("first_epoch"))
|
|
1431
|
+
last_epoch = _int(tracking.get("last_epoch"))
|
|
1432
|
+
last_reward_epoch = _int(tracking.get("last_reward_epoch"))
|
|
1433
|
+
span = (last_epoch - first_epoch + 1) if first_epoch and last_epoch >= first_epoch else 0
|
|
1434
|
+
interval = _num((node.get("epochs") or {}).get("interval_sec"))
|
|
1435
|
+
avg_raw = (reward_raw / reward_epochs) if reward_epochs else 0.0
|
|
1436
|
+
# Prefer the measured rate of the indexed window (robust right after a
|
|
1437
|
+
# deploy); fall back to the live epoch interval once that is known.
|
|
1438
|
+
epochs_per_day = 0.0
|
|
1439
|
+
first_ts = epoch_seconds(tracking.get("first_reward_at"))
|
|
1440
|
+
last_ts = epoch_seconds(tracking.get("last_reward_at"))
|
|
1441
|
+
if reward_epochs > 1 and last_ts > first_ts + 300:
|
|
1442
|
+
epochs_per_day = (reward_epochs - 1) / (last_ts - first_ts) * 86400.0
|
|
1443
|
+
if epochs_per_day <= 0 and interval > 0:
|
|
1444
|
+
epochs_per_day = 86400.0 / interval
|
|
1445
|
+
per_day_raw = avg_raw * epochs_per_day if epochs_per_day else 0.0
|
|
1446
|
+
offset = _int(tracking.get("offset"))
|
|
1447
|
+
log_size = _int(tracking.get("log_size"))
|
|
1448
|
+
|
|
1449
|
+
block.update({
|
|
1450
|
+
"available": True,
|
|
1451
|
+
"reason": "",
|
|
1452
|
+
"balance": token_amount(balance_raw),
|
|
1453
|
+
"balance_raw": str(balance_raw),
|
|
1454
|
+
"holdings": token_amount(balance_raw + bond_raw),
|
|
1455
|
+
"nonce": _int(account.get("nonce")),
|
|
1456
|
+
"pending_nonce": _int(account.get("pending_nonce")),
|
|
1457
|
+
"tx_count": _int(detail.get("tx_count")),
|
|
1458
|
+
"tx_count_known": bool(detail),
|
|
1459
|
+
"has_public_key": bool(account.get("has_public_key")),
|
|
1460
|
+
"encrypted_balance": bool(detail.get("has_encrypted_balance")),
|
|
1461
|
+
"detail_known": bool(detail),
|
|
1462
|
+
"bond": token_amount(bond_raw),
|
|
1463
|
+
"bond_raw": str(bond_raw),
|
|
1464
|
+
"state": str(enrollment.get("state") or "unknown"),
|
|
1465
|
+
"bonded_epoch": bonded_epoch,
|
|
1466
|
+
"ready_epoch": _int(enrollment.get("ready_epoch")),
|
|
1467
|
+
"exit_epoch": _int(enrollment.get("exit_epoch")),
|
|
1468
|
+
"epochs_in_set": max(0, head_epoch - bonded_epoch) if bonded_epoch else 0,
|
|
1469
|
+
"membership": {
|
|
1470
|
+
"active": bool(membership.get("active")),
|
|
1471
|
+
"scheduled": bool(membership.get("scheduled")),
|
|
1472
|
+
"activate_epoch": _int(membership.get("activate_epoch")),
|
|
1473
|
+
"next_set_epoch": _int(membership.get("next_set_epoch")),
|
|
1474
|
+
},
|
|
1475
|
+
"duty": {
|
|
1476
|
+
"automatic": bool(duty.get("automatic")),
|
|
1477
|
+
"required_span": _int(duty.get("required_span")),
|
|
1478
|
+
"max_gap": _int(duty.get("max_gap")),
|
|
1479
|
+
"last_pulse": duty.get("last_pulse"),
|
|
1480
|
+
},
|
|
1481
|
+
})
|
|
1482
|
+
return _wallet_reward_block(block, tracking, baseline, baseline_raw, balance_raw,
|
|
1483
|
+
head_epoch, span, avg_raw, per_day_raw, offset, log_size,
|
|
1484
|
+
epochs_per_day)
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _wallet_reward_block(block, tracking, baseline, baseline_raw, balance_raw,
|
|
1488
|
+
head_epoch, span, avg_raw, per_day_raw, offset=0, log_size=0,
|
|
1489
|
+
epochs_per_day=0.0, bonded_epoch=0, nonce=0):
|
|
1490
|
+
"""Reward attribution + baseline cross-check for `wallet_block`.
|
|
1491
|
+
|
|
1492
|
+
The headline total is the balance delta since the setup snapshot, because
|
|
1493
|
+
the log is only a partial view. That delta is exact only while nothing else
|
|
1494
|
+
moved the wallet; a bond that started before the snapshot or a transaction
|
|
1495
|
+
signed after it both blur the number, so the block says so (`est` + `note`)
|
|
1496
|
+
and keeps the gross log fold next to it as a cross-check.
|
|
1497
|
+
"""
|
|
1498
|
+
reward_raw = _int(tracking.get("reward_raw"))
|
|
1499
|
+
reward_epochs = _int(tracking.get("reward_epochs"))
|
|
1500
|
+
proposals = _int(tracking.get("proposals"))
|
|
1501
|
+
last_reward_epoch = _int(tracking.get("last_reward_epoch"))
|
|
1502
|
+
|
|
1503
|
+
gross_raw = reward_raw # the log fold, independent view
|
|
1504
|
+
baseline_epoch = _int(baseline.get("epoch"))
|
|
1505
|
+
has_baseline = bool(baseline) and baseline_raw > 0
|
|
1506
|
+
if has_baseline:
|
|
1507
|
+
total_raw = balance_raw - baseline_raw
|
|
1508
|
+
method = "balance-delta"
|
|
1509
|
+
covered = max(0, head_epoch - baseline_epoch)
|
|
1510
|
+
else:
|
|
1511
|
+
total_raw = gross_raw
|
|
1512
|
+
method = "log-fold"
|
|
1513
|
+
covered = reward_epochs
|
|
1514
|
+
|
|
1515
|
+
base_bond = _int(baseline.get("bonded_epoch")) or _int(bonded_epoch)
|
|
1516
|
+
bond_at = _int(bonded_epoch) # the bond epoch the node reports
|
|
1517
|
+
base_nonce = _int(baseline.get("nonce"))
|
|
1518
|
+
signed_since = max(0, _int(nonce) - base_nonce) if base_nonce else 0
|
|
1519
|
+
|
|
1520
|
+
reasons = []
|
|
1521
|
+
if not has_baseline:
|
|
1522
|
+
reasons.append("no baseline snapshot yet, showing the log fold")
|
|
1523
|
+
if has_baseline and bond_at and baseline_epoch and bond_at < baseline_epoch:
|
|
1524
|
+
reasons.append("the bond started at epoch %d" % bond_at)
|
|
1525
|
+
if signed_since:
|
|
1526
|
+
reasons.append("signed %d tx after the snapshot" % signed_since)
|
|
1527
|
+
est = bool(reasons)
|
|
1528
|
+
note = ("; ".join(reasons) + " - estimate from the balance delta") if reasons \
|
|
1529
|
+
else "exact: balance delta with no other movement"
|
|
1530
|
+
|
|
1531
|
+
# Split follows the gross proportion, then lands exactly on the total.
|
|
1532
|
+
proposer_raw = _int(tracking.get("proposer_raw"))
|
|
1533
|
+
validator_raw = _int(tracking.get("validator_raw"))
|
|
1534
|
+
if gross_raw and total_raw != gross_raw:
|
|
1535
|
+
proposer_raw = int(round(proposer_raw * total_raw / gross_raw))
|
|
1536
|
+
validator_raw = max(0, total_raw - proposer_raw)
|
|
1537
|
+
avg_value = (total_raw / covered) if covered else 0.0
|
|
1538
|
+
|
|
1539
|
+
block["rewards"] = {
|
|
1540
|
+
"total": token_amount(total_raw),
|
|
1541
|
+
"total_raw": str(total_raw),
|
|
1542
|
+
"epochs": covered,
|
|
1543
|
+
"est": est,
|
|
1544
|
+
"method": method,
|
|
1545
|
+
"note": note,
|
|
1546
|
+
"log_total": token_amount(gross_raw),
|
|
1547
|
+
"log_total_raw": str(gross_raw),
|
|
1548
|
+
"log_ratio_pct": round(total_raw / gross_raw * 100, 1) if gross_raw else 0.0,
|
|
1549
|
+
"proposer": token_amount(proposer_raw),
|
|
1550
|
+
"proposer_raw": str(proposer_raw),
|
|
1551
|
+
"proposer_epochs": proposals,
|
|
1552
|
+
"validator": token_amount(validator_raw),
|
|
1553
|
+
"validator_raw": str(validator_raw),
|
|
1554
|
+
"avg_per_epoch": round(avg_value / TOKEN_UNIT, TOKEN_DECIMALS),
|
|
1555
|
+
"est_per_day": round(avg_value / TOKEN_UNIT * epochs_per_day, TOKEN_DECIMALS),
|
|
1556
|
+
"epochs_per_day": round(epochs_per_day, 1),
|
|
1557
|
+
"share_pct": round(proposals / span * 100, 1) if span else 0.0,
|
|
1558
|
+
"last_epoch": last_reward_epoch,
|
|
1559
|
+
"last_amount": token_amount(_int(tracking.get("last_reward_raw"))),
|
|
1560
|
+
"last_role": ("proposer" if tracking.get("last_reward_proposer") else "validator")
|
|
1561
|
+
if last_reward_epoch else "",
|
|
1562
|
+
"last_at": str(tracking.get("last_reward_at") or ""),
|
|
1563
|
+
"epochs_since": max(0, head_epoch - last_reward_epoch) if last_reward_epoch else None,
|
|
1564
|
+
"recent": [
|
|
1565
|
+
{"epoch": _int(row[0]), "role": str(row[1]),
|
|
1566
|
+
"amount": token_amount(_int(row[2])),
|
|
1567
|
+
"at": str(row[3]) if len(row) > 3 else ""}
|
|
1568
|
+
for row in list(tracking.get("recent") or [])[::-1]
|
|
1569
|
+
],
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
age = (time.time() - _num(baseline.get("ts"))) if baseline.get("ts") else None
|
|
1573
|
+
block["baseline"] = {
|
|
1574
|
+
"balance": token_amount(baseline_raw),
|
|
1575
|
+
"balance_raw": str(baseline_raw),
|
|
1576
|
+
"epoch": baseline_epoch,
|
|
1577
|
+
"bonded_epoch": base_bond,
|
|
1578
|
+
"nonce": base_nonce,
|
|
1579
|
+
"signed_since": signed_since,
|
|
1580
|
+
"at": iso_utc(baseline.get("ts")),
|
|
1581
|
+
"age_sec": round(age, 1) if age is not None and age >= 0 else None,
|
|
1582
|
+
"source": str(baseline.get("source") or ""),
|
|
1583
|
+
"delta": token_amount(balance_raw - baseline_raw),
|
|
1584
|
+
"delta_raw": str(balance_raw - baseline_raw),
|
|
1585
|
+
}
|
|
1586
|
+
block["tracking"] = {
|
|
1587
|
+
"log_path": str(tracking.get("log_path") or ""),
|
|
1588
|
+
"state_file": str(wallet_state_path()),
|
|
1589
|
+
"indexed_bytes": offset,
|
|
1590
|
+
"log_size": log_size,
|
|
1591
|
+
"progress_pct": round(min(100.0, offset / log_size * 100), 1) if log_size else 100.0,
|
|
1592
|
+
"first_epoch": _int(tracking.get("first_epoch")),
|
|
1593
|
+
"last_epoch": _int(tracking.get("last_epoch")),
|
|
1594
|
+
"span_epochs": span,
|
|
1595
|
+
"since": iso_utc(tracking.get("started_ts")),
|
|
1596
|
+
"updated": iso_utc(tracking.get("updated_ts")),
|
|
1597
|
+
}
|
|
1598
|
+
return block
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def init_wallet_state(reset=False):
|
|
1602
|
+
"""CLI: capture (or re-capture) the wallet baseline. Returns an exit code."""
|
|
1603
|
+
api_port = get_api_port()
|
|
1604
|
+
status = rpc_get_status(api_port) or {}
|
|
1605
|
+
address = status.get("validator") or ""
|
|
1606
|
+
if not address:
|
|
1607
|
+
try:
|
|
1608
|
+
address = json.loads(
|
|
1609
|
+
(NODE_SRC_DIR / ".keys" / "validator" / "wallet.json").read_text()
|
|
1610
|
+
).get("address", "")
|
|
1611
|
+
except Exception:
|
|
1612
|
+
address = ""
|
|
1613
|
+
if not address:
|
|
1614
|
+
print("wallet: validator address unknown (RPC and wallet.json unavailable)")
|
|
1615
|
+
return 1
|
|
1616
|
+
|
|
1617
|
+
account = rpc_call_method(api_port, "octra_balance", [address], timeout=6) or {}
|
|
1618
|
+
epoch = _int(status.get("head_epoch") or status.get("current_epoch"))
|
|
1619
|
+
state, created = wallet_state_init(
|
|
1620
|
+
address, account, epoch,
|
|
1621
|
+
source="dashboard deploy" if not reset else "manual reset",
|
|
1622
|
+
force=reset,
|
|
1623
|
+
)
|
|
1624
|
+
baseline = state.get("baseline") or {}
|
|
1625
|
+
tracking = state.get("tracking") or {}
|
|
1626
|
+
verb = "captured" if created else "already captured"
|
|
1627
|
+
print(f"wallet: baseline {verb} for {address}")
|
|
1628
|
+
print(f" balance : {baseline.get('balance')} OCTRA "
|
|
1629
|
+
f"({baseline.get('balance_raw')} raw @ epoch {baseline.get('epoch')})")
|
|
1630
|
+
print(f" state file: {wallet_state_path()}")
|
|
1631
|
+
print(f" log index : {tracking.get('offset', 0)} bytes @ "
|
|
1632
|
+
f"{iso_utc(tracking.get('updated_ts')) or 'not started'}")
|
|
1633
|
+
return 0
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
# -- Status assembly -----------------------------------------------
|
|
1642
|
+
def build_status():
|
|
1643
|
+
api_port = get_api_port()
|
|
1644
|
+
node_env = parse_env(NODE_ENV_PATH)
|
|
1645
|
+
|
|
1646
|
+
status = rpc_get_status(api_port)
|
|
1647
|
+
peers_result = rpc_call_method(api_port, "octra_consensusPeerStates")
|
|
1648
|
+
|
|
1649
|
+
node = {
|
|
1650
|
+
"rpc_ready": status is not None,
|
|
1651
|
+
"api_port": api_port,
|
|
1652
|
+
"role": node_env.get("OCTRA_OPERATOR_ROLE", "unknown"),
|
|
1653
|
+
"name": node_env.get("OCTRA_OPERATOR_PM2_NAME", "unknown"),
|
|
1654
|
+
"chain": node_env.get("OCTRA_CHAIN_ID", "unknown"),
|
|
1655
|
+
"data_dir": node_env.get("OCTRA_DATA_DIR", str(NODE_DATA_ROOT / "devnet")),
|
|
1656
|
+
"address": "unknown",
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if status:
|
|
1660
|
+
node.update({
|
|
1661
|
+
"current_epoch": _int(status.get("current_epoch")),
|
|
1662
|
+
"head_epoch": _int(status.get("head_epoch") or status.get("current_epoch")),
|
|
1663
|
+
"state_root": status.get("state_root", ""),
|
|
1664
|
+
"irmin_commit": status.get("irmin_commit", ""),
|
|
1665
|
+
"txid_hi": str(status.get("txid_hi", "")),
|
|
1666
|
+
"accounts": _int(status.get("total_accounts")),
|
|
1667
|
+
"active_accounts": _int(status.get("active_accounts")),
|
|
1668
|
+
"total_supply": str(status.get("total_supply", "")),
|
|
1669
|
+
"circulating_supply": str(status.get("circulating_supply", "")),
|
|
1670
|
+
"encrypted_supply": str(status.get("encrypted_supply", "")),
|
|
1671
|
+
"network_version": status.get("network_version", ""),
|
|
1672
|
+
"storage_backend": status.get("storage_backend", ""),
|
|
1673
|
+
"root_count": _int(status.get("root_count")),
|
|
1674
|
+
})
|
|
1675
|
+
if status.get("validator"):
|
|
1676
|
+
node["address"] = status.get("validator")
|
|
1677
|
+
node["pending_epochs"] = max(
|
|
1678
|
+
0, node.get("current_epoch", 0) - node.get("head_epoch", 0)
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
# Wallet address fallback (when the RPC does not report a validator)
|
|
1682
|
+
if node["address"] == "unknown":
|
|
1683
|
+
for wallet_path in (
|
|
1684
|
+
NODE_SRC_DIR / ".keys" / "validator" / "wallet.json",
|
|
1685
|
+
Path(str(node.get("data_dir") or "")) / "wallet.json",
|
|
1686
|
+
):
|
|
1687
|
+
if wallet_path.is_file():
|
|
1688
|
+
try:
|
|
1689
|
+
node["address"] = json.loads(wallet_path.read_text()).get("address", "unknown")
|
|
1690
|
+
break
|
|
1691
|
+
except Exception:
|
|
1692
|
+
pass
|
|
1693
|
+
|
|
1694
|
+
commit_path = NODE_SRC_DIR / "SOURCE_COMMIT"
|
|
1695
|
+
if commit_path.is_file():
|
|
1696
|
+
try:
|
|
1697
|
+
node["source_commit"] = commit_path.read_text().strip()[:12]
|
|
1698
|
+
except Exception:
|
|
1699
|
+
node["source_commit"] = ""
|
|
1700
|
+
node.setdefault("source_commit", "")
|
|
1701
|
+
|
|
1702
|
+
consensus = consensus_block(peers_result)
|
|
1703
|
+
local_head = node.get("head_epoch", 0)
|
|
1704
|
+
node["epochs"] = epoch_block(local_head)
|
|
1705
|
+
peers = peers_block(peers_result, local_head, node["epochs"].get("interval_sec"))
|
|
1706
|
+
|
|
1707
|
+
node["quorum_pct"] = consensus["quorum"].get("prevote_pct", 0.0)
|
|
1708
|
+
node["peer_max_lag"] = peers["lag_max"]
|
|
1709
|
+
|
|
1710
|
+
node["process"] = pm2_info()
|
|
1711
|
+
proc_online = str(node["process"].get("status", "")).lower() == "online"
|
|
1712
|
+
|
|
1713
|
+
if status is not None and consensus.get("voting") == "enabled":
|
|
1714
|
+
node["status"] = "validator_active"
|
|
1715
|
+
elif status is not None:
|
|
1716
|
+
node["status"] = "observer_synced"
|
|
1717
|
+
elif proc_online:
|
|
1718
|
+
node["status"] = "syncing"
|
|
1719
|
+
else:
|
|
1720
|
+
node["status"] = "offline"
|
|
1721
|
+
|
|
1722
|
+
healthy = status is not None and proc_online
|
|
1723
|
+
with _lock:
|
|
1724
|
+
_avail_history.append(1 if healthy else 0)
|
|
1725
|
+
|
|
1726
|
+
logs = log_summary(pm2_log_tail_cached(120))
|
|
1727
|
+
backend = availability_block()
|
|
1728
|
+
backend.update({
|
|
1729
|
+
"version": "2.1",
|
|
1730
|
+
"uptime_secs": int(time.time() - _start_time),
|
|
1731
|
+
"uptime": human_age(time.time() - _start_time),
|
|
1732
|
+
})
|
|
1733
|
+
|
|
1734
|
+
return {
|
|
1735
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
1736
|
+
"server_time": round(time.time(), 3),
|
|
1737
|
+
"node": node,
|
|
1738
|
+
"consensus": consensus,
|
|
1739
|
+
"peers": peers,
|
|
1740
|
+
"wallet": wallet_block(api_port, node),
|
|
1741
|
+
"system": system_info(),
|
|
1742
|
+
"logs": logs,
|
|
1743
|
+
"backend": backend,
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def build_wallet():
|
|
1748
|
+
"""Wallet block only - cheaper than /api/status for a wallet-only client."""
|
|
1749
|
+
api_port = get_api_port()
|
|
1750
|
+
status = rpc_get_status(api_port) or {}
|
|
1751
|
+
head = _int(status.get("head_epoch") or status.get("current_epoch"))
|
|
1752
|
+
node = {
|
|
1753
|
+
"address": status.get("validator") or "",
|
|
1754
|
+
"head_epoch": head,
|
|
1755
|
+
"epochs": epoch_block(head),
|
|
1756
|
+
}
|
|
1757
|
+
if not node["address"]:
|
|
1758
|
+
for wallet_path in (
|
|
1759
|
+
NODE_SRC_DIR / ".keys" / "validator" / "wallet.json",
|
|
1760
|
+
NODE_DATA_ROOT / "devnet" / "wallet.json",
|
|
1761
|
+
):
|
|
1762
|
+
try:
|
|
1763
|
+
node["address"] = json.loads(wallet_path.read_text()).get("address", "")
|
|
1764
|
+
if node["address"]:
|
|
1765
|
+
break
|
|
1766
|
+
except Exception:
|
|
1767
|
+
continue
|
|
1768
|
+
return {"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
1769
|
+
"wallet": wallet_block(api_port, node)}
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
# -- HTTP handler --------------------------------------------------
|
|
1773
|
+
class DashboardHandler(SimpleHTTPRequestHandler):
|
|
1774
|
+
server_version = "OctraDashboard/2.1"
|
|
1775
|
+
|
|
1776
|
+
def __init__(self, *args, **kwargs):
|
|
1777
|
+
super().__init__(*args, directory=str(DASHBOARD_DIR), **kwargs)
|
|
1778
|
+
|
|
1779
|
+
# The monitor is read-only. Answer POST with a clear 405 instead of the base
|
|
1780
|
+
# class's bare 501, so a stale page (or an old bookmark) tells the operator
|
|
1781
|
+
# where the manage actions actually live.
|
|
1782
|
+
def do_POST(self):
|
|
1783
|
+
self._json(
|
|
1784
|
+
{"error": "this monitor is read-only - use the console's 'Manage active node' tab"},
|
|
1785
|
+
status=405,
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
def do_GET(self):
|
|
1789
|
+
parsed = urlparse(self.path)
|
|
1790
|
+
route = parsed.path
|
|
1791
|
+
try:
|
|
1792
|
+
if route == "/api/status":
|
|
1793
|
+
self._json(build_status())
|
|
1794
|
+
elif route == "/api/wallet":
|
|
1795
|
+
self._json(build_wallet())
|
|
1796
|
+
elif route == "/api/logs":
|
|
1797
|
+
qs = parse_qs(parsed.query)
|
|
1798
|
+
try:
|
|
1799
|
+
lines = max(1, min(MAX_LOG_LINES, int(qs.get("lines", ["200"])[0])))
|
|
1800
|
+
except ValueError:
|
|
1801
|
+
lines = 200
|
|
1802
|
+
levels = qs.get("level", [""])[0]
|
|
1803
|
+
entries = pm2_log_tail_cached(lines)
|
|
1804
|
+
if levels:
|
|
1805
|
+
wanted = {l.strip().lower() for l in levels.split(",") if l.strip()}
|
|
1806
|
+
entries = [
|
|
1807
|
+
e for e in entries
|
|
1808
|
+
if e.get("level") in wanted
|
|
1809
|
+
or (e.get("level") == "fatal" and "error" in wanted)
|
|
1810
|
+
]
|
|
1811
|
+
self._json({"logs": entries, "summary": log_summary(entries)})
|
|
1812
|
+
elif route == "/api/ping":
|
|
1813
|
+
self._json({"ok": True, "server_time": round(time.time(), 3)})
|
|
1814
|
+
elif route.startswith("/api/"):
|
|
1815
|
+
self._json({"error": "unknown endpoint"}, status=404)
|
|
1816
|
+
else:
|
|
1817
|
+
super().do_GET()
|
|
1818
|
+
except BrokenPipeError:
|
|
1819
|
+
pass
|
|
1820
|
+
except Exception as exc: # never kill the service on a bad request
|
|
1821
|
+
try:
|
|
1822
|
+
self._json({"error": str(exc)}, status=500)
|
|
1823
|
+
except Exception:
|
|
1824
|
+
pass
|
|
1825
|
+
|
|
1826
|
+
def end_headers(self):
|
|
1827
|
+
# Static assets: let nginx add caching; the Python dev server stays fresh.
|
|
1828
|
+
if not self.path.startswith("/api/"):
|
|
1829
|
+
self.send_header("Cache-Control", "no-cache, must-revalidate")
|
|
1830
|
+
super().end_headers()
|
|
1831
|
+
|
|
1832
|
+
def _json(self, data, status=200):
|
|
1833
|
+
try:
|
|
1834
|
+
# ascii_deep(): the last line of defence - whatever the node, an RPC
|
|
1835
|
+
# error or a log line carried cannot reach the browser as non-ASCII
|
|
1836
|
+
body = json.dumps(ascii_deep(data), default=str).encode("utf-8")
|
|
1837
|
+
self.send_response(status)
|
|
1838
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1839
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
1840
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1841
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1842
|
+
self.end_headers()
|
|
1843
|
+
self.wfile.write(body)
|
|
1844
|
+
except Exception:
|
|
1845
|
+
pass
|
|
1846
|
+
|
|
1847
|
+
def log_message(self, fmt, *args):
|
|
1848
|
+
pass # nginx handles access logging
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
# -- Main ----------------------------------------------------------
|
|
1852
|
+
def main():
|
|
1853
|
+
parser = argparse.ArgumentParser(description="Octra node dashboard backend")
|
|
1854
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
1855
|
+
# Bind to loopback: nginx (or an SSH tunnel) is the only intended client.
|
|
1856
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
1857
|
+
parser.add_argument("--init-wallet", action="store_true",
|
|
1858
|
+
help="capture the wallet reward baseline (idempotent) and exit")
|
|
1859
|
+
parser.add_argument("--reset-wallet", action="store_true",
|
|
1860
|
+
help="re-capture the wallet balance baseline, keeping the log index")
|
|
1861
|
+
args = parser.parse_args()
|
|
1862
|
+
|
|
1863
|
+
if args.init_wallet or args.reset_wallet:
|
|
1864
|
+
raise SystemExit(init_wallet_state(reset=args.reset_wallet))
|
|
1865
|
+
|
|
1866
|
+
# Prime the counters so the first request already reports real rates.
|
|
1867
|
+
try:
|
|
1868
|
+
system_info()
|
|
1869
|
+
time.sleep(0.5)
|
|
1870
|
+
system_info()
|
|
1871
|
+
except Exception:
|
|
1872
|
+
pass
|
|
1873
|
+
|
|
1874
|
+
httpd = ThreadingHTTPServer((args.host, args.port), DashboardHandler)
|
|
1875
|
+
print(f"Octra dashboard -> http://{args.host}:{args.port}")
|
|
1876
|
+
print(" /api/status -> node, consensus, peers, wallet, system snapshot")
|
|
1877
|
+
print(" /api/wallet -> wallet, stake and reward attribution only")
|
|
1878
|
+
print(" /api/logs -> parsed log tail (?lines=N&level=warn,error)")
|
|
1879
|
+
print(" /api/ping -> liveness probe")
|
|
1880
|
+
try:
|
|
1881
|
+
httpd.serve_forever()
|
|
1882
|
+
except KeyboardInterrupt:
|
|
1883
|
+
httpd.shutdown()
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
if __name__ == "__main__":
|
|
1887
|
+
main()
|