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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -2
  3. package/assets/common/bond-inspect.sh +368 -0
  4. package/assets/common/config.sh +263 -0
  5. package/assets/common/extract-data.sh +197 -0
  6. package/assets/common/harden-host.sh +37 -0
  7. package/assets/common/install-deps.sh +93 -0
  8. package/assets/common/node-enroll.sh +65 -0
  9. package/assets/common/node-setup.sh +150 -0
  10. package/assets/common/node-start.sh +90 -0
  11. package/assets/common/node-stop.sh +18 -0
  12. package/assets/common/node-upgrade.sh +45 -0
  13. package/assets/common/pre-run.sh +497 -0
  14. package/assets/common/promote-validator.sh +104 -0
  15. package/assets/deploy/10-prepare-host.sh +98 -0
  16. package/assets/deploy/20-install-toolchain.sh +93 -0
  17. package/assets/deploy/30-setup-node.sh +82 -0
  18. package/assets/deploy/40-start-node.sh +75 -0
  19. package/assets/deploy/50-verify.sh +259 -0
  20. package/assets/deploy/60-enroll.sh +304 -0
  21. package/assets/deploy/70-promote.sh +152 -0
  22. package/assets/deploy/lib.sh +398 -0
  23. package/assets/manage/10-bond-increase.sh +149 -0
  24. package/assets/manage/10-diagnose-node.sh +169 -0
  25. package/assets/manage/20-exit-validator.sh +259 -0
  26. package/assets/manage/25-recover-node.sh +291 -0
  27. package/assets/manage/30-status.sh +64 -0
  28. package/assets/manage/40-exit-withdraw.sh +133 -0
  29. package/assets/manage/bond_inc.sh +318 -0
  30. package/assets/manage/dashboard_monitor/app.js +1544 -0
  31. package/assets/manage/dashboard_monitor/assets/v2.2/css/base.css +113 -0
  32. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/content.css +133 -0
  33. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/controls.css +229 -0
  34. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/data.css +374 -0
  35. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/icons.css +35 -0
  36. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/motion.css +57 -0
  37. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/shell.css +51 -0
  38. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/sidebar.css +242 -0
  39. package/assets/manage/dashboard_monitor/assets/v2.2/css/components/topbar.css +138 -0
  40. package/assets/manage/dashboard_monitor/assets/v2.2/css/fonts.css +30 -0
  41. package/assets/manage/dashboard_monitor/assets/v2.2/css/synteq-ui-v2.css +25 -0
  42. package/assets/manage/dashboard_monitor/assets/v2.2/css/theme-dark.css +38 -0
  43. package/assets/manage/dashboard_monitor/assets/v2.2/css/theme-light.css +36 -0
  44. package/assets/manage/dashboard_monitor/assets/v2.2/css/tokens.css +61 -0
  45. package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-Medium.ttf +0 -0
  46. package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-Regular.ttf +0 -0
  47. package/assets/manage/dashboard_monitor/assets/v2.2/fonts/GoogleSansCode-SemiBold.ttf +0 -0
  48. package/assets/manage/dashboard_monitor/assets/v2.2/js/synteq-ui-v2.js +869 -0
  49. package/assets/manage/dashboard_monitor/deploy-dashboard.sh +230 -0
  50. package/assets/manage/dashboard_monitor/favicon.svg +13 -0
  51. package/assets/manage/dashboard_monitor/index.html +1197 -0
  52. package/assets/manage/dashboard_monitor/nginx-octra.conf +76 -0
  53. package/assets/manage/dashboard_monitor/octra-dashboard.service +29 -0
  54. package/assets/manage/dashboard_monitor/octra-node-prune.cron +12 -0
  55. package/assets/manage/dashboard_monitor/octra-node-prune.service +16 -0
  56. package/assets/manage/dashboard_monitor/octra-node-prune.timer +12 -0
  57. package/assets/manage/dashboard_monitor/prune-node-junk.sh +329 -0
  58. package/assets/manage/dashboard_monitor/server.py +1887 -0
  59. package/assets/manage/dashboard_monitor/ssl-certbot.sh +90 -0
  60. package/assets/manage/dashboard_monitor/styles.css +327 -0
  61. package/assets/manage/lib.sh +347 -0
  62. package/assets/manifest.json +143 -0
  63. package/assets/migrate/00-backup-identity.sh +67 -0
  64. package/assets/migrate/01-prepare-target.sh +145 -0
  65. package/assets/migrate/02-authorize-pull.sh +43 -0
  66. package/assets/migrate/03-pull-state.sh +102 -0
  67. package/assets/migrate/04-stop-source.sh +85 -0
  68. package/assets/migrate/05-finalize-target.sh +140 -0
  69. package/assets/migrate/06-start-target.sh +57 -0
  70. package/assets/migrate/07-verify.sh +237 -0
  71. package/assets/migrate/08-rollback.sh +54 -0
  72. package/assets/migrate/lib.sh +149 -0
  73. package/assets/upgrade/10-preflight.sh +120 -0
  74. package/assets/upgrade/20-upgrade.sh +142 -0
  75. package/assets/upgrade/30-manual-upgrade.sh +129 -0
  76. package/assets/upgrade/40-storage-prune.sh +65 -0
  77. package/assets/upgrade/50-status.sh +58 -0
  78. package/assets/upgrade/lib.sh +232 -0
  79. package/bin/ovtools.js +32 -0
  80. package/dist/api/checks.d.ts +6 -0
  81. package/dist/api/checks.d.ts.map +1 -0
  82. package/dist/api/checks.js +173 -0
  83. package/dist/api/checks.js.map +1 -0
  84. package/dist/api/hostkeys.d.ts +10 -0
  85. package/dist/api/hostkeys.d.ts.map +1 -0
  86. package/dist/api/hostkeys.js +58 -0
  87. package/dist/api/hostkeys.js.map +1 -0
  88. package/dist/api/jobs.d.ts +33 -0
  89. package/dist/api/jobs.d.ts.map +1 -0
  90. package/dist/api/jobs.js +483 -0
  91. package/dist/api/jobs.js.map +1 -0
  92. package/dist/api/nodes.d.ts +16 -0
  93. package/dist/api/nodes.d.ts.map +1 -0
  94. package/dist/api/nodes.js +126 -0
  95. package/dist/api/nodes.js.map +1 -0
  96. package/dist/api/server.d.ts +3 -0
  97. package/dist/api/server.d.ts.map +1 -0
  98. package/dist/api/server.js +443 -0
  99. package/dist/api/server.js.map +1 -0
  100. package/dist/cli.d.ts +3 -0
  101. package/dist/cli.d.ts.map +1 -0
  102. package/dist/cli.js +301 -0
  103. package/dist/cli.js.map +1 -0
  104. package/dist/constants.d.ts +76 -0
  105. package/dist/constants.d.ts.map +1 -0
  106. package/dist/constants.js +150 -0
  107. package/dist/constants.js.map +1 -0
  108. package/dist/index.d.ts +18 -0
  109. package/dist/index.d.ts.map +1 -0
  110. package/dist/index.js +60 -0
  111. package/dist/index.js.map +1 -0
  112. package/dist/orchestrator/base.d.ts +52 -0
  113. package/dist/orchestrator/base.d.ts.map +1 -0
  114. package/dist/orchestrator/base.js +219 -0
  115. package/dist/orchestrator/base.js.map +1 -0
  116. package/dist/orchestrator/deploy.d.ts +33 -0
  117. package/dist/orchestrator/deploy.d.ts.map +1 -0
  118. package/dist/orchestrator/deploy.js +313 -0
  119. package/dist/orchestrator/deploy.js.map +1 -0
  120. package/dist/orchestrator/manage.d.ts +28 -0
  121. package/dist/orchestrator/manage.d.ts.map +1 -0
  122. package/dist/orchestrator/manage.js +267 -0
  123. package/dist/orchestrator/manage.js.map +1 -0
  124. package/dist/orchestrator/migrate.d.ts +31 -0
  125. package/dist/orchestrator/migrate.d.ts.map +1 -0
  126. package/dist/orchestrator/migrate.js +442 -0
  127. package/dist/orchestrator/migrate.js.map +1 -0
  128. package/dist/ssh/client.d.ts +39 -0
  129. package/dist/ssh/client.d.ts.map +1 -0
  130. package/dist/ssh/client.js +418 -0
  131. package/dist/ssh/client.js.map +1 -0
  132. package/dist/test/core.test.d.ts +2 -0
  133. package/dist/test/core.test.d.ts.map +1 -0
  134. package/dist/test/core.test.js +188 -0
  135. package/dist/test/core.test.js.map +1 -0
  136. package/dist/types.d.ts +267 -0
  137. package/dist/types.d.ts.map +1 -0
  138. package/dist/types.js +3 -0
  139. package/dist/types.js.map +1 -0
  140. package/dist/utils/assets.d.ts +28 -0
  141. package/dist/utils/assets.d.ts.map +1 -0
  142. package/dist/utils/assets.js +583 -0
  143. package/dist/utils/assets.js.map +1 -0
  144. package/dist/utils/browser.d.ts +2 -0
  145. package/dist/utils/browser.d.ts.map +1 -0
  146. package/dist/utils/browser.js +23 -0
  147. package/dist/utils/browser.js.map +1 -0
  148. package/dist/utils/dashboard.d.ts +16 -0
  149. package/dist/utils/dashboard.d.ts.map +1 -0
  150. package/dist/utils/dashboard.js +177 -0
  151. package/dist/utils/dashboard.js.map +1 -0
  152. package/dist/utils/helpers.d.ts +29 -0
  153. package/dist/utils/helpers.d.ts.map +1 -0
  154. package/dist/utils/helpers.js +204 -0
  155. package/dist/utils/helpers.js.map +1 -0
  156. package/dist/utils/scripts.d.ts +60 -0
  157. package/dist/utils/scripts.d.ts.map +1 -0
  158. package/dist/utils/scripts.js +174 -0
  159. package/dist/utils/scripts.js.map +1 -0
  160. package/dist/utils/state.d.ts +12 -0
  161. package/dist/utils/state.d.ts.map +1 -0
  162. package/dist/utils/state.js +44 -0
  163. package/dist/utils/state.js.map +1 -0
  164. package/dist/utils/tar.d.ts +11 -0
  165. package/dist/utils/tar.d.ts.map +1 -0
  166. package/dist/utils/tar.js +93 -0
  167. package/dist/utils/tar.js.map +1 -0
  168. package/dist/utils/update.d.ts +9 -0
  169. package/dist/utils/update.d.ts.map +1 -0
  170. package/dist/utils/update.js +134 -0
  171. package/dist/utils/update.js.map +1 -0
  172. package/dist/utils/websocket.d.ts +10 -0
  173. package/dist/utils/websocket.d.ts.map +1 -0
  174. package/dist/utils/websocket.js +203 -0
  175. package/dist/utils/websocket.js.map +1 -0
  176. package/package.json +55 -4
  177. package/public/app.js +2712 -0
  178. package/public/assets/v2.2/css/base.css +113 -0
  179. package/public/assets/v2.2/css/components/content.css +133 -0
  180. package/public/assets/v2.2/css/components/controls.css +229 -0
  181. package/public/assets/v2.2/css/components/data.css +373 -0
  182. package/public/assets/v2.2/css/components/icons.css +35 -0
  183. package/public/assets/v2.2/css/components/motion.css +57 -0
  184. package/public/assets/v2.2/css/components/shell.css +51 -0
  185. package/public/assets/v2.2/css/components/sidebar.css +242 -0
  186. package/public/assets/v2.2/css/components/topbar.css +138 -0
  187. package/public/assets/v2.2/css/fonts.css +30 -0
  188. package/public/assets/v2.2/css/synteq-ui-v2.css +25 -0
  189. package/public/assets/v2.2/css/theme-dark.css +38 -0
  190. package/public/assets/v2.2/css/theme-light.css +36 -0
  191. package/public/assets/v2.2/css/tokens.css +61 -0
  192. package/public/assets/v2.2/fonts/GoogleSansCode-Medium.ttf +0 -0
  193. package/public/assets/v2.2/fonts/GoogleSansCode-Regular.ttf +0 -0
  194. package/public/assets/v2.2/fonts/GoogleSansCode-SemiBold.ttf +0 -0
  195. package/public/assets/v2.2/js/synteq-ui-v2.js +869 -0
  196. package/public/favicon.svg +7 -0
  197. package/public/index.html +1080 -0
  198. 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()