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
package/public/app.js ADDED
@@ -0,0 +1,2712 @@
1
+ /**
2
+ * Validator Management System - browser runtime (deploy / migrate / manage a node)
3
+ * =========================================
4
+ * Talks to the ovtools API:
5
+ * GET /api/config hosts, phase catalogue, artifacts
6
+ * GET /api/state current phase / progress / exit code
7
+ * GET /api/nodes?old&new live chain + membership of both nodes
8
+ * GET /api/log/stream SSE tail of the running phase (plus history)
9
+ * POST /api/run /api/cancel
10
+ *
11
+ * Design notes
12
+ * - one action: POST /api/run {phase:'auto'} runs the whole pipeline
13
+ * (preconditions -> migration -> dashboard deploy). There is no per-step
14
+ * runner in the UI - the backend owns the order, the page only mirrors it.
15
+ * - all values are written with textContent (node output never becomes markup)
16
+ * - the console is an operator tool: the pipeline goes through a typed
17
+ * confirmation, and the UI never guesses - it mirrors the backend state
18
+ * - a dropped SSE connection is expected on long phases; the stream resumes
19
+ * from the last sequence number without duplicating rows
20
+ */
21
+ (function () {
22
+ 'use strict';
23
+
24
+ var POLL_STATE_MS = 2000;
25
+ var POLL_NODES_MS = 10000;
26
+ var MAX_ROWS = 1500;
27
+ var PROGRAMS = ['deploy', 'migrate', 'manage'];
28
+
29
+ var state = {
30
+ cfg: null,
31
+ program: 'migrate', // 'migrate' | 'deploy' | 'manage' (three programs)
32
+ runner: null,
33
+ seq: 0,
34
+ rows: 0,
35
+ // deploy / migrate / manage never share data: the log, the node cards and the
36
+ // error counters of one tab must not show up in another (2026-11-03 report:
37
+ // the migrate log showed the deploy run and the host chips)
38
+ logs: { deploy: [], migrate: [], manage: [] },
39
+ errors: { deploy: 0, migrate: 0, manage: 0 },
40
+ nodesByProgram: { deploy: null, migrate: null, manage: null },
41
+ logLevel: 'all',
42
+ logQuery: '',
43
+ logPaused: false,
44
+ hostFilter: 'all',
45
+ hosts: [],
46
+ es: null,
47
+ confirm: null,
48
+ check: null,
49
+ checkLoaded: false,
50
+ nodes: { src: null, dst: null },
51
+ lastStatePoll: 0,
52
+ // `startedAt` of the run THIS page has seen running. /api/state also returns
53
+ // the run the console still remembers from an earlier session, and that one
54
+ // must never drive the UI (see finishedNodeRun).
55
+ runWatch: null,
56
+ // id of the activation the console already told us about (the modal opens once
57
+ // per activation, even though /api/state keeps returning it)
58
+ seenActivation: null,
59
+ // ...and the id of the exit report already shown (pending -> done is a new id)
60
+ seenExit: null
61
+ };
62
+
63
+ /* - DOM helpers - */
64
+ /* Two dashboards, one code path. The migrate dashboard keeps the original ids;
65
+ the deploy dashboard uses the same names with a `d-` prefix. `$(id)` resolves
66
+ the element of the *active* program, so every shared helper (setText, val,
67
+ badge, progress, check rendering) works for both without branching. */
68
+ var ID_MAP = {
69
+ 'inp-old': 'd-inp-ip', 'inp-new': 'd-inp-ip',
70
+ 'inp-old-pass': 'd-inp-pass', 'inp-new-pass': 'd-inp-pass',
71
+ 'inp-old-auth': 'd-inp-auth', 'inp-new-auth': 'd-inp-auth',
72
+ 'inp-old-key': 'd-inp-key', 'inp-new-key': 'd-inp-key',
73
+ 'inp-role': 'd-role',
74
+ 'btn-check': 'd-btn-check',
75
+ 'check-result': 'd-check-result', 'check-list': 'd-check-list',
76
+ 'check-badge': 'd-check-badge', 'check-badge-text': 'd-check-badge-text',
77
+ 'check-dot': 'd-check-dot', 'check-summary': 'd-check-summary',
78
+ 'check-age': 'd-check-age', 'check-actions': 'd-check-actions',
79
+ 'progress-wrap': 'd-progress-wrap', 'phase-gauge': 'd-gauge',
80
+ 'phase-label': 'd-phase-label', 'phase-pct': 'd-phase-pct',
81
+ 'phase-rate': 'd-phase-rate', 'phase-status': 'd-phase-status',
82
+ 'phase-idle': 'd-phase-idle', 'phase-exit': 'd-phase-exit',
83
+ 'btn-run-all': 'd-btn-start', 'btn-status': 'd-btn-status',
84
+ 'dash-result': 'd-dash-result'
85
+ };
86
+
87
+ /* The manage dashboard is a single-host panel too, so it reuses the same ids
88
+ with an `m-` prefix. `amount` is the only manage-specific field. */
89
+ var MANAGE_MAP = {
90
+ "input-target": "m-inp-ip",
91
+ "auth-target": "m-inp-pass",
92
+ "inp-new": "m-inp-ip", "inp-new-pass": "m-inp-pass",
93
+ "inp-new-auth": "m-inp-auth", "inp-new-key": "m-inp-key",
94
+ "inp-bond-amount": "m-inp-bond-amount",
95
+ "action-note": "m-action-note",
96
+ "btn-check": "m-btn-check",
97
+ "check-result": "m-check-result", "check-list": "m-check-list",
98
+ "check-badge": "m-check-badge", "check-badge-text": "m-check-badge-text",
99
+ "check-summary": "m-check-summary", "check-age": "m-check-age",
100
+ "check-actions": "m-check-actions",
101
+ "progress-wrap": "m-progress-wrap", "phase-gauge": "m-gauge",
102
+ "phase-label": "m-phase-label", "phase-pct": "m-phase-pct",
103
+ "phase-rate": "m-phase-rate", "phase-status": "m-phase-status",
104
+ "phase-idle": "m-phase-idle", "phase-exit": "m-phase-exit",
105
+ "dash-result": "m-dash-result"
106
+ };
107
+
108
+ function $(id) {
109
+ var mapped = (state.program === 'deploy' && ID_MAP[id]) ? ID_MAP[id] : null;
110
+ if (!mapped) {
111
+ mapped = (state.program === 'manage' && MANAGE_MAP[id]) ? MANAGE_MAP[id] : null;
112
+ }
113
+ return document.getElementById(mapped || id);
114
+ }
115
+
116
+ function panelId(id) { return document.getElementById(id); }
117
+
118
+ var PROGRAM_KEY = 'octra-console-program'; // the dashboard the operator used last
119
+
120
+ function setText(id, value) {
121
+ var node = $(id);
122
+ if (!node) return;
123
+ var next = (value === undefined || value === null || value === '') ? '-' : String(value);
124
+ if (node.textContent !== next) node.textContent = next;
125
+ }
126
+
127
+ function val(id) {
128
+ var node = $(id);
129
+ return node ? String(node.value || '').trim() : '';
130
+ }
131
+
132
+ function badge(id, variant, text) {
133
+ var node = $(id);
134
+ if (!node) return;
135
+ node.classList.remove('v2-badge--ok', 'v2-badge--warn', 'v2-badge--err', 'v2-badge--brand');
136
+ if (variant) node.classList.add(variant);
137
+ var label = node.querySelector('span:last-child');
138
+ if (label && text !== undefined) label.textContent = text;
139
+ }
140
+
141
+ function fmtElapsed(sec) {
142
+ if (!sec && sec !== 0) return '-';
143
+ var m = Math.floor(sec / 60), s = sec % 60;
144
+ return m > 0 ? m + 'm ' + s + 's' : s + 's';
145
+ }
146
+
147
+ /* the operator asked for minutes, not 2,402s: `35s`, `12m`, `1h 30m` */
148
+ function fmtMinutes(sec) {
149
+ var value = Number(sec);
150
+ if (!isFinite(value) || value < 0) return '-';
151
+ if (value < 90) return Math.round(value) + 's';
152
+ var m = Math.round(value / 60);
153
+ if (m < 60) return m + 'm';
154
+ return Math.floor(m / 60) + 'h ' + ('0' + (m % 60)).slice(-2) + 'm';
155
+ }
156
+
157
+ function fmtInt(v) {
158
+ if (v === null || v === undefined || v === '') return '-';
159
+ var n = Number(v);
160
+ return isFinite(n) ? n.toLocaleString('en-US') : String(v);
161
+ }
162
+
163
+ /* ledger amount: the node speaks raw units with 6 decimals (1 OCT = 1e6),
164
+ exactly like dashboard/server.py: TOKEN_DECIMALS = 6 */
165
+ function fmtToken(v) {
166
+ if (v === null || v === undefined || v === '') return '-';
167
+ var n = Number(v);
168
+ if (!isFinite(n)) return String(v);
169
+ return n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 6 });
170
+ }
171
+
172
+ function getJSON(url) {
173
+ return fetch(url, { cache: 'no-store' }).then(function (r) {
174
+ return r.json().then(function (body) {
175
+ if (!r.ok) throw new Error(body && body.error ? body.error : 'HTTP ' + r.status);
176
+ return body;
177
+ });
178
+ });
179
+ }
180
+
181
+ function postJSON(url, payload) {
182
+ return fetch(url, {
183
+ method: 'POST',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify(payload || {})
186
+ }).then(function (r) {
187
+ return r.json().then(function (body) {
188
+ if (!r.ok) throw new Error(body && body.error ? body.error : 'HTTP ' + r.status);
189
+ return body;
190
+ });
191
+ });
192
+ }
193
+
194
+ function tip(anchor, text, kind) {
195
+ if (window.SynteqV2 && SynteqV2.tip) SynteqV2.tip(anchor, text, { kind: kind || 'ok' });
196
+ }
197
+
198
+ /* - config & phase catalogue - */
199
+ /* "same credential as source" only exists on Migrate (that checkbox lives in
200
+ the migrate panel and is checked by default). Deploy and Manage have ONE host
201
+ of their own, so the flag must never mirror an empty source credential into
202
+ their password field - that is what made Check report "no password typed
203
+ for <ip>" while the field was filled (2026-11-03). */
204
+ function samePass() {
205
+ if (state.program !== 'migrate') { return false; }
206
+ var box = $('chk-same-pass');
207
+ return !!(box && box.checked);
208
+ }
209
+
210
+
211
+ function syncPasswordFields() {
212
+ if (state.program !== 'migrate') {
213
+ // a single credential per dashboard: nothing to mirror, never disabled
214
+ var select = $('inp-new-auth');
215
+ var own = $('inp-new-pass');
216
+ if (own) { own.disabled = false; own.placeholder = 'root password'; }
217
+ // the eye belongs to this dashboard (data-eye on its auth select), so the
218
+ // hidden migrate one is never touched
219
+ var eyeId = select && select.getAttribute ? select.getAttribute('data-eye') : null;
220
+ var ownEye = eyeId ? document.getElementById(eyeId) : null;
221
+ if (ownEye) { ownEye.disabled = false; }
222
+ return;
223
+ }
224
+ var target = $('inp-new-pass');
225
+ if (!target) return;
226
+ var same = samePass();
227
+ target.disabled = same;
228
+ if (same) target.value = '';
229
+ target.placeholder = same ? 'same as source' : 'root password';
230
+ // the eye button follows the field: disabled (and re-masked) when the target
231
+ // mirrors the source password
232
+ var eye = $('btn-new-pass-eye');
233
+ if (eye) {
234
+ eye.disabled = same;
235
+ if (same) setPassVisible(target, eye, false);
236
+ }
237
+ }
238
+
239
+ /* - auth method per host: password | ssh key (OpenSSH) ------------------- */
240
+ /* Only two methods exist any more (Pageant is gone). Everything an auth row
241
+ needs is on the <select> itself (data-keybox / data-keytext / data-pass /
242
+ data-eye / data-mirror-source), so the handler works from the element that
243
+ changed - not from the panel that happened to be open when the page booted. */
244
+ function syncAuthRow(select) {
245
+ if (!select || !select.getAttribute) return;
246
+ var keyId = select.getAttribute('data-key');
247
+ var boxId = select.getAttribute('data-keybox');
248
+ var textId = select.getAttribute('data-keytext');
249
+ var passId = select.getAttribute('data-passfield');
250
+ var eyeId = select.getAttribute('data-eye');
251
+ var mirrorId = select.getAttribute('data-mirror-source');
252
+
253
+ var keyBox = boxId ? document.getElementById(boxId) : null;
254
+ var keyInput = keyId ? document.getElementById(keyId) : null;
255
+ var keyText = textId ? document.getElementById(textId) : null;
256
+ var passInput = passId ? document.getElementById(passId) : null;
257
+ var eye = eyeId ? document.getElementById(eyeId) : null;
258
+
259
+ // "same credential as source" mirrors the method and the key path
260
+ var mirrored = false;
261
+ var src = null;
262
+ if (mirrorId && samePass()) {
263
+ src = document.getElementById(mirrorId);
264
+ if (src) {
265
+ select.value = src.value === 'key' ? 'key' : 'password';
266
+ mirrored = true;
267
+ }
268
+ }
269
+ select.disabled = mirrored;
270
+ var method = select.value === 'key' ? 'key' : 'password';
271
+
272
+ if (keyBox) keyBox.hidden = method !== 'key';
273
+ if (keyInput) {
274
+ if (mirrored) {
275
+ var srcKey = src ? document.getElementById(src.getAttribute('data-key')) : null;
276
+ keyInput.value = srcKey ? String(srcKey.value || '') : '';
277
+ }
278
+ keyInput.disabled = mirrored;
279
+ }
280
+ if (keyText) {
281
+ keyText.disabled = mirrored;
282
+ if (mirrored) keyText.value = '';
283
+ }
284
+ if (passInput) {
285
+ passInput.disabled = mirrored || method !== 'password';
286
+ passInput.placeholder = mirrored ? 'same as source'
287
+ : (method === 'password' ? 'root password' : 'ssh key auth - no password');
288
+ }
289
+ if (eye) {
290
+ eye.disabled = !!(passInput && passInput.disabled);
291
+ if (eye.disabled) setPassVisible(passInput, eye, false);
292
+ }
293
+ }
294
+
295
+ function syncAuthFields(side) {
296
+ var select = $('inp-' + side + '-auth');
297
+ if (select) syncAuthRow(select);
298
+ }
299
+
300
+ function syncAllAuthFields() {
301
+ for (var i = 0; i < AUTH_SELECTS.length; i++) {
302
+ var select = document.getElementById(AUTH_SELECTS[i]);
303
+ if (select) syncAuthRow(select);
304
+ }
305
+ }
306
+
307
+ /* the four auth method selectors (one per host row, one per program panel) */
308
+ var AUTH_SELECTS = ['d-inp-auth', 'inp-old-auth', 'inp-new-auth', 'm-inp-auth'];
309
+
310
+ /* select id -> is it an auth method selector? */
311
+ function isAuthSelect(id) {
312
+ return /^(d|m)-inp-auth$/.test(id) || /^inp-(old|new)-auth$/.test(id);
313
+ }
314
+
315
+ function syncAllAuthFields() {
316
+ syncAuthFields('old');
317
+ syncAuthFields('new');
318
+ }
319
+
320
+ /* - password show/hide ------------------------------------------- */
321
+ function setPassVisible(input, button, visible) {
322
+ if (!input) return;
323
+ input.type = visible ? 'text' : 'password';
324
+ if (!button) return;
325
+ var label = button.getAttribute('data-pass-label') || 'password';
326
+ button.setAttribute('aria-pressed', String(visible));
327
+ button.setAttribute('aria-label', (visible ? 'Hide ' : 'Show ') + label);
328
+ button.title = visible ? 'Hide ' + label : 'Show ' + label;
329
+ }
330
+
331
+ function wirePassToggles() {
332
+ var buttons = document.querySelectorAll('[data-pass]');
333
+ for (var i = 0; i < buttons.length; i++) {
334
+ (function (button) {
335
+ // the auth <select> used to carry data-pass and showed a "password
336
+ // visible" tip when the dropdown was clicked: never treat a SELECT as an
337
+ // eye button (the selects use data-passfield now)
338
+ if (button.tagName === 'SELECT') return;
339
+ button.addEventListener('click', function () {
340
+ var input = $(button.getAttribute('data-pass'));
341
+ if (!input) return;
342
+ // state comes from aria-pressed, not from input.type: a browser can
343
+ // switch the type itself (autofill) and the button must still work
344
+ var visible = button.getAttribute('aria-pressed') !== 'true';
345
+ setPassVisible(input, button, visible);
346
+ tip(button, visible ? 'Password visible' : 'Password hidden', visible ? 'warn' : 'ok');
347
+ });
348
+ })(buttons[i]);
349
+ }
350
+ }
351
+
352
+ /* The tools that operate ONE host (no source/target pair): Deploy and Manage.
353
+ Their log chips and node labels must never say "SRC". */
354
+ function oneHostProgram(program) {
355
+ var p = program || state.program;
356
+ return p === 'deploy' || p === 'manage';
357
+ }
358
+
359
+ /* Manage has no source side at all: the pair keeps a single address (the server
360
+ keys the check on it). Deploy reuses its own field for both, which the server
361
+ ignores. */
362
+ function noSourceProgram(program) {
363
+ var p = program || state.program;
364
+ return p === 'manage';
365
+ }
366
+
367
+ function pair() {
368
+ var oldPassword = val('inp-old-pass');
369
+ var oldAuth = authOf('inp-old-auth', 'inp-old-key');
370
+ var newAuth = authOf('inp-new-auth', 'inp-new-key');
371
+ // "same credential as source" mirrors the method *and* the key path
372
+ var same = samePass();
373
+ if (same) {
374
+ newAuth = { method: oldAuth.method, key: oldAuth.key || '' };
375
+ }
376
+ var oneHost = noSourceProgram();
377
+ return {
378
+ program: state.program,
379
+ // manage/upgrade operate ONE host: there is no source, so the pair keeps a
380
+ // single address (the server keys its check on that)
381
+ oldIp: oneHost ? '' : val('inp-old'),
382
+ newIp: val('inp-new'),
383
+ oldPassword: oneHost ? '' : oldPassword,
384
+ newPassword: same ? oldPassword : val('inp-new-pass'),
385
+ oldAuth: oneHost ? 'password' : oldAuth.method,
386
+ newAuth: newAuth.method,
387
+ oldKey: oneHost ? '' : oldAuth.key,
388
+ newKey: newAuth.key,
389
+ sameAuth: same
390
+ };
391
+ }
392
+
393
+ function authOf(selectId, keyId) {
394
+ var select = $(selectId);
395
+ var method = select ? String(select.value || 'password') : 'password';
396
+ var key = method === 'key' ? val(keyId) : '';
397
+ if (method === 'key' && !key) { method = 'password'; } // no key typed yet
398
+ return { method: method, key: key };
399
+ }
400
+
401
+ /* Remember the last host pair (never the passwords) so a reload keeps the
402
+ node panel useful instead of showing dashes. */
403
+ var HOSTS_KEY = 'octra-mc-hosts';
404
+
405
+ function rememberHosts() {
406
+ if (state.program !== 'migrate') { return; } // deploy/manage: single host of their own
407
+ try {
408
+ localStorage.setItem(HOSTS_KEY, JSON.stringify({
409
+ oldIp: val('inp-old'),
410
+ newIp: val('inp-new')
411
+ }));
412
+ } catch (err) { /* private mode */ }
413
+ }
414
+
415
+ function restoreHosts() {
416
+ if (state.program !== 'migrate') { return; }
417
+ try {
418
+ var raw = localStorage.getItem(HOSTS_KEY);
419
+ if (!raw) return;
420
+ var saved = JSON.parse(raw);
421
+ var oldInput = $('inp-old');
422
+ var newInput = $('inp-new');
423
+ if (saved && saved.oldIp && oldInput) oldInput.value = saved.oldIp;
424
+ if (saved && saved.newIp && newInput) newInput.value = saved.newIp;
425
+ } catch (err) { /* ignore malformed state */ }
426
+ }
427
+
428
+ /* -- gating ------------------------------------------------------- */
429
+ function busy() {
430
+ return !!(state.runner && state.runner.running);
431
+ }
432
+
433
+ function setNodeTitle(node, why) {
434
+ if (!node.hasAttribute('data-base-title')) {
435
+ node.setAttribute('data-base-title', node.getAttribute('title') || '');
436
+ }
437
+ if (why) {
438
+ node.title = why;
439
+ return;
440
+ }
441
+ var base = node.getAttribute('data-base-title');
442
+ if (base) node.title = base;
443
+ else node.removeAttribute('title');
444
+ }
445
+
446
+ function setDisabled(id, disabled, why) {
447
+ var node = $(id);
448
+ if (!node) return;
449
+ node.disabled = !!disabled;
450
+ setNodeTitle(node, disabled && why ? why : '');
451
+ }
452
+
453
+ function applyGating() {
454
+ var running = busy();
455
+ setDisabled('btn-run-all', running, 'A phase is already running');
456
+ setDisabled('btn-check', running, 'A phase is already running');
457
+ setDisabled('btn-status', running, 'A phase is already running');
458
+ setDisabled('btn-swap', running, 'A phase is already running');
459
+ setDisabled('btn-run-rollback', running || !checkOk(),
460
+ running ? 'A phase is already running' : 'Press Check first - rollback is destructive');
461
+ // the manage dashboard has no single "run" button: gate its actions
462
+ if (state.program === 'manage') {
463
+ var ready = checkOkFor('manage'); // bond/exit: the ACTIVE validator
464
+ var readyUp = checkOkFor('upgrade'); // update/manual: an updatable node
465
+ ['m-btn-bond', 'm-btn-exit'].forEach(function (id) {
466
+ var node = panelId(id);
467
+ if (!node) return;
468
+ node.disabled = running || !ready;
469
+ setNodeTitle(node, running ? 'A phase is already running'
470
+ : (!ready ? 'Press Check first - the node must be the active validator' : ''));
471
+ });
472
+ // Update node and Manual update rebuild and restart the node: they need the
473
+ // update half of the Check (checkout, tools, disk, identity).
474
+ ['m-btn-upgrade', 'm-btn-manual'].forEach(function (id) {
475
+ var node = panelId(id);
476
+ if (!node) return;
477
+ node.disabled = running || !readyUp;
478
+ setNodeTitle(node, running ? 'A phase is already running'
479
+ : (!readyUp ? 'Press Check first - the node must be reachable and updatable' : ''));
480
+ });
481
+ // prune / monitor / status do not restart the node: they only need an idle console
482
+ // Diagnose is read-only (evidence only) and Fix stuck node is the repair path
483
+ // for a node that is NOT healthy - both must work when Check fails, which is
484
+ // why they are not gated on a passing sweep.
485
+ ['m-btn-storage', 'm-btn-dashboard', 'm-btn-status', 'm-btn-diagnose', 'm-btn-recover'].forEach(function (id) {
486
+ var node = panelId(id);
487
+ if (!node) return;
488
+ node.disabled = running;
489
+ setNodeTitle(node, running ? 'A phase is already running' : '');
490
+ });
491
+ }
492
+ }
493
+
494
+ /* -- precondition check ---------------------------------------- */
495
+ /* The key must be EXACTLY what the server caches (server.py: "deploy|ip",
496
+ "manage|ip", "old|new" for migrate). It used to be "program|old|new" for
497
+ every program, so checkOk() never matched and the gated actions (manage
498
+ bond/exit, migrate rollback) could never be enabled. */
499
+ function checkKey() {
500
+ var p = pair();
501
+ if (state.program === 'migrate') { return p.oldIp + '|' + p.newIp; }
502
+ return state.program + '|' + p.newIp;
503
+ }
504
+
505
+ function invalidateCheck() {
506
+ var res = $('check-result');
507
+ if (res) res.hidden = true;
508
+ state.check = null;
509
+ state.checkLoaded = false;
510
+ applyGating();
511
+ }
512
+
513
+ function checkOk() {
514
+ var c = state.check;
515
+ if (!c || !c.result || !c.result.ready) return false;
516
+ return c.key === checkKey();
517
+ }
518
+
519
+ /* The Manage tab gates its actions apart: bond/exit need an ACTIVE validator,
520
+ the update actions only need a node that can be updated (its checkout, its
521
+ tools, the disk). The server sends both flags with the check; an older server
522
+ without them falls back to the overall verdict. */
523
+ function checkOkFor(kind) {
524
+ if (!checkOk()) return false;
525
+ var r = state.check.result;
526
+ if (kind === 'upgrade') return r.readyUpgrade !== false;
527
+ return r.readyManage !== false;
528
+ }
529
+
530
+ function renderCheck(check) {
531
+ var res = $('check-result');
532
+ if (!res) return;
533
+ if (!check || !check.result) { res.hidden = true; return; }
534
+ var r = check.result;
535
+ // a check belongs to the dashboard that ran it
536
+ if ((r.program || 'migrate') !== state.program) { res.hidden = true; return; }
537
+ res.hidden = false;
538
+
539
+ setText('check-badge-text', r.ready
540
+ ? (state.program === 'deploy' ? 'ready to deploy'
541
+ : (state.program === 'manage' ? 'ready to manage' : 'ready to migrate'))
542
+ : 'not ready');
543
+ badge('check-badge', r.ready ? 'v2-badge--ok' : 'v2-badge--err');
544
+ setText('check-summary', r.counts.ok + ' ok - ' + r.counts.warn + ' warn - ' + r.counts.fail +
545
+ ' fail - ' + r.durationMs + ' ms');
546
+ // one program, one set of hosts: the deploy/manage programs check a single
547
+ // VPS, so "src -> dst" (which then reads 1.2.3.4 -> 1.2.3.4) is migrate only
548
+ var route = 'target ' + r.targetIp;
549
+ if (state.program === 'manage') { route = 'node ' + r.targetIp; }
550
+ if (state.program === 'migrate' && r.sourceIp && r.sourceIp !== r.targetIp) {
551
+ route = r.sourceIp + ' -> ' + r.targetIp;
552
+ }
553
+ setText('check-age', route);
554
+
555
+ var list = $('check-list');
556
+ if (!list) return;
557
+ list.textContent = '';
558
+ r.items.forEach(function (item) {
559
+ var row = document.createElement('div');
560
+ row.className = 'mc-check__row mc-check__row--' + item.status;
561
+
562
+ var st = document.createElement('span');
563
+ st.className = 'mc-check__status';
564
+ st.textContent = String(item.status || '').toUpperCase();
565
+
566
+ var label = document.createElement('span');
567
+ label.className = 'mc-check__label';
568
+ label.textContent = item.label;
569
+
570
+ var detail = document.createElement('span');
571
+ detail.className = 'mc-check__detail v2-mono';
572
+ detail.textContent = item.detail || '';
573
+ if (item.detail) detail.title = item.detail;
574
+
575
+ row.appendChild(st);
576
+ row.appendChild(label);
577
+ row.appendChild(detail);
578
+ list.appendChild(row);
579
+ });
580
+
581
+ // host-key mismatch (e.g. after a VPS re-install) needs an explicit action
582
+ var actions = $('check-actions');
583
+ if (actions) {
584
+ actions.textContent = '';
585
+ var pending = r.needsTrust || [];
586
+ actions.hidden = pending.length === 0;
587
+ pending.forEach(function (ip) {
588
+ var hint = document.createElement('span');
589
+ hint.className = 'mc-check__hint';
590
+ hint.textContent = ip + ' presents a new host key (typical after a re-install) - the console ' +
591
+ 'could not auto-trust it, so confirm it here.';
592
+
593
+ var btn = document.createElement('button');
594
+ btn.className = 'v2-btn v2-btn--primary';
595
+ btn.type = 'button';
596
+ btn.textContent = 'Trust host key for ' + ip;
597
+ btn.addEventListener('click', function () {
598
+ openConfirm({
599
+ id: 'trust',
600
+ label: 'Trust host key for ' + ip,
601
+ desc: 'PuTTY has a different key cached for ' + ip + '. Accepting replaces the cached key ' +
602
+ 'with the fingerprint this server presents right now. Only continue if you ' +
603
+ 're-installed that VPS yourself.',
604
+ confirm: 'TRUST'
605
+ }, { action: 'trust', ip: ip });
606
+ });
607
+
608
+ actions.appendChild(hint);
609
+ actions.appendChild(btn);
610
+ });
611
+ }
612
+ }
613
+
614
+ function runCheck(anchor) {
615
+ var p = pair();
616
+ if (state.program === 'manage') {
617
+ if (!p.newIp) { tip(anchor, 'Fill the node host first', 'err'); return; }
618
+ } else if (!p.oldIp || !p.newIp) {
619
+ tip(anchor, 'Fill source and target first', 'err');
620
+ return;
621
+ }
622
+ var btn = anchor || $('btn-check');
623
+ if (btn) btn.disabled = true;
624
+ var res = $('check-result');
625
+ if (res) res.hidden = false;
626
+ setText('check-badge-text', 'checking...');
627
+ badge('check-badge', 'v2-badge--warn');
628
+ setText('check-summary', (state.program === 'deploy'
629
+ ? 'probing the target over SSH and the node API'
630
+ : 'probing both hosts over SSH and the node API'));
631
+ setText('check-age', state.program === 'deploy'
632
+ ? 'target ' + p.newIp
633
+ : p.oldIp + ' -> ' + p.newIp);
634
+
635
+ postJSON('/api/check', p).then(function (body) {
636
+ state.check = body.check;
637
+ state.checkLoaded = true;
638
+ renderCheck(body.check);
639
+ applyGating();
640
+ tip(btn, body.check.result.ready ? 'All checks passed' : 'Check found blockers',
641
+ body.check.result.ready ? 'ok' : 'err');
642
+ }).catch(function (err) {
643
+ state.check = null;
644
+ applyGating();
645
+ setText('check-badge-text', 'check failed');
646
+ badge('check-badge', 'v2-badge--err');
647
+ setText('check-summary', err.message);
648
+ tip(btn, err.message, 'err');
649
+ }).then(function () {
650
+ if (btn) btn.disabled = false;
651
+ });
652
+ }
653
+
654
+
655
+ /* - runner state - */
656
+ /* Bottom-right of the navigation: the console state in ONE word (the phase
657
+ detail stays in the topbar badge). Same split as the monitor's dock badge. */
658
+ function setDockStatus(word, variant) {
659
+ setText('dock-status-text', word);
660
+ badge('dock-status', variant);
661
+ var host = $('dock-status');
662
+ if (host) host.title = 'Console state: ' + word;
663
+ var dot = $('dock-dot');
664
+ if (dot) {
665
+ dot.classList.remove('v2-dot--ok', 'v2-dot--warn', 'v2-dot--err', 'v2-dot--idle');
666
+ dot.classList.add(variant === 'v2-badge--ok' ? 'v2-dot--ok'
667
+ : variant === 'v2-badge--warn' ? 'v2-dot--warn'
668
+ : variant === 'v2-badge--err' ? 'v2-dot--err' : 'v2-dot--idle');
669
+ }
670
+ }
671
+
672
+ function renderRoute() {
673
+ var node = $('hero-route-text');
674
+ if (!node) return;
675
+ var runner = state.runner || {};
676
+ if (state.program !== 'migrate') {
677
+ // deploy, manage and upgrade watch ONE host: it is always the target, so the
678
+ // log chips and the route must never label it "SRC"
679
+ var only = (runner.program === state.program && runner.newIp) ? runner.newIp : val('inp-new');
680
+ node.textContent = only || '-';
681
+ if (only) noteHost(only);
682
+ return;
683
+ }
684
+ var from = runner.oldIp || val('inp-old');
685
+ var to = runner.newIp || val('inp-new');
686
+ node.textContent = (from && to) ? (from + ' -> ' + to) : '-';
687
+ if (from) noteHost(from);
688
+ if (to) noteHost(to);
689
+ }
690
+
691
+ function renderRunner(s) {
692
+ state.runner = s;
693
+ var running = !!s.running;
694
+ // which run this page is following (a finished run from an earlier session
695
+ // arrives through /api/state too, and must not drive the UI), then what the
696
+ // Project-settings row offers
697
+ noteRunWatch(s);
698
+ syncDashboardGate();
699
+ // Follow a phase that is RUNNING right now (the dashboard of the program that
700
+ // is actually working). A finished run of another program must NOT drag the
701
+ // operator back to that tab - that is how the deploy log/host chips used to
702
+ // appear under Migrate (2026-11-03).
703
+ if (running && s.program && s.program !== state.program) {
704
+ state.program = s.program;
705
+ renderProgram();
706
+ }
707
+ var mine = !s.program || s.program === state.program;
708
+ // a pipeline pause always ends with one question: the enrollment top-up
709
+ if (!running && s.awaiting && mine) { showAwaitPrompt(s.awaiting); }
710
+ // the console watched the chain admit this validator: tell the operator once,
711
+ // whatever the run is doing (it can arrive hours after the run ended)
712
+ if (mine && s.activation) { showActivation(s.activation); }
713
+ // ...and the same for an exit: the moment the stake is back, and the schedule
714
+ // (unlock epoch + how far away) as soon as the chain recorded the exit
715
+ if (mine && s.exitInfo) { showExit(s.exitInfo); }
716
+
717
+ var finished = mine && s.exitCode !== null && s.exitCode !== undefined;
718
+ // the console can be watching the set fold for a finished run: say so instead
719
+ // of "idle" (the log keeps its own progress lines)
720
+ var watching = !running && s.watching && s.watching.ip;
721
+ var leaving = !running && s.exitWatching && s.exitWatching.ip;
722
+ var dockWord = running ? 'Running'
723
+ : (leaving ? 'Waiting'
724
+ : (watching ? 'Watching'
725
+ : (finished ? (s.exitCode === 0 ? 'Done' : 'Failed') : 'Idle')));
726
+ var dockTone = running || leaving || watching ? 'v2-badge--warn'
727
+ : (finished ? (s.exitCode === 0 ? 'v2-badge--ok' : 'v2-badge--err') : '');
728
+ setDockStatus(dockWord, dockTone);
729
+ setText('topbar-status-text', running ? s.phase + ' running'
730
+ : (finished ? (s.exitCode === 0 ? 'completed' : 'failed') : 'idle'));
731
+ badge('topbar-status', running ? 'v2-badge--warn'
732
+ : (finished ? (s.exitCode === 0 ? 'v2-badge--ok' : 'v2-badge--err') : ''));
733
+
734
+ setText('hero-state-text', running ? 'running ' + (s.step || s.phase)
735
+ : (leaving ? 'waiting for the stake to unlock (' + s.exitWatching.ip + ')'
736
+ : (watching ? 'watching for the set fold (' + s.watching.ip + ')'
737
+ : (finished ? (s.exitCode === 0 ? 'last phase ok' : 'last phase failed') : 'idle'))));
738
+ badge('hero-state-badge', running || watching || leaving ? 'v2-badge--warn'
739
+ : (finished ? (s.exitCode === 0 ? 'v2-badge--ok' : 'v2-badge--err') : ''));
740
+ renderRoute();
741
+ setText('hero-elapsed', mine && s.startedAt ? fmtElapsed(s.elapsedSec) : '-');
742
+
743
+ $('btn-stop').disabled = !running;
744
+ applyGating();
745
+ if (!mine) {
746
+ // another program's run: keep its progress out of this panel
747
+ var idleWrap = $('progress-wrap');
748
+ if (idleWrap && !running) { idleWrap.hidden = true; }
749
+ return;
750
+ }
751
+ renderDashboard(s);
752
+
753
+ var wrap = $('progress-wrap');
754
+ if (wrap) wrap.hidden = !(running || s.progress || s.status);
755
+ var pct = s.progress && typeof s.progress.pct === 'number' ? s.progress.pct : 0;
756
+ setText('phase-pct', pct);
757
+ // bytes - rate - eta: the sync reports done/total, so the console can say how
758
+ // many minutes are left instead of only a percentage
759
+ var rateText = '-';
760
+ if (s.progress) {
761
+ rateText = s.progress.bytes + ' - ' + s.progress.rate;
762
+ if (s.progress.eta) { rateText += ' - ' + s.progress.eta; }
763
+ }
764
+ setText('phase-rate', rateText);
765
+ setText('phase-label', s.label ? ('[' + s.label.ip + '] ' + s.label.label) : (s.phase || 'idle'));
766
+ setText('phase-status', s.status ? ('status = ' + s.status.state + (s.status.detail ? ' ' + s.status.detail : '')) : '-');
767
+ setText('phase-idle', running && typeof s.idleSec === 'number' && s.idleSec >= 30
768
+ ? 'quiet for ' + fmtMinutes(s.idleSec)
769
+ : '');
770
+ setText('phase-exit', s.exitCode === null || s.exitCode === undefined ? '' : 'exit ' + s.exitCode);
771
+ var gauge = $('phase-gauge');
772
+ if (gauge) gauge.setAttribute('aria-valuenow', String(pct));
773
+ var fill = document.querySelector('#phase-gauge .v2-gauge__fill');
774
+ if (fill && window.SynteqV2 && SynteqV2.renderGauge) SynteqV2.renderGauge(fill, pct, { kind: 'brand' });
775
+ }
776
+
777
+ /* - dashboard result (deployed in its own run, after the pause) - */
778
+ function renderDashboard(s) {
779
+ var box = $('dash-result');
780
+ if (!box) return;
781
+ if (s.program && s.program !== state.program) { box.hidden = true; return; }
782
+ if (s.running) return; // nothing final to show
783
+ if (s.phase !== 'auto' && s.phase !== 'dashboard') return;
784
+ if (s.exitCode === null || s.exitCode === undefined) return;
785
+ box.hidden = false;
786
+ box.textContent = '';
787
+ if (s.exitCode !== 0) {
788
+ box.className = 'mc-result mc-result--err';
789
+ box.textContent = 'Pipeline stopped with exit ' + s.exitCode +
790
+ ' - the monitoring dashboard was NOT deployed. Fix the error, then start again.';
791
+ return;
792
+ }
793
+ var host = s.domain || s.newIp;
794
+ box.className = 'mc-result mc-result--ok';
795
+ box.textContent = s.phase === 'dashboard'
796
+ ? 'Monitoring dashboard live on https://' + host + '/ - node ' + s.newIp
797
+ : 'Validator live on ' + s.newIp + ' - monitoring dashboard: https://' + host + '/';
798
+ }
799
+
800
+ function pollState() {
801
+ return getJSON('/api/state').then(function (body) {
802
+ state.lastStatePoll = Date.now();
803
+ setText('live-age', 'live');
804
+ renderRunner(body.state);
805
+ // adopt a check the server already has (page reload, second tab, or the
806
+ // precondition step of the running pipeline) - but only when it belongs to
807
+ // the program on screen, otherwise Migrate would inherit Deploy's check
808
+ var srvCheck = body.check;
809
+ var known = (state.check && state.check.ts) || 0;
810
+ if (srvCheck && srvCheck.result && (!state.checkLoaded || (srvCheck.ts || 0) > known)
811
+ && (srvCheck.result.program || 'migrate') === state.program) {
812
+ state.checkLoaded = true;
813
+ state.check = srvCheck;
814
+ renderCheck(srvCheck);
815
+ applyGating();
816
+ }
817
+ safetyChecks();
818
+ }).catch(function (err) {
819
+ setText('live-age', 'offline');
820
+ setText('topbar-status-text', 'console offline');
821
+ badge('topbar-status', 'v2-badge--err');
822
+ setDockStatus('Offline', 'v2-badge--err');
823
+ console.warn('state poll failed', err);
824
+ });
825
+ }
826
+
827
+ /* - node state - */
828
+ function membershipText(n) {
829
+ if (!n.reachable) return 'unreachable';
830
+ if (n.active) return 'active';
831
+ if (n.scheduled) return 'scheduled';
832
+ return 'not member';
833
+ }
834
+
835
+ function renderNode(prefix, n) {
836
+ var slot = prefix === 'src' ? 'src' : 'dst';
837
+ state.nodes[slot] = n || null;
838
+ if (!n) {
839
+ // no probe (yet) for this tab: show dashes instead of another program's host
840
+ ['-ip', '-epoch', '-head', '-membership', '-nextset', '-enroll', '-bond', '-foot']
841
+ .forEach(function (suffix) { setText(prefix + suffix, '-'); });
842
+ setText(prefix + '-state-text', 'unknown');
843
+ badge(prefix + '-state', '');
844
+ return;
845
+ }
846
+ setText(prefix + '-ip', n.ip);
847
+ setText(prefix + '-epoch', fmtInt(n.epoch));
848
+ setText(prefix + '-head', n.head_epoch ? 'head ' + fmtInt(n.head_epoch) : '-');
849
+ setText(prefix + '-membership', membershipText(n));
850
+ setText(prefix + '-nextset', n.next_set_epoch ? 'next set ' + fmtInt(n.next_set_epoch) : '-');
851
+ setText(prefix + '-enroll', n.enrollment || '-');
852
+ setText(prefix + '-bond', n.bondOct === null || n.bondOct === undefined
853
+ ? '-'
854
+ : fmtToken(n.bondOct) + ' OCT bonded');
855
+ setText(prefix + '-foot', n.address ? n.address : (n.error || '-'));
856
+
857
+ var variant = n.active ? 'v2-badge--ok' : (n.reachable ? 'v2-badge--warn' : 'v2-badge--err');
858
+ var text = n.state === 'validator' ? 'validator'
859
+ : n.state === 'scheduled' ? 'scheduled'
860
+ : n.state === 'ready-idle' ? 'ready (idle)'
861
+ : n.state === 'unreachable' ? 'unreachable' : 'observer';
862
+ setText(prefix + '-state-text', text);
863
+ badge(prefix + '-state', variant);
864
+ }
865
+
866
+ function pollNodes() {
867
+ var p = pair();
868
+ // migrate watches two hosts; deploy and manage watch one (the source card is
869
+ // hidden there, so asking for it would only poll a stale address)
870
+ var oldIp = state.program === 'migrate' ? p.oldIp : '';
871
+ var newIp = p.newIp;
872
+ if (!newIp) { return Promise.resolve(); }
873
+ var query = '/api/nodes?new=' + encodeURIComponent(newIp) +
874
+ (oldIp ? '&old=' + encodeURIComponent(oldIp) : '');
875
+ return getJSON(query)
876
+ .then(function (body) {
877
+ var nodes = body.nodes || [];
878
+ var srcNode = oldIp ? (nodes[0] || null) : null;
879
+ var dstNode = oldIp ? (nodes[1] || null) : (nodes[0] || null);
880
+ state.nodesByProgram[state.program] = { src: srcNode, dst: dstNode };
881
+ state.nodes.src = srcNode;
882
+ state.nodes.dst = dstNode;
883
+ renderNode('src', srcNode);
884
+ renderNode('dst', dstNode);
885
+ setText('nodes-meta', 'updated ' + new Date().toLocaleTimeString());
886
+ safetyChecks();
887
+ })
888
+ .catch(function (err) { console.warn('node probe failed', err); });
889
+ }
890
+
891
+
892
+ /* -- enroll popup: the deploy pauses after verify, the node wallet exists and
893
+ enrolling spends the bond, so the operator funds the address first ------ */
894
+ function showEnrollPrompt(awaiting) {
895
+ var modal = $('enroll-modal');
896
+ if (!modal || !awaiting || awaiting.action !== 'enroll') return;
897
+ var ip = awaiting.ip || '';
898
+ if (!modal.hidden && modal.dataset.ip === ip) return; // already on screen
899
+ modal.dataset.ip = ip;
900
+ setText('enroll-address', awaiting.address || 'unknown - read wallet.json on the host');
901
+ var bondInput = $('enroll-bond');
902
+ if (bondInput) {
903
+ // the popup speaks OCT; the server keeps the raw amount. The form has no
904
+ // bond field any more, so a -Bond given on the command line (or a re-run)
905
+ // is the only source besides the default of 1 OCT.
906
+ var raw = Number(awaiting.bond) || 0;
907
+ var oct = raw > 0 ? Math.max(1, Math.round(raw / 1000000)) : 1;
908
+ bondInput.value = String(oct);
909
+ }
910
+ var funded = $('enroll-funded');
911
+ if (funded) { funded.checked = false; }
912
+ var ok = $('enroll-ok');
913
+ if (ok) { ok.disabled = true; }
914
+ setText('enroll-note', awaiting.address ? ''
915
+ : 'wallet address unknown - check the host before enrolling');
916
+ modal.hidden = false;
917
+ if (funded) funded.focus();
918
+ }
919
+
920
+ function closeEnroll() {
921
+ var modal = $('enroll-modal');
922
+ if (modal) { modal.hidden = true; }
923
+ }
924
+
925
+ function submitEnroll() {
926
+ var amount = Number(val('enroll-bond')) || 0;
927
+ var fundedBox = $('enroll-funded');
928
+ if (amount <= 0) {
929
+ setText('enroll-note', 'Enter the enroll amount in whole OCT (minimum 1).');
930
+ return;
931
+ }
932
+ if (!fundedBox || !fundedBox.checked) {
933
+ setText('enroll-note', 'Tick the top-up confirmation first.');
934
+ return;
935
+ }
936
+ var meta = phaseMeta('enroll');
937
+ if (!meta) { setText('enroll-note', 'console config not loaded yet'); return; }
938
+ var payload = buildPayload('enroll');
939
+ var modal = $('enroll-modal');
940
+ if (modal && modal.dataset.ip) { payload.newIp = modal.dataset.ip; }
941
+ payload.role = 'validator';
942
+ payload.bondOct = amount; // OCT - the server converts to raw
943
+ payload.funded = true;
944
+ payload.confirm = meta.confirm || 'ENROLL';
945
+ closeEnroll();
946
+ postRun(payload, meta, null);
947
+ }
948
+
949
+ /* -- the enroll popup and the activation popup are the only two pauses --------
950
+ The dashboard host name is asked in the Manage tab's own modal (Deploy
951
+ monitor), so there is no domain popup here any more (2026-11-05). --------- */
952
+
953
+ /* -- activation popup: the console watched the chain admit the validator ------
954
+ A deploy/migrate run does not fail because the set fold has not happened yet:
955
+ it reports "scheduled for epoch X" and keeps watching. The moment the node
956
+ reports validator_active = true (in the run or from the background watch, long
957
+ after it) this opens by itself, so nobody has to poll controls/stat.sh. ------ */
958
+ function showActivation(activation) {
959
+ var modal = $('activation-modal');
960
+ if (!modal || !activation || !activation.ip) return;
961
+ if (state.seenActivation === activation.id) return; // already shown
962
+ state.seenActivation = activation.id;
963
+ setText('activation-node', activation.ip);
964
+ setText('activation-epoch', activation.epoch ? String(activation.epoch) : 'not reported');
965
+ var bond = Number(activation.bond) || 0;
966
+ setText('activation-note', bond > 0
967
+ ? 'Bond: ' + (bond / 1000000) + ' OCT (raw ' + bond + ').'
968
+ : '');
969
+ modal.hidden = false;
970
+ }
971
+
972
+ function closeActivation() {
973
+ var modal = $('activation-modal');
974
+ if (modal) { modal.hidden = true; }
975
+ }
976
+
977
+ /* -- the exit popup: when the stake comes back --------------------------------
978
+ An exit is a chain deadline, not a finished job. The step reports the epoch
979
+ the chain recorded the exit at, the epoch the bond unlocks and how far away
980
+ that is; the console then follows the unbonding in the background (the host
981
+ retries the withdrawal every 15 minutes by itself) and this popup opens again
982
+ the moment validator_bond reaches 0 - nobody re-runs a phase or polls a
983
+ script. `info.id` changes only when the answer really changes (pending ->
984
+ done, or a new exit), so a poll does not reopen it. --------------------- */
985
+ function showExit(info) {
986
+ var modal = $('exit-state-modal');
987
+ if (!modal || !info || !info.ip) return;
988
+ if (state.seenExit === info.id) return; // already shown
989
+ state.seenExit = info.id;
990
+ var done = !!info.done;
991
+ setText('exit-state-title', done ? 'Stake is back' : 'Exit accepted');
992
+ setText('exit-state-text', done
993
+ ? 'The bond left the registry: the stake is back in the validator wallet.'
994
+ : 'The validator left the set. The stake unlocks at the epoch below - there is nothing to do on your side.');
995
+ setText('exit-state-node', info.ip);
996
+ setText('exit-state-epoch', info.exit_epoch ? String(info.exit_epoch) : 'not recorded yet');
997
+ setText('exit-state-unlock', info.withdraw_epoch ? String(info.withdraw_epoch) : 'not reported yet');
998
+ setText('exit-state-remaining', (info.remaining === null || info.remaining === undefined)
999
+ ? '-' : String(info.remaining) + ' epoch(s)');
1000
+ setText('exit-state-eta', done ? 'done' : (info.eta || 'unknown yet'));
1001
+ if (done) {
1002
+ clearNote('exit-state-note');
1003
+ } else {
1004
+ setText('exit-state-note', 'The console keeps following this in the background (the host withdraws the '
1005
+ + 'stake by itself) and opens this popup again when the funds are back.');
1006
+ }
1007
+ modal.hidden = false;
1008
+ }
1009
+
1010
+ function closeExitReport() {
1011
+ var modal = $('exit-state-modal');
1012
+ if (modal) { modal.hidden = true; }
1013
+ }
1014
+
1015
+ /* the pause the pipeline stopped on decides which popup opens */
1016
+ function showAwaitPrompt(awaiting) {
1017
+ if (!awaiting) return;
1018
+ showEnrollPrompt(awaiting);
1019
+ }
1020
+
1021
+ /* - safety banner - */
1022
+ function safetyChecks() {
1023
+ var box = $('safety-alert');
1024
+ if (!box || !state.cfg) return;
1025
+ var notes = [];
1026
+ if (state.program === 'manage') {
1027
+ // critical, one host: the node must be the voting validator
1028
+ var metaM = programMeta();
1029
+ if (metaM && !metaM.orchestratorPresent) {
1030
+ notes.push(['err', (metaM.orchestrator || 'the manage assets') + ' are missing.']);
1031
+ }
1032
+ if (state.cfg && state.cfg.bondIncreasePresent === false) {
1033
+ notes.push(['err', 'bond_inc.sh is missing in the project root - the increase cannot run.']);
1034
+ }
1035
+ var node = state.nodes.dst;
1036
+ if (node && node.reachable && !node.active) {
1037
+ notes.push(['warn', 'this node is not an active validator - the manage program operates the host that votes.']);
1038
+ }
1039
+ }
1040
+ if (state.program === 'manage') {
1041
+ // the update actions live in this tab too: the console must have the
1042
+ // orchestrator they run through
1043
+ if (state.cfg && state.cfg.upgradeOrchestratorPresent === false) {
1044
+ notes.push(['err', 'the update assets are missing - the update actions cannot run.']);
1045
+ }
1046
+ var nodeM = state.nodes.dst;
1047
+ if (nodeM && nodeM.reachable && nodeM.active) {
1048
+ notes.push(['warn', 'this node is the ACTIVE validator: an update restarts it, so it is offline for a few minutes.']);
1049
+ }
1050
+ }
1051
+ if (state.program !== 'manage') {
1052
+ var deploy = state.program === 'deploy';
1053
+ var oldIp = deploy ? '' : val('inp-old');
1054
+ var newIp = val('inp-new');
1055
+ var meta = programMeta();
1056
+
1057
+ if (meta && !meta.orchestratorPresent) {
1058
+ notes.push(['err', (meta.orchestrator || 'orchestrator') + ' is missing.']);
1059
+ }
1060
+ if (oldIp && newIp && oldIp === newIp) notes.push(['err', 'source and target are the same host.']);
1061
+ var src = state.nodes.src;
1062
+ if (!deploy && src && src.reachable && !src.active) {
1063
+ notes.push(['warn', 'source is not an active validator (membership: ' + membershipText(src) + ').']);
1064
+ }
1065
+ var dst = state.nodes.dst;
1066
+ if (!deploy && dst && dst.reachable && (dst.active || dst.scheduled)) {
1067
+ notes.push(['warn', 'target already runs a validator - "prep" will refuse (freshness guard).']);
1068
+ }
1069
+ if (deploy && dst && dst.reachable && dst.active) {
1070
+ notes.push(['warn', 'the target already runs a node - deploy is for a fresh VPS.']);
1071
+ }
1072
+ }
1073
+
1074
+ if (notes.length === 0) { box.hidden = true; box.textContent = ''; return; }
1075
+ box.hidden = false;
1076
+ box.textContent = '';
1077
+ notes.forEach(function (item) {
1078
+ var row = document.createElement('div');
1079
+ row.className = 'mc-alert__row mc-alert__row--' + item[0];
1080
+ row.textContent = item[1];
1081
+ box.appendChild(row);
1082
+ });
1083
+ }
1084
+
1085
+ /* - approval modal: one click, no typing --------------------------- */
1086
+ /* The phase catalogue still carries a word (DEPLOY, MIGRATE, ...) for the
1087
+ CLI and the API, but the console only asks the operator to approve. */
1088
+ function openConfirm(meta, payload) {
1089
+ state.confirm = { meta: meta, payload: payload };
1090
+ setText('confirm-title', meta.label);
1091
+ var text = $('confirm-text');
1092
+ if (text) { text.textContent = meta.desc; }
1093
+ var target = payload.action === 'trust'
1094
+ ? (payload.ip || '')
1095
+ : (payload.oldIp && payload.oldIp !== payload.newIp
1096
+ ? payload.oldIp + ' -> ' + payload.newIp : payload.newIp);
1097
+ var risk = (meta.risk === 'critical' ? 'DESTRUCTIVE - ' : 'Runs on - ') + target;
1098
+ if (payload.action === 'trust') { risk = 'Host key for ' + target + ' - only accept a key you expect.'; }
1099
+ if (meta.id === 'switch' || meta.id === 'auto' || meta.id === 'all') {
1100
+ risk += '. The source node will STOP and go offline.';
1101
+ }
1102
+ setText('confirm-risk', risk);
1103
+ $('confirm-modal').hidden = false;
1104
+ var ok = $('confirm-ok');
1105
+ if (ok) { ok.disabled = false; ok.focus(); }
1106
+ }
1107
+
1108
+ function closeConfirm() {
1109
+ state.confirm = null;
1110
+ $('confirm-modal').hidden = true;
1111
+ }
1112
+
1113
+ function submitConfirm() {
1114
+ if (!state.confirm) return;
1115
+ var payload = Object.assign({}, state.confirm.payload);
1116
+ var meta = state.confirm.meta;
1117
+ payload.approved = true; // the click IS the confirmation
1118
+ payload.confirm = meta.confirm || ''; // kept for CLI-compatible clients
1119
+ closeConfirm();
1120
+ if (payload.action === 'trust') {
1121
+ trustHost(payload.ip, null);
1122
+ return;
1123
+ }
1124
+ postRun(payload, meta);
1125
+ }
1126
+
1127
+ /* - project settings (swap) ------------------------------------------- */
1128
+ /* The data store is detected and formatted by the program itself, so there is
1129
+ nothing to type for it; the monitoring dashboard moved to the Manage active
1130
+ node tab (its own modal), so this section only carries the swap size. */
1131
+ function fillSettings(settings) {
1132
+ settings = settings || {};
1133
+ state.settings = settings;
1134
+ if ($('set-swap')) { $('set-swap').value = String(settings.swapGB); }
1135
+ syncDashboardGate();
1136
+ }
1137
+
1138
+ /* The "dashboard is off" row and the follow-up logic below share one idea: it is
1139
+ an ACTION row, never something to read while filling the form. */
1140
+ /* Which run is the one this page is following: the server keeps the last run in
1141
+ memory, so a page opened later (or a reload) receives a *finished* run from a
1142
+ previous session through /api/state. Watching means: this page saw it running.
1143
+ A different run that was never seen running clears the marker again. */
1144
+ function noteRunWatch(s) {
1145
+ if (s.running) { state.runWatch = s.startedAt; return; }
1146
+ if (state.runWatch !== s.startedAt) { state.runWatch = null; }
1147
+ }
1148
+
1149
+ /* Phases that BUILD or MOVE a node. The dashboard deploy is deliberately not one
1150
+ of them: once the monitoring UI is online there is nothing left to offer. */
1151
+ var BUILD_PHASES = { deploy: true, enroll: true, auto: true, all: true };
1152
+
1153
+ /* The row under Project settings is a *pointer*, not a form field: it says where
1154
+ monitoring, bond and exit live now that a deploy/migrate run does not touch the
1155
+ dashboard at all (2026-11-05). It appears only after a node-building run that
1156
+ finished successfully *while this page was open* (2026-11-04 report: the console
1157
+ remembered a finished deploy of an older session and the row was there the moment
1158
+ the app was opened, before any address was typed). */
1159
+ function finishedNodeRun() {
1160
+ var run = state.runner;
1161
+ if (!run || run.running || run.exitCode !== 0) return null;
1162
+ if (run.awaiting && run.awaiting.action) return null; // paused for a popup
1163
+ if (run.program !== 'deploy' && run.program !== 'migrate') return null;
1164
+ if (!run.newIp) return null;
1165
+ if (!state.runWatch || state.runWatch !== run.startedAt) return null;
1166
+ if (!BUILD_PHASES[run.phase]) return null; // 'dashboard' = published
1167
+ return run;
1168
+ }
1169
+
1170
+ function syncDashboardGate() {
1171
+ var row = panelId('settings-off');
1172
+ if (!row) return;
1173
+ var run = finishedNodeRun();
1174
+ row.hidden = !run;
1175
+ if (!run) return;
1176
+ setText('dash-off-host', run.newIp);
1177
+ }
1178
+
1179
+ /* host the follow-up row acts on: the finished run, else what is typed */
1180
+ function gateHost() {
1181
+ var run = finishedNodeRun();
1182
+ return (run && run.newIp) || val('inp-new');
1183
+ }
1184
+
1185
+ /* Typing an address invalidates the check; it is no longer a gate condition. */
1186
+ function bindHostInputs() {
1187
+ ['d-inp-ip', 'inp-new', 'm-inp-ip'].forEach(function (id) {
1188
+ var input = document.getElementById(id);
1189
+ if (!input || input.__hostBound) return;
1190
+ input.__hostBound = true;
1191
+ input.addEventListener('input', function () {
1192
+ invalidateCheck();
1193
+ if (state.program === 'migrate') { rememberHosts(); }
1194
+ });
1195
+ });
1196
+ }
1197
+
1198
+ function loadSettings() {
1199
+ return getJSON('/api/settings').then(function (body) {
1200
+ fillSettings(body.settings);
1201
+ }).catch(function (err) { console.warn('settings load failed', err); });
1202
+ }
1203
+
1204
+ function saveSettings(anchor) {
1205
+ var previous = state.settings || {};
1206
+ var body = {
1207
+ swapGB: Number(val('set-swap')),
1208
+ // kept server-side with their defaults: the form no longer asks for them
1209
+ dataDevice: previous.dataDevice || '/dev/sdb',
1210
+ dataMinGB: previous.dataMinGB || 1000
1211
+ };
1212
+ postJSON('/api/settings', body).then(function (res) {
1213
+ fillSettings(res.settings);
1214
+ syncDashboardGate();
1215
+ tip(anchor, 'Settings saved');
1216
+ setText('settings-meta', 'saved ' + new Date().toLocaleTimeString() +
1217
+ ' - swap ' + res.settings.swapGB + ' GB');
1218
+ }).catch(function (err) {
1219
+ tip(anchor, err.message, 'err');
1220
+ });
1221
+ }
1222
+
1223
+ /* A 404 from these helper endpoints means the console process is older than
1224
+ the page (the route only exists in the new server.py): say that instead of
1225
+ leaving the operator with a bare "unknown endpoint". */
1226
+ function endpointError(err, what) {
1227
+ var msg = String((err && err.message) || 'request failed');
1228
+ if (/404|unknown endpoint/i.test(msg)) {
1229
+ return 'the console runs an older server - restart it and try ' + what + ' again';
1230
+ }
1231
+ return msg;
1232
+ }
1233
+
1234
+ /* -- file picking ------------------------------------------------------ */
1235
+ /* Browsers never reveal a path, so every picker ends the same way: the file
1236
+ text is read here and the server stores it (tmp/keys/, tmp/certs/) and
1237
+ answers with the path the orchestrator receives. The dialog is the
1238
+ browser's own: a native dialog opened by the console process is created but
1239
+ never activated (Windows refuses the foreground to a background process),
1240
+ which is why "Browse" used to look dead. */
1241
+ var pendingFilePick = null;
1242
+
1243
+ function browserPicksDirectories() {
1244
+ try {
1245
+ return 'webkitdirectory' in document.createElement('input');
1246
+ } catch (err) { return false; }
1247
+ }
1248
+
1249
+ function readFileText(file) {
1250
+ return new Promise(function (resolve, reject) {
1251
+ var reader = new FileReader();
1252
+ reader.onload = function () { resolve(String(reader.result || '')); };
1253
+ reader.onerror = function () { reject(new Error('cannot read ' + ((file && file.name) || 'that file'))); };
1254
+ reader.readAsText(file);
1255
+ });
1256
+ }
1257
+
1258
+ /* Open one of the hidden inputs; `onFiles` runs with the chosen FileList. */
1259
+ function openFilePicker(inputId, onFiles) {
1260
+ var input = document.getElementById(inputId);
1261
+ if (!input || typeof input.click !== 'function') return false;
1262
+ pendingFilePick = { input: input, onFiles: onFiles };
1263
+ input.value = '';
1264
+ input.click();
1265
+ return true;
1266
+ }
1267
+
1268
+ function bindFilePickers() {
1269
+ ['inp-key-pick', 'inp-cert-dir'].forEach(function (id) {
1270
+ var input = document.getElementById(id);
1271
+ if (!input || input.__pickBound) return;
1272
+ input.__pickBound = true;
1273
+ input.addEventListener('change', function () {
1274
+ var pending = pendingFilePick;
1275
+ pendingFilePick = null;
1276
+ if (!pending || pending.input !== input) return;
1277
+ var files = [];
1278
+ var list = input.files || [];
1279
+ for (var i = 0; i < list.length; i++) { files.push(list[i]); }
1280
+ if (files.length) pending.onFiles(files);
1281
+ });
1282
+ });
1283
+ }
1284
+
1285
+
1286
+ /* -- SSH key: pick a file on this machine, or paste the key text ------- */
1287
+ /* The Browse button opens the *browser's* file dialog (always in front) and
1288
+ the key text is sent to /api/keyfile, which stores it 0600 under tmp/keys/
1289
+ and answers with the path the orchestrator receives. The native picker of
1290
+ the console process stays as the fallback. */
1291
+ function pickKey(anchor, input) {
1292
+ var target = (anchor && anchor.getAttribute('data-key')) || '';
1293
+ var field = input || (target ? document.getElementById(target) : null);
1294
+ var opened = openFilePicker('inp-key-pick', function (files) {
1295
+ readFileText(files[0]).then(function (text) {
1296
+ return postJSON('/api/keyfile', { text: text });
1297
+ }).then(function (res) {
1298
+ if (field) field.value = res.path;
1299
+ invalidateCheck();
1300
+ tip(anchor, 'Key stored at ' + res.path);
1301
+ }).catch(function (err) {
1302
+ tip(anchor, endpointError(err, 'the key file'), 'err');
1303
+ });
1304
+ });
1305
+ if (opened) return;
1306
+ postJSON('/api/pickkey', {}).then(function (res) {
1307
+ if (res.path) {
1308
+ if (field) { field.value = res.path; }
1309
+ invalidateCheck();
1310
+ tip(anchor, 'Key selected');
1311
+ } else {
1312
+ tip(anchor, res.error || 'no file picked', 'warn');
1313
+ }
1314
+ }).catch(function (err) { tip(anchor, endpointError(err, 'the key picker'), 'err'); });
1315
+ }
1316
+
1317
+ function saveKeyText(area, keyInput) {
1318
+ var text = area ? String(area.value || '') : '';
1319
+ if (!text.trim()) return;
1320
+ postJSON('/api/keyfile', { text: text }).then(function (res) {
1321
+ if (keyInput) { keyInput.value = res.path; }
1322
+ invalidateCheck();
1323
+ setText('settings-meta', 'key stored at ' + res.path);
1324
+ }).catch(function (err) {
1325
+ setText('settings-meta', 'key paste failed: ' + endpointError(err, 'the key paste'));
1326
+ if (window.console) console.warn('key paste failed', err);
1327
+ });
1328
+ }
1329
+
1330
+ /* -- host key trust (after a VPS re-install) ------------------- */
1331
+ function trustHost(ip, anchor) {
1332
+ var p = pair();
1333
+ var body = { ip: ip };
1334
+ if (ip === p.oldIp) {
1335
+ body.password = p.oldPassword;
1336
+ body.auth = p.oldAuth;
1337
+ body.key = p.oldKey;
1338
+ } else if (ip === p.newIp) {
1339
+ body.password = p.newPassword;
1340
+ body.auth = p.newAuth;
1341
+ body.key = p.newKey;
1342
+ }
1343
+ postJSON('/api/trust', body).then(function (result) {
1344
+ if (anchor) {
1345
+ tip(anchor, result.ok ? 'Host key trusted' : (result.detail || 'trust failed'),
1346
+ result.ok ? 'ok' : 'err');
1347
+ }
1348
+ // re-check so the panel shows the post-trust state
1349
+ setTimeout(function () { runCheck(); }, 200);
1350
+ }).catch(function (err) {
1351
+ if (anchor) tip(anchor, err.message, 'err');
1352
+ setText('check-summary', 'trust failed: ' + err.message);
1353
+ });
1354
+ }
1355
+
1356
+ /* - run / cancel - */
1357
+ function buildPayload(phase) {
1358
+ var p = pair();
1359
+ var payload = {
1360
+ phase: phase,
1361
+ program: state.program,
1362
+ oldIp: p.oldIp,
1363
+ newIp: p.newIp,
1364
+ oldPassword: p.oldPassword,
1365
+ newPassword: p.newPassword,
1366
+ oldAuth: p.oldAuth,
1367
+ newAuth: p.newAuth,
1368
+ oldKey: p.oldKey,
1369
+ newKey: p.newKey,
1370
+ sameAuth: samePass(),
1371
+ // the dashboard host name is NOT part of the form: the pipeline pauses for
1372
+ // it (domain popup) and only the follow-up phase carries it
1373
+ domain: ''
1374
+ };
1375
+ if (state.program === 'deploy') {
1376
+ payload.role = val('inp-role') || (state.cfg && state.cfg.defaultRole) || 'validator';
1377
+ // the node name is generated by the program (octra-node-<hostname>) and the
1378
+ // bond is asked in the "Ready for enrollment" popup, so neither is sent here;
1379
+ // the verify samples/interval are program constants on the server
1380
+ payload.bondOct = 0;
1381
+ }
1382
+ // the manage program collects its input in modals (bond amount, monitor
1383
+ // domain + TLS choice): they set the fields on the payload just before posting
1384
+ return payload;
1385
+ }
1386
+
1387
+ /* The credential the operator chose for one side is either a password or an
1388
+ OpenSSH key file: the console validates whichever it is. */
1389
+ function authReady(p, side) {
1390
+ var method = side === 'old' ? p.oldAuth : p.newAuth;
1391
+ var key = side === 'old' ? p.oldKey : p.newKey;
1392
+ var password = side === 'old' ? p.oldPassword : p.newPassword;
1393
+ if (method === 'key') { return !!key; }
1394
+ if (method === 'agent') { return true; }
1395
+ return !!password;
1396
+ }
1397
+
1398
+ function phaseMeta(id) {
1399
+ var programs = (state.cfg && state.cfg.programs) || [];
1400
+ var program = programs.filter(function (p) { return p.id === state.program; })[0];
1401
+ var list = (program && program.phases) || (state.cfg && state.cfg.phases) || [];
1402
+ return list.filter(function (p) { return p.id === id; })[0] || null;
1403
+ }
1404
+
1405
+ function programMeta() {
1406
+ var programs = (state.cfg && state.cfg.programs) || [];
1407
+ return programs.filter(function (p) { return p.id === state.program; })[0] || null;
1408
+ }
1409
+
1410
+ function programMetaById(id) {
1411
+ var programs = (state.cfg && state.cfg.programs) || [];
1412
+ return programs.filter(function (p) { return p.id === id; })[0] || null;
1413
+ }
1414
+
1415
+ /* the pipeline strip inside each dashboard: every step, in order, so the
1416
+ operator sees what "Start" will do (deploy pauses before enroll) */
1417
+ function renderPipelineInto(boxId, meta, program) {
1418
+ var box = panelId(boxId);
1419
+ if (!box) return;
1420
+ box.textContent = '';
1421
+ var mainPhase = program === 'deploy' ? 'deploy' : (program === 'manage' ? 'bond' : 'auto');
1422
+ var phases = ((meta && meta.phases) || []).filter(function (ph) {
1423
+ return ph.risk !== 'readonly' && ph.id !== 'rollback';
1424
+ });
1425
+ phases.forEach(function (ph, idx) {
1426
+ var chip = document.createElement('span');
1427
+ var cls = 'mc-pipeline__step';
1428
+ if (ph.id === mainPhase) { cls += ' mc-pipeline__step--main'; }
1429
+ else if (ph.risk === 'critical') { cls += ' mc-pipeline__step--critical'; }
1430
+ chip.className = cls;
1431
+ chip.textContent = ph.label;
1432
+ chip.title = ph.desc || '';
1433
+ box.appendChild(chip);
1434
+ if (idx < phases.length - 1) {
1435
+ var sep = document.createElement('span');
1436
+ sep.className = 'mc-pipeline__sep';
1437
+ sep.textContent = '>';
1438
+ box.appendChild(sep);
1439
+ }
1440
+ });
1441
+ }
1442
+
1443
+ /* - program switcher: two independent dashboards ------------------------ */
1444
+ /* Only the selected dashboard is on screen (fields, check, progress and
1445
+ actions are separate), so nothing from the other program can confuse the
1446
+ operator. The choice is remembered (localStorage + #hash). */
1447
+ function renderProgram() {
1448
+ var program = state.program;
1449
+ var meta = programMeta();
1450
+ var panelDeploy = panelId('panel-deploy');
1451
+ var panelMigrate = panelId('panel-migrate');
1452
+ var panelManage = panelId('panel-manage');
1453
+ if (panelDeploy) { panelDeploy.hidden = program !== 'deploy'; }
1454
+ if (panelMigrate) { panelMigrate.hidden = program !== 'migrate'; }
1455
+ if (panelManage) { panelManage.hidden = program !== 'manage'; }
1456
+ // Project settings (swap + data store) belong to the deploy and migrate flows.
1457
+ // Manage operates a node that already runs (bond, exit, update, prune, monitor),
1458
+ // so the section is not shown there.
1459
+ // Project settings (swap + data store) belong to the deploy and migrate flows.
1460
+ // Manage operates a node that already runs (bond, exit, update, prune, monitor),
1461
+ // so the section is not shown there.
1462
+ var settingsSection = panelId('settings');
1463
+ if (settingsSection) { settingsSection.hidden = program === 'manage'; }
1464
+
1465
+ // one app, three tools: the app name is fixed, the tool is the hero title
1466
+ document.title = 'Validator Management System';
1467
+ setText('topbar-title', 'Validator Management');
1468
+ setText('hero-title', meta ? meta.label : 'Validator Management');
1469
+ var orchPath = (meta && meta.orchestrator) || (state.cfg && state.cfg.orchestrator) || '';
1470
+ var orchName = orchPath ? String(orchPath).replace(/^.*[\\/]/, '') : '-';
1471
+ var orchOk = (meta && typeof meta.orchestratorPresent === 'boolean')
1472
+ ? meta.orchestratorPresent : !!(state.cfg && state.cfg.orchestratorPresent);
1473
+ setText('hero-orchestrator', orchOk ? orchName : (orchName + ' MISSING'));
1474
+
1475
+ var seg = $('seg-programs');
1476
+ if (seg) {
1477
+ var buttons = seg.querySelectorAll('.mc-tab');
1478
+ for (var i = 0; i < buttons.length; i++) {
1479
+ var on = buttons[i].getAttribute('data-program') === state.program;
1480
+ buttons[i].className = 'mc-tab' + (on ? ' mc-tab--active' : '');
1481
+ buttons[i].setAttribute('aria-selected', String(on));
1482
+ }
1483
+ }
1484
+
1485
+ renderPipelineInto('d-pipeline', programMetaById('deploy'), 'deploy');
1486
+ renderPipelineInto('pipeline-list', programMetaById('migrate'), 'migrate');
1487
+ renderPipelineInto('m-pipeline', programMetaById('manage'), 'manage');
1488
+
1489
+ var dashWarning = (state.cfg && !state.cfg.dashboardPresent)
1490
+ ? 'WARNING: dashboard assets are missing - the dashboard will not be deployed.' : '';
1491
+ setText('d-action-note', dashWarning ||
1492
+ 'One click: check - prep - toolchain - setup - start - verify, then (validator) the top-up popup, '
1493
+ + 'enroll - promote - verify, and the console watches the chain admit the validator.');
1494
+ setText('m-action-note', (state.cfg && state.cfg.bondIncreasePresent === false)
1495
+ ? 'WARNING: bond_inc.sh is missing in the project root - the increase cannot run.'
1496
+ : 'Critical: bond and exit need the ACTIVE validator. Increase bond = bond_inc.sh as the octra user '
1497
+ + '(the first increase runs plain, later ones add --new automatically). Exit = exit the set, wait '
1498
+ + 'for the unbonding, withdraw the stake. Update = controls/upgrade.sh (tested first, then applied) - '
1499
+ + 'the manual fallback pulls and rebuilds by hand; prune only drops the preserved states.');
1500
+ // the migrate note is only filled when there is something to say (the last
1501
+ // identity backup); the stale "one click: ... deploy dashboard" sentence is gone
1502
+ var migrateNote = dashWarning;
1503
+ if (!dashWarning && state.cfg && state.cfg.archives && state.cfg.archives.length) {
1504
+ migrateNote = 'automatic pipeline - last identity backup: ' + state.cfg.archives[0].name +
1505
+ ' (' + Math.max(1, Math.round(state.cfg.archives[0].bytes / 1024)) + ' KB)';
1506
+ }
1507
+ // setText turns '' into '-': an empty note must disappear instead, so it is
1508
+ // written directly (the markup keeps the span for the dynamic text)
1509
+ var migrateNoteEl = $('action-note');
1510
+ if (migrateNoteEl) { migrateNoteEl.textContent = migrateNote || ''; }
1511
+ // the manage actions (bond, exit, update, prune, monitor, status) live in the
1512
+ // "Live node state" section (next to the state they act on): only that tool
1513
+ // shows the block, and a single-host dashboard needs no pair divider
1514
+ var nodeActions = panelId('node-actions');
1515
+ if (nodeActions) { nodeActions.hidden = program !== 'manage'; }
1516
+ var nodesGrid = panelId('nodes-grid');
1517
+ if (nodesGrid) {
1518
+ nodesGrid.className = 'mc-nodes' + (oneHostProgram(program) ? ' mc-nodes--manage' : '');
1519
+ }
1520
+ setText('hero-project', (state.cfg && state.cfg.projectRoot || '').replace(/^.*[\\/]/, '') || 'project');
1521
+
1522
+ // the source node card belongs to the migration dashboard
1523
+ var srcCard = panelId('node-src-card');
1524
+ if (srcCard) { srcCard.hidden = program !== 'migrate'; }
1525
+
1526
+ rememberProgram();
1527
+ invalidateCheck();
1528
+ bindAuthRows();
1529
+ bindHostInputs();
1530
+ syncPasswordFields();
1531
+ syncAllAuthFields();
1532
+ syncDashboardGate();
1533
+ // this tab's own data: its log, its host chips, its node cards
1534
+ renderLogFor(program);
1535
+ var cached = state.nodesByProgram[program];
1536
+ renderNode('src', cached ? cached.src : null);
1537
+ renderNode('dst', cached ? cached.dst : null);
1538
+ safetyChecks();
1539
+ applyGating();
1540
+ renderRoute();
1541
+ }
1542
+
1543
+ /* Bind the auth rows of every panel once (renderProgram calls it on each
1544
+ switch): the method selector reveals the key row, Browse opens the native
1545
+ dialog, and the paste box becomes a 0600 file whose path fills the field. */
1546
+ function bindAuthRows() {
1547
+ for (var i = 0; i < AUTH_SELECTS.length; i++) {
1548
+ (function (select) {
1549
+ if (!select || select.__authBound) return;
1550
+ select.__authBound = true;
1551
+ select.addEventListener('change', function () {
1552
+ syncAuthRow(select);
1553
+ invalidateCheck();
1554
+ });
1555
+ var key = document.getElementById(select.getAttribute('data-key') || '');
1556
+ if (key) key.addEventListener('input', function () { invalidateCheck(); });
1557
+ var pick = document.getElementById(select.getAttribute('data-pick') || '');
1558
+ if (pick) {
1559
+ pick.addEventListener('click', function (ev) {
1560
+ pickKey(ev && ev.currentTarget ? ev.currentTarget : pick, key);
1561
+ });
1562
+ }
1563
+ var text = document.getElementById(select.getAttribute('data-keytext') || '');
1564
+ if (text) text.addEventListener('change', function () { saveKeyText(text, key); });
1565
+ })(document.getElementById(AUTH_SELECTS[i]));
1566
+ }
1567
+ var same = document.getElementById('chk-same-pass');
1568
+ if (same && !same.__authBound) {
1569
+ same.__authBound = true;
1570
+ same.addEventListener('change', function () {
1571
+ syncPasswordFields();
1572
+ syncAllAuthFields();
1573
+ invalidateCheck();
1574
+ });
1575
+ }
1576
+ }
1577
+
1578
+ /* Switch program from anywhere (tabs, "Go to Manage active node"): the address
1579
+ typed on one dashboard follows the operator to the next one. */
1580
+ function setProgram(program) {
1581
+ if (PROGRAMS.indexOf(program) < 0) return;
1582
+ if (program === state.program) return;
1583
+ var carry = val('inp-new') || val('inp-old');
1584
+ state.program = program;
1585
+ renderProgram();
1586
+ if (carry) {
1587
+ var target = $('inp-new');
1588
+ if (target && !target.value) { target.value = carry; }
1589
+ }
1590
+ invalidateCheck();
1591
+ renderRoute();
1592
+ }
1593
+
1594
+ function rememberProgram() {
1595
+ try { localStorage.setItem(PROGRAM_KEY, state.program); } catch (err) { /* private mode */ }
1596
+ try {
1597
+ if (window.location && window.history
1598
+ && window.location.hash !== '#' + state.program) {
1599
+ window.history.replaceState(null, '', '#' + state.program);
1600
+ }
1601
+ } catch (err) { /* an embedded/headless browser may not allow history */ }
1602
+ }
1603
+
1604
+ /* which dashboard opens: the #hash wins, then the last used, then the server default */
1605
+ function restoreProgram(cfg) {
1606
+ var hash = '';
1607
+ try {
1608
+ hash = (window.location && window.location.hash)
1609
+ ? String(window.location.hash).replace('#', '') : '';
1610
+ } catch (err) { hash = ''; }
1611
+ if (hash && PROGRAMS.indexOf(hash) >= 0) { return hash; }
1612
+ try {
1613
+ var saved = localStorage.getItem(PROGRAM_KEY);
1614
+ if (saved && PROGRAMS.indexOf(saved) >= 0) { return saved; }
1615
+ } catch (err) { /* private mode */ }
1616
+ var ids = ((cfg && cfg.programs) || []).map(function (p) { return p.id; });
1617
+ if (cfg && cfg.defaultProgram && ids.indexOf(cfg.defaultProgram) >= 0) { return cfg.defaultProgram; }
1618
+ return 'migrate';
1619
+ }
1620
+
1621
+ var DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
1622
+
1623
+ /* The only forward action: the backend runs the whole pipeline of the current
1624
+ program in order (deploy: check -> prep -> ... -> dashboard; migrate:
1625
+ preconditions -> migration -> dashboard deploy). */
1626
+ /* - manage program: three explicit actions on ONE host --------------- */
1627
+ /* Bond and exit collect their input in a modal (the panel has no fields any
1628
+ more): the operator types the amount - or just confirms the exit - and the
1629
+ modal posts it. A passing Check is still required, because both sign a
1630
+ transaction with the wallet of the voting validator. */
1631
+ function startManage(phase, anchor) {
1632
+ var meta = phaseMeta(phase);
1633
+ if (!meta) { tip(anchor, 'console config not loaded yet', 'err'); return; }
1634
+ var p = pair();
1635
+ if (!p.newIp) { tip(anchor, 'Type the node address first', 'err'); return; }
1636
+ if (!authReady(p, 'new')) { tip(anchor, 'Type the node credentials first', 'err'); return; }
1637
+ if (busy()) { tip(anchor, 'A phase is already running', 'err'); return; }
1638
+ if (phase !== 'status' && !checkOk()) {
1639
+ tip(anchor, 'Press Check first - the node must be the active validator', 'err');
1640
+ return;
1641
+ }
1642
+ if (phase === 'bond') { openBondModal(); return; }
1643
+ if (phase === 'exit') { openExitModal(); return; }
1644
+ openConfirm(meta, buildPayload(phase));
1645
+ }
1646
+
1647
+ /* -- Increase bond: amount + confirmation in a modal ----------------- */
1648
+ function openBondModal() {
1649
+ var modal = panelId('bond-modal');
1650
+ if (!modal) return;
1651
+ setText('bond-node', val('inp-new'));
1652
+ var amount = panelId('bond-amount');
1653
+ if (amount) { if (!amount.value) amount.value = '1'; amount.focus(); amount.select(); }
1654
+ clearNote('bond-note');
1655
+ modal.hidden = false;
1656
+ }
1657
+
1658
+ function closeBond() {
1659
+ var modal = panelId('bond-modal');
1660
+ if (modal) modal.hidden = true;
1661
+ }
1662
+
1663
+ function submitBond() {
1664
+ var amount = String(val('bond-amount') || '').trim();
1665
+ if (!/^[0-9]+(\.[0-9]{1,6})?$/.test(amount) || Number(amount) < 1) {
1666
+ setText('bond-note', 'Type the amount in OCT (min 1, e.g. 100 or 12.5).');
1667
+ return;
1668
+ }
1669
+ var meta = phaseMeta('bond');
1670
+ if (!meta) { setText('bond-note', 'console config not loaded yet'); return; }
1671
+ var payload = buildPayload('bond');
1672
+ payload.amount = amount;
1673
+ payload.approved = true;
1674
+ payload.confirm = meta.confirm || 'BOND';
1675
+ closeBond();
1676
+ postRun(payload, meta, null);
1677
+ }
1678
+
1679
+ /* -- Exit validator: an explicit confirmation modal ------------------ */
1680
+ function openExitModal() {
1681
+ var modal = panelId('exit-modal');
1682
+ if (!modal) return;
1683
+ setText('exit-node', val('inp-new'));
1684
+ clearNote('exit-note');
1685
+ modal.hidden = false;
1686
+ }
1687
+
1688
+ function closeExit() {
1689
+ var modal = panelId('exit-modal');
1690
+ if (modal) modal.hidden = true;
1691
+ }
1692
+
1693
+ function submitExit() {
1694
+ var meta = phaseMeta('exit');
1695
+ if (!meta) { setText('exit-note', 'console config not loaded yet'); return; }
1696
+ var payload = buildPayload('exit');
1697
+ payload.approved = true;
1698
+ payload.confirm = meta.confirm || 'EXIT';
1699
+ closeExit();
1700
+ postRun(payload, meta, null);
1701
+ }
1702
+
1703
+ /* "node-<last5 of the wallet>.example.com" - the same rule the server uses for
1704
+ the domain popup, computed here so the monitor modal opens with a usable
1705
+ name instead of an empty field. */
1706
+ function suggestDomain(address) {
1707
+ var clean = String(address || '').toLowerCase().replace(/[^0-9a-z]/g, '');
1708
+ if (clean.length < 5) return '';
1709
+ var suffix = (state.cfg && state.cfg.domainSuffix) || 'example.com';
1710
+ return 'node-' + clean.slice(-5) + '.' + suffix;
1711
+ }
1712
+
1713
+ /* A field that failed validation must be impossible to miss: message next to
1714
+ it, red outline, focus - plus a tip on the button that was pressed. "The
1715
+ button did nothing" is the worst possible feedback. */
1716
+ function fieldError(inputId, noteId, message, anchor) {
1717
+ var input = panelId(inputId);
1718
+ if (input) {
1719
+ if (input.classList && input.classList.add) input.classList.add('mc-invalid');
1720
+ if (typeof input.focus === 'function') input.focus();
1721
+ if (typeof input.select === 'function') input.select();
1722
+ }
1723
+ setText(noteId, message);
1724
+ if (anchor) tip(anchor, message, 'err');
1725
+ }
1726
+
1727
+ function clearFieldError(inputId, noteId) {
1728
+ var input = panelId(inputId);
1729
+ if (input && input.classList && input.classList.remove) input.classList.remove('mc-invalid');
1730
+ if (noteId) clearNote(noteId);
1731
+ }
1732
+
1733
+ /* setText() turns '' into '-', which would leave a stray dash line in a modal:
1734
+ modal notes must become empty so `:empty { display: none }` hides them. */
1735
+ function clearNote(id) {
1736
+ var node = panelId(id);
1737
+ if (node) node.textContent = '';
1738
+ }
1739
+
1740
+ /* -- Deploy monitor: domain + TLS choice (Cloudflare origin / certbot) */
1741
+ function openMonitorModal() {
1742
+ var modal = panelId('monitor-modal');
1743
+ if (!modal) return;
1744
+ var p = pair();
1745
+ if (!p.newIp) { tip(panelId('m-btn-dashboard'), 'Type the node address first', 'err'); return; }
1746
+ if (!authReady(p, 'new')) {
1747
+ tip(panelId('m-btn-dashboard'), 'Type the node credentials first', 'err'); return;
1748
+ }
1749
+ if (busy()) { tip(panelId('m-btn-dashboard'), 'A phase is already running', 'err'); return; }
1750
+ setText('monitor-node', p.newIp);
1751
+ var domain = panelId('monitor-domain');
1752
+ if (domain && !String(domain.value || '').trim()) {
1753
+ var nodeAddr = (state.nodes.dst && state.nodes.dst.address) || '';
1754
+ domain.value = (state.settings && state.settings.dashboardDomain)
1755
+ || suggestDomain(nodeAddr) || '';
1756
+ }
1757
+ clearFieldError('monitor-domain', 'monitor-note');
1758
+ clearFieldError('monitor-certdir', '');
1759
+ var force = panelId('monitor-force');
1760
+ if (force) force.checked = false;
1761
+ syncMonitorSsl();
1762
+ modal.hidden = false;
1763
+ }
1764
+
1765
+ function closeMonitor() {
1766
+ var modal = panelId('monitor-modal');
1767
+ if (modal) modal.hidden = true;
1768
+ }
1769
+
1770
+ function monitorSslMode() {
1771
+ var certbot = panelId('monitor-ssl-certbot');
1772
+ return (certbot && certbot.checked) ? 'certbot' : 'cloudflare';
1773
+ }
1774
+
1775
+ /* the Cloudflare folder row and the certbot e-mail row follow the radio */
1776
+ function syncMonitorSsl() {
1777
+ var mode = monitorSslMode();
1778
+ var cf = panelId('monitor-cf-row');
1779
+ var le = panelId('monitor-le-row');
1780
+ if (cf) cf.hidden = mode !== 'cloudflare';
1781
+ if (le) le.hidden = mode !== 'certbot';
1782
+ }
1783
+
1784
+ /* the TLS folder note: green when the pair is complete, red when it is not */
1785
+ function certDirNote(text, isError) {
1786
+ var note = panelId('monitor-certdir-note');
1787
+ if (!note) return;
1788
+ note.textContent = text;
1789
+ note.className = 'v2-t-xs ' + (isError ? 'mc-err' : 'v2-mute');
1790
+ if (!isError) clearFieldError('monitor-certdir', '');
1791
+ }
1792
+
1793
+ /* "Choose folder": the browser opens the folder dialog (always in front) and
1794
+ the two PEM files it holds are sent to /api/certfiles, which stores them in
1795
+ tmp/certs/<folder>/ and answers with the path -CertDir receives. A native
1796
+ folder dialog opened by the console process is built but never activated,
1797
+ so this is the only reliable way on Windows. */
1798
+ function pickCertDir() {
1799
+ var btn = panelId('monitor-certdir-pick');
1800
+ var input = panelId('monitor-certdir');
1801
+ if (browserPicksDirectories()) {
1802
+ var opened = openFilePicker('inp-cert-dir', function (files) {
1803
+ var chain = null, key = null, folder = '';
1804
+ for (var i = 0; i < files.length; i++) {
1805
+ var name = String(files[i].name || '').toLowerCase();
1806
+ var rel = String(files[i].webkitRelativePath || '');
1807
+ if (!folder && rel.indexOf('/') > 0) folder = rel.split('/')[0];
1808
+ if (name === 'fullchain.pem') chain = files[i];
1809
+ if (name === 'privkey.pem') key = files[i];
1810
+ }
1811
+ if (!chain || !key) {
1812
+ var seen = [];
1813
+ for (var j = 0; j < files.length; j++) { seen.push(files[j].name); }
1814
+ certDirNote('that folder has no fullchain.pem + privkey.pem pair (saw: '
1815
+ + seen.slice(0, 6).join(', ') + ')', true);
1816
+ return;
1817
+ }
1818
+ certDirNote('reading ' + (folder || 'the selected folder') + ' ...', false);
1819
+ Promise.all([readFileText(chain), readFileText(key)]).then(function (texts) {
1820
+ return postJSON('/api/certfiles', {
1821
+ folder: folder, fullchain: texts[0], privkey: texts[1]
1822
+ });
1823
+ }).then(function (res) {
1824
+ if (input) input.value = res.path;
1825
+ // the field holds the full path; the note shows the short form, so a
1826
+ // long C:\...\tmp\certs\... line does not dominate the dialog
1827
+ var short = String(res.path || '').replace(/^.*[\\/]tmp[\\/]/i, 'tmp\\');
1828
+ certDirNote('certificate stored here: ' + (short || res.path), false);
1829
+ tip(btn, 'Folder selected');
1830
+ }).catch(function (err) {
1831
+ certDirNote(endpointError(err, 'the certificate folder'), true);
1832
+ });
1833
+ });
1834
+ if (opened) return;
1835
+ }
1836
+ /* fallback: the native folder dialog of the console process */
1837
+ postJSON('/api/pickdir', {}).then(function (res) {
1838
+ if (!res || !res.path) {
1839
+ throw new Error((res && res.error) || 'no folder was picked');
1840
+ }
1841
+ if (input) input.value = res.path;
1842
+ var missing = res.missing || [];
1843
+ certDirNote(missing.length
1844
+ ? 'missing in that folder: ' + missing.join(', ')
1845
+ : 'folder OK: fullchain.pem + privkey.pem found', missing.length > 0);
1846
+ }).catch(function (err) { tip(btn, endpointError(err, 'the folder picker'), 'err'); });
1847
+ }
1848
+
1849
+ function submitMonitor() {
1850
+ var anchor = panelId('monitor-ok');
1851
+ var domain = String(val('monitor-domain') || '').trim().toLowerCase();
1852
+ var mode = monitorSslMode();
1853
+ var certDir = String(val('monitor-certdir') || '').trim();
1854
+ var email = String(val('monitor-email') || '').trim();
1855
+ var force = panelId('monitor-force');
1856
+ if (!domain) {
1857
+ fieldError('monitor-domain', 'monitor-note',
1858
+ 'Type the host name the monitor will be published on (for example node-abcde.example.com).',
1859
+ anchor);
1860
+ return;
1861
+ }
1862
+ if (!DOMAIN_RE.test(domain)) {
1863
+ fieldError('monitor-domain', 'monitor-note',
1864
+ 'Use a plain host name like node-abcde.example.com - no http://, no path, no port.', anchor);
1865
+ return;
1866
+ }
1867
+ if (mode === 'cloudflare' && !certDir) {
1868
+ fieldError('monitor-certdir', 'monitor-note',
1869
+ 'Pick the folder that holds fullchain.pem and privkey.pem first.', anchor);
1870
+ return;
1871
+ }
1872
+ var meta = phaseMeta('dashboard');
1873
+ if (!meta) { setText('monitor-note', 'console config not loaded yet'); return; }
1874
+ var payload = buildPayload('dashboard');
1875
+ payload.domain = domain;
1876
+ payload.sslMode = mode;
1877
+ payload.certDir = mode === 'cloudflare' ? certDir : '';
1878
+ payload.email = mode === 'certbot' ? email : '';
1879
+ payload.reset = !!(force && force.checked);
1880
+ payload.approved = true; // the click is the approval
1881
+ closeMonitor();
1882
+ postRun(payload, meta, null);
1883
+ }
1884
+
1885
+ function startMigration(anchor) {
1886
+ var phase = state.program === 'deploy' ? 'deploy' : 'auto';
1887
+ var meta = phaseMeta(phase);
1888
+ if (!meta) { tip(anchor, 'console config not loaded yet', 'err'); return; }
1889
+ var payload = buildPayload(phase);
1890
+ if (state.program === 'deploy') {
1891
+ if (!payload.newIp) { tip(anchor, 'Fill the target host first', 'err'); return; }
1892
+ if (!authReady(payload, 'new')) {
1893
+ tip(anchor, 'Type the root password or point at an OpenSSH key for the target', 'err');
1894
+ return;
1895
+ }
1896
+ } else {
1897
+ if (!payload.oldIp || !payload.newIp) { tip(anchor, 'Fill source and target first', 'err'); return; }
1898
+ if (payload.oldIp === payload.newIp) { tip(anchor, 'Source and target must differ', 'err'); return; }
1899
+ if (!authReady(payload, 'old') || !authReady(payload, 'new')) {
1900
+ tip(anchor, 'Credentials missing for a host (password or key)', 'err');
1901
+ return;
1902
+ }
1903
+ }
1904
+ // the dashboard host name is not typed here any more: it belongs to the Manage
1905
+ // tab (Deploy monitor), which asks for it in its own modal
1906
+ openConfirm(meta, payload);
1907
+ }
1908
+
1909
+ function requestRun(phase, anchor) {
1910
+ if (phase === 'check') { runCheck(anchor); return; }
1911
+ var meta = phaseMeta(phase);
1912
+ if (!meta) return;
1913
+ var payload = buildPayload(phase);
1914
+ if (state.program === 'manage') {
1915
+ // single host: only the node address + its credentials exist
1916
+ if (!payload.newIp) { tip(anchor, 'Fill the node host', 'err'); return; }
1917
+ if (!authReady(payload, 'new')) { tip(anchor, 'Type the node credentials', 'err'); return; }
1918
+ // bond/exit sign a transaction with the wallet of the voting validator
1919
+ if ((phase === 'bond' || phase === 'exit') && meta.risk === 'critical' && !checkOkFor('manage')) {
1920
+ tip(anchor, 'Press Check first - the node must be the active validator', 'err');
1921
+ return;
1922
+ }
1923
+ // the update actions rebuild and restart the node: they need the update half
1924
+ if ((phase === 'upgrade' || phase === 'manual') && !checkOkFor('upgrade')) {
1925
+ tip(anchor, 'Press Check first - the node must be reachable and updatable', 'err');
1926
+ return;
1927
+ }
1928
+ if (phase === 'upgrade') {
1929
+ var testBox = panelId('m-test-only');
1930
+ if (testBox && testBox.checked) { payload.testOnly = true; }
1931
+ }
1932
+ if (meta.risk === 'critical') { openConfirm(meta, payload); return; }
1933
+ postRun(payload, meta, anchor);
1934
+ return;
1935
+ }
1936
+ if (!payload.oldIp) { tip(anchor, 'Fill the source host', 'err'); return; }
1937
+ if (phase !== 'backup' && !payload.newIp) { tip(anchor, 'Fill the target host', 'err'); return; }
1938
+ if (phase === 'rollback' && !checkOk()) {
1939
+ tip(anchor, 'Press Check first - rollback is destructive', 'err');
1940
+ return;
1941
+ }
1942
+ if (meta.confirm) { openConfirm(meta, payload); return; }
1943
+ postRun(payload, meta, anchor);
1944
+ }
1945
+
1946
+ function postRun(payload, meta, anchor) {
1947
+ postJSON('/api/run', payload).then(function (body) {
1948
+ if (anchor) tip(anchor, 'Started ' + meta.label);
1949
+ var result = $('dash-result');
1950
+ if (result) { result.hidden = true; result.textContent = ''; }
1951
+ // this page owns the run it just started: even a run that finishes before
1952
+ // the next poll counts as watched (see finishedNodeRun)
1953
+ if (body.state && typeof body.state.startedAt === 'number') {
1954
+ state.runWatch = body.state.startedAt;
1955
+ }
1956
+ renderRunner(body.state);
1957
+ // Resume the stream right BEFORE the run's first line (`lastSeq` already
1958
+ // contains the "phase started" marker). Resetting to 0 replayed the whole
1959
+ // buffer, which is why the first lines used to appear twice.
1960
+ var started = body.state && typeof body.state.lastSeq === 'number' ? body.state.lastSeq : 0;
1961
+ state.seq = Math.max(0, started - 1);
1962
+ clearLog();
1963
+ connectLog();
1964
+ // the operator just confirmed a popup or pressed a button: show the run
1965
+ scrollToLogs();
1966
+ }).catch(function (err) {
1967
+ if (anchor) tip(anchor, err.message, 'err');
1968
+ var note = $('action-note');
1969
+ if (note) note.textContent = 'ERROR: ' + err.message;
1970
+ });
1971
+ }
1972
+
1973
+ function cancelRun(anchor) {
1974
+ postJSON('/api/cancel', {}).then(function () {
1975
+ if (anchor) tip(anchor, 'Cancel requested', 'warn');
1976
+ }).catch(function (err) {
1977
+ if (anchor) tip(anchor, err.message, 'err');
1978
+ });
1979
+ }
1980
+
1981
+
1982
+ /* - live log (SSE + polling fallback) - */
1983
+ var LEVEL_LABEL = { info: 'INFO', ok: 'OK', warn: 'WARN', error: 'ERR' };
1984
+ var LEVEL_CLASS = { warn: 'v2-log__row--warn', error: 'v2-log__row--error' };
1985
+
1986
+ /* After a submit - a button, the confirm modal, the enrollment/domain popup -
1987
+ the operator wants to watch the run: bring the Live log into view. */
1988
+ function scrollToLogs() {
1989
+ var section = $('log');
1990
+ if (!section || typeof section.scrollIntoView !== 'function') return;
1991
+ try { section.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
1992
+ catch (err) { section.scrollIntoView(); }
1993
+ }
1994
+
1995
+ /* stream health: a dropped SSE connection falls back to polling the buffer */
1996
+ var sseFailures = 0;
1997
+ var logPollTimer = null;
1998
+
1999
+ function logVisible(entry) {
2000
+ if (state.logLevel !== 'all' && entry.level !== state.logLevel) return false;
2001
+ if (state.hostFilter !== 'all' && (entry.host || '') !== state.hostFilter) return false;
2002
+ if (state.logQuery) {
2003
+ var hay = (entry.line || '').toLowerCase();
2004
+ if (hay.indexOf(state.logQuery) === -1) return false;
2005
+ }
2006
+ return true;
2007
+ }
2008
+
2009
+ // Host tint. The source and the target must NEVER share a colour, so they are
2010
+ // not hashed: whichever side the address sits on decides the tint, and it is
2011
+ // labelled SRC/DST so the reading does not depend on colour at all.
2012
+ // Both hues are deliberately NOT red (--v2-tel-out was rose, and a working step
2013
+ // in red reads like an error - 2026-11-04): emerald for SRC, sky for DST.
2014
+ var ROLE_COLOR = { src: '--mc-role-src', dst: '--mc-role-dst' };
2015
+ var ROLE_TAG = { src: 'SRC', dst: 'DST' };
2016
+ var OTHER_COLORS = ['--v2-brand', '--v2-status-warning'];
2017
+
2018
+ function hostRole(ip) {
2019
+ if (!ip) return null;
2020
+ var runner = state.runner || {};
2021
+ if (state.program === 'migrate') {
2022
+ var from = runner.oldIp || val('inp-old');
2023
+ var to = runner.newIp || val('inp-new');
2024
+ if (from && ip === from) return 'src';
2025
+ if (to && ip === to) return 'dst';
2026
+ return null;
2027
+ }
2028
+ // deploy/manage have a single host: labelling it "SRC" was wrong (and made
2029
+ // the migrate log look like it belonged to the deploy run)
2030
+ var only = (runner.program === state.program && runner.newIp) ? runner.newIp : val('inp-new');
2031
+ return only && ip === only ? 'dst' : null;
2032
+ }
2033
+
2034
+ function hostColor(ip) {
2035
+ if (!ip) return null;
2036
+ var role = hostRole(ip);
2037
+ if (role) return ROLE_COLOR[role];
2038
+ var sum = 0;
2039
+ for (var i = 0; i < ip.length; i++) sum = (sum + ip.charCodeAt(i) * (i + 7)) % 9973;
2040
+ return OTHER_COLORS[sum % OTHER_COLORS.length];
2041
+ }
2042
+
2043
+ /* rows that are progress noise rather than news: heartbeats, speed lines,
2044
+ the epoch poll while the set fold re-admits the validator. They stay in the
2045
+ log but are muted, so a silent-but-busy step cannot look like a hang. */
2046
+ var MUTED_RE = /(^\s*\.\.\. still running|^\s*\.\.\.|still running|quiet for|^no output|^\s*sleeping \d+s|^\s*epoch advanced|^\s*epoch=|: \d+% complete|^\s*verifying \d+)/;
2047
+
2048
+ /* Lines the operator must not miss in a 1500-row log: the manage token, the
2049
+ published URL/domain, the wallet address and a completed milestone. They get a
2050
+ brand-tinted row (never red - red is the error colour). */
2051
+ var KEY_RE = /(manage token|wallet address|\b(URL|API|UI)\s*:|https?:\/\/[a-z0-9][a-z0-9.-]*\.[a-z]{2,}|=== DONE:|---> DONE:|=== .*\b(COMPLETE|DONE)\b|voting = enabled|\bbond [0-9.]+ OCT)/i;
2052
+
2053
+ function rowClass(entry) {
2054
+ var cls = 'v2-log__row';
2055
+ if (entry.level === 'warn' || entry.level === 'error') cls += ' ' + LEVEL_CLASS[entry.level];
2056
+ else if (entry.level === 'ok') cls += ' v2-log__row--ok';
2057
+ var line = entry.line || '';
2058
+ if (/^--> /.test(line)) cls += ' mc-row--step';
2059
+ else if (MUTED_RE.test(line) && entry.level === 'info') cls += ' v2-log__row--muted';
2060
+ if (KEY_RE.test(line)) cls += ' v2-log__row--key';
2061
+ return cls;
2062
+ }
2063
+
2064
+ function logRow(entry) {
2065
+ var row = document.createElement('div');
2066
+ row.className = rowClass(entry);
2067
+ row.dataset.level = entry.level || 'info';
2068
+ row.dataset.host = entry.host || '';
2069
+ row.dataset.text = (entry.line || '').toLowerCase();
2070
+ if (KEY_RE.test(entry.line || '')) row.dataset.key = '1';
2071
+ if (!logVisible(entry)) row.hidden = true;
2072
+
2073
+ var ts = document.createElement('span');
2074
+ ts.className = 'v2-log__ts';
2075
+ var d = new Date((entry.ts || 0) * 1000);
2076
+ ts.textContent = isNaN(d.getTime()) ? '--:--:--' : d.toLocaleTimeString('en-GB');
2077
+
2078
+ var level = document.createElement('span');
2079
+ level.className = 'v2-log__level' + (entry.level === 'warn' ? ' v2-log__level--warn'
2080
+ : entry.level === 'error' ? ' v2-log__level--error' : '');
2081
+ level.textContent = LEVEL_LABEL[entry.level] || 'INFO';
2082
+
2083
+ var host = document.createElement('span');
2084
+ host.className = 'v2-log__comp mc-hostchip';
2085
+ if (entry.host) {
2086
+ var role = hostRole(entry.host);
2087
+ var color = hostColor(entry.host);
2088
+ if (color) host.style.color = 'var(' + color + ')';
2089
+ if (role) {
2090
+ var tag = document.createElement('span');
2091
+ tag.className = 'mc-hostchip__role mc-hostchip__role--' + role;
2092
+ tag.textContent = ROLE_TAG[role];
2093
+ tag.title = role === 'src' ? 'source VPS' : 'target VPS';
2094
+ host.appendChild(tag);
2095
+ }
2096
+ var ip = document.createElement('span');
2097
+ ip.className = 'mc-hostchip__ip';
2098
+ ip.textContent = entry.host;
2099
+ host.appendChild(ip);
2100
+ host.title = (role === 'src' ? 'source VPS ' : role === 'dst' ? 'target VPS ' : '')
2101
+ + entry.host + ' - step running there';
2102
+ }
2103
+
2104
+ var msg = document.createElement('span');
2105
+ msg.className = 'v2-log__msg';
2106
+ msg.textContent = entry.line || '';
2107
+ row.__msg = msg; // the duplicate counter rewrites this node
2108
+
2109
+ row.appendChild(ts);
2110
+ row.appendChild(level);
2111
+ row.appendChild(host);
2112
+ row.appendChild(msg);
2113
+ rememberRow(entry, row);
2114
+ return row;
2115
+ }
2116
+
2117
+ /* - collapse a line that repeats identically (a retry loop, a re-printed
2118
+ warning): one row with a counter beats 40 copies ---------------------- */
2119
+ var lastRow = null;
2120
+ var lastKey = '';
2121
+ var lastBase = '';
2122
+ var repeatCount = 1;
2123
+
2124
+ function dedupeKey(entry) {
2125
+ return (entry.level || '') + '|' + (entry.host || '') + '|' + (entry.line || '');
2126
+ }
2127
+
2128
+ function rememberRow(entry, row) {
2129
+ lastRow = row;
2130
+ lastKey = dedupeKey(entry);
2131
+ lastBase = entry.line || '';
2132
+ row.dataset.repeat = '1';
2133
+ }
2134
+
2135
+ /* bump the counter on an existing row instead of printing the same line again */
2136
+ function bumpRow(row, entry) {
2137
+ var n = Number((row && row.dataset.repeat) || 1) + 1;
2138
+ row.dataset.repeat = String(n);
2139
+ var msg = row.__msg || row.querySelector('.v2-log__msg');
2140
+ if (msg) msg.textContent = (entry.line || lastBase) + ' (x' + n + ')';
2141
+ return true;
2142
+ }
2143
+
2144
+ function bumpRepeat(entry) {
2145
+ if (!lastRow || dedupeKey(entry) !== lastKey) return false;
2146
+ return bumpRow(lastRow, entry);
2147
+ }
2148
+
2149
+ function atBottom(view) {
2150
+ return view.scrollHeight - view.scrollTop - view.clientHeight < 60;
2151
+ }
2152
+
2153
+ /* Render in batches: apt/npm can emit hundreds of lines per second and one
2154
+ DOM write per line freezes the tab. Entries are queued and flushed once per
2155
+ animation frame (or 120 ms when the tab is hidden). */
2156
+ var pendingRows = [];
2157
+ var flushTimer = null;
2158
+
2159
+ function appendEntry(entry) {
2160
+ if (!entry || typeof entry.seq !== 'number') return;
2161
+ if (entry.seq <= state.seq) return; // reconnect replay guard
2162
+ state.seq = entry.seq;
2163
+ var owner = programOf(entry);
2164
+ var store = logStore(owner);
2165
+ store.push(entry);
2166
+ if (store.length > MAX_ROWS) store.splice(0, store.length - MAX_ROWS);
2167
+ if (entry.level === 'error') {
2168
+ state.errors[owner] = (state.errors[owner] || 0) + 1;
2169
+ setErrorBadge(owner, state.errors[owner]);
2170
+ }
2171
+ // a line of another program is kept for its tab, but never painted here
2172
+ if (owner !== state.program) return;
2173
+ paintEntry(entry);
2174
+ }
2175
+
2176
+ /* the program a log line belongs to (older servers send no program: the line
2177
+ then belongs to whatever is on screen) */
2178
+ function programOf(entry) {
2179
+ var id = entry && entry.program;
2180
+ return PROGRAMS.indexOf(id) >= 0 ? id : state.program;
2181
+ }
2182
+
2183
+ function logStore(program) {
2184
+ if (!state.logs[program]) { state.logs[program] = []; }
2185
+ return state.logs[program];
2186
+ }
2187
+
2188
+ function setErrorBadge(program, count) {
2189
+ if (program !== state.program) return;
2190
+ var node = $('nav-log-badge');
2191
+ if (!node) return;
2192
+ node.hidden = !count;
2193
+ node.textContent = String(count || 0);
2194
+ }
2195
+
2196
+ function paintEntry(entry) {
2197
+ if (state.logPaused) return;
2198
+ // the line right above is identical: count it instead of printing it again
2199
+ if (pendingRows.length === 0 && bumpRepeat(entry)) return;
2200
+
2201
+ pendingRows.push(entry);
2202
+ if (pendingRows.length > 4000) pendingRows.splice(0, pendingRows.length - 4000);
2203
+ if (entry.host) noteHost(entry.host);
2204
+
2205
+ if (flushTimer === null) {
2206
+ var schedule = window.requestAnimationFrame || function (fn) { return setTimeout(fn, 120); };
2207
+ flushTimer = schedule(flushRows);
2208
+ }
2209
+ }
2210
+
2211
+ function flushRows() {
2212
+ flushTimer = null;
2213
+ var view = $('log-view');
2214
+ if (!view || pendingRows.length === 0) return;
2215
+
2216
+ var batch = pendingRows;
2217
+ pendingRows = [];
2218
+ var stick = atBottom(view);
2219
+
2220
+ var frag = document.createDocumentFragment();
2221
+ var prev = null;
2222
+ batch.forEach(function (entry) {
2223
+ if (prev && dedupeKey(entry) === dedupeKey(prev.entry)) {
2224
+ bumpRow(prev.row, entry); // same line twice in one frame
2225
+ return;
2226
+ }
2227
+ var row = logRow(entry);
2228
+ frag.appendChild(row);
2229
+ prev = { entry: entry, row: row };
2230
+ });
2231
+ var empty = $('log-empty');
2232
+ if (empty) empty.hidden = true;
2233
+ view.appendChild(frag);
2234
+
2235
+ // trim in one pass so the DOM never grows unbounded
2236
+ var rows = view.querySelectorAll('.v2-log__row');
2237
+ if (rows.length > MAX_ROWS) {
2238
+ var drop = rows.length - MAX_ROWS;
2239
+ for (var i = 0; i < drop; i++) view.removeChild(rows[i]);
2240
+ }
2241
+ state.rows = view.querySelectorAll('.v2-log__row').length;
2242
+
2243
+ if (stick) view.scrollTop = view.scrollHeight;
2244
+ setText('log-meta', 'streaming - ' + state.rows + ' lines' +
2245
+ (pendingRows.length ? ' (+' + pendingRows.length + ')' : ''));
2246
+ }
2247
+
2248
+ function connectLog() {
2249
+ if (state.es) { state.es.close(); state.es = null; }
2250
+ var es = new EventSource('/api/log/stream?since=' + state.seq);
2251
+ state.es = es;
2252
+ es.onmessage = function (event) {
2253
+ try { appendEntry(JSON.parse(event.data)); } catch (err) { /* ignore malformed frame */ }
2254
+ };
2255
+ es.onerror = function () {
2256
+ sseFailures += 1;
2257
+ setText('log-meta', 'reconnecting - ' + state.rows + ' lines');
2258
+ // EventSource retries on its own, but a flaky link can keep it down for
2259
+ // minutes. Poll the buffer as a fallback so the operator still sees lines.
2260
+ if (sseFailures >= 2) startLogPolling();
2261
+ };
2262
+ es.onopen = function () {
2263
+ sseFailures = 0;
2264
+ stopLogPolling();
2265
+ setText('log-meta', 'streaming - ' + state.rows + ' lines');
2266
+ };
2267
+ }
2268
+
2269
+ function startLogPolling() {
2270
+ if (logPollTimer) return;
2271
+ setText('log-meta', 'polling - ' + state.rows + ' lines');
2272
+ logPollTimer = setInterval(function () {
2273
+ getJSON('/api/log?since=' + state.seq).then(function (body) {
2274
+ var lines = body.lines || [];
2275
+ lines.forEach(appendEntry);
2276
+ setText('log-meta', 'polling - ' + state.rows + ' lines');
2277
+ }).catch(function () { /* stay quiet, the next tick retries */ });
2278
+ }, 3000);
2279
+ }
2280
+
2281
+ function stopLogPolling() {
2282
+ if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
2283
+ }
2284
+
2285
+ function clearLog() {
2286
+ var view = $('log-view');
2287
+ if (!view) return;
2288
+ view.textContent = '';
2289
+ lastRow = null;
2290
+ lastKey = '';
2291
+ lastBase = '';
2292
+ var empty = document.createElement('div');
2293
+ empty.className = 'v2-log__empty';
2294
+ empty.id = 'log-empty';
2295
+ empty.textContent = 'waiting for output...';
2296
+ view.appendChild(empty);
2297
+ state.rows = 0;
2298
+ setText('log-meta', 'streaming - 0 lines');
2299
+ }
2300
+
2301
+ /* Show one program's output: its own rows, its own host chips, its own error
2302
+ count. Called on every program switch, so the tabs never borrow each other's
2303
+ history (the backend tags every line with the program that produced it). */
2304
+ function renderLogFor(program) {
2305
+ clearLog();
2306
+ pendingRows = [];
2307
+ resetHosts();
2308
+ var store = logStore(program);
2309
+ var hosts = [];
2310
+ var errors = 0;
2311
+ store.forEach(function (entry) {
2312
+ if (entry.host && hosts.indexOf(entry.host) < 0) { hosts.push(entry.host); }
2313
+ if (entry.level === 'error') { errors += 1; }
2314
+ });
2315
+ state.hosts = hosts;
2316
+ state.errors[program] = errors;
2317
+ pendingRows = store.slice();
2318
+ if (pendingRows.length) {
2319
+ flushRows();
2320
+ } else {
2321
+ var empty = $('log-empty');
2322
+ if (empty) empty.hidden = false;
2323
+ }
2324
+ applyLogFilter();
2325
+ renderHostFilter();
2326
+ renderLegend();
2327
+ setErrorBadge(program, errors);
2328
+ }
2329
+
2330
+ function applyLogFilter() {
2331
+ var view = $('log-view');
2332
+ if (!view) return;
2333
+ var rows = view.querySelectorAll('.v2-log__row');
2334
+ for (var i = 0; i < rows.length; i++) {
2335
+ var row = rows[i];
2336
+ var show = (state.logLevel === 'all' || row.dataset.level === state.logLevel) &&
2337
+ (state.hostFilter === 'all' || row.dataset.host === state.hostFilter) &&
2338
+ (!state.logQuery || (row.dataset.text || '').indexOf(state.logQuery) !== -1);
2339
+ row.hidden = !show;
2340
+ }
2341
+ }
2342
+
2343
+ /* -- host chips: which VPS a line came from ------------------------ */
2344
+ function noteHost(ip) {
2345
+ if (!ip || state.hosts.indexOf(ip) >= 0) return;
2346
+ state.hosts.push(ip);
2347
+ renderHostFilter();
2348
+ renderLegend();
2349
+ }
2350
+
2351
+ function resetHosts() {
2352
+ state.hosts = [];
2353
+ state.hostFilter = 'all';
2354
+ renderHostFilter();
2355
+ renderLegend();
2356
+ }
2357
+
2358
+ function renderHostFilter() {
2359
+ var seg = $('seg-host');
2360
+ if (!seg) return;
2361
+ seg.textContent = '';
2362
+ var add = function (value, label) {
2363
+ var btn = document.createElement('button');
2364
+ btn.type = 'button';
2365
+ btn.className = 'v2-seg__btn' + (state.hostFilter === value ? ' v2-seg__btn--active' : '');
2366
+ btn.setAttribute('data-host-filter', value);
2367
+ btn.textContent = label;
2368
+ seg.appendChild(btn);
2369
+ };
2370
+ add('all', 'All');
2371
+ state.hosts.forEach(function (ip) {
2372
+ var role = hostRole(ip);
2373
+ add(ip, (role ? ROLE_TAG[role] + ' ' : '') + ip);
2374
+ });
2375
+ seg.hidden = state.hosts.length === 0;
2376
+ }
2377
+
2378
+ function renderLegend() {
2379
+ var box = $('log-legend');
2380
+ if (!box) return;
2381
+ box.textContent = '';
2382
+ state.hosts.forEach(function (ip) {
2383
+ var role = hostRole(ip);
2384
+ var item = document.createElement('span');
2385
+ item.className = 'mc-legend__item';
2386
+ item.title = role === 'src' ? 'source VPS (this box keeps voting until the switch)'
2387
+ : role === 'dst' ? 'target VPS (where the validator ends up)' : 'host seen in the log';
2388
+ var dot = document.createElement('span');
2389
+ dot.className = 'mc-legend__dot';
2390
+ dot.style.background = 'var(' + hostColor(ip) + ')';
2391
+ var label = document.createElement('span');
2392
+ label.className = 'v2-mono';
2393
+ label.textContent = (role ? ROLE_TAG[role] + ' ' : '') + ip;
2394
+ item.appendChild(dot);
2395
+ item.appendChild(label);
2396
+ box.appendChild(item);
2397
+ });
2398
+ box.hidden = state.hosts.length === 0;
2399
+ }
2400
+
2401
+ function downloadLog() {
2402
+ var view = $('log-view');
2403
+ if (!view) return;
2404
+ var text = Array.prototype.map.call(view.querySelectorAll('.v2-log__row'), function (row) {
2405
+ return row.textContent;
2406
+ }).join('\n');
2407
+ var blob = new Blob([text], { type: 'text/plain' });
2408
+ var a = document.createElement('a');
2409
+ a.href = URL.createObjectURL(blob);
2410
+ a.download = 'ovtools-' + new Date().toISOString().replace(/[:.]/g, '-') + '.log';
2411
+ document.body.appendChild(a);
2412
+ a.click();
2413
+ document.body.removeChild(a);
2414
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
2415
+ }
2416
+
2417
+ /* - wiring - */
2418
+ function wire() {
2419
+ $('btn-refresh').addEventListener('click', function (ev) {
2420
+ pollState();
2421
+ pollNodes();
2422
+ tip(ev.currentTarget, 'Refreshed');
2423
+ });
2424
+
2425
+ // one console, two dashboards: the tabs swap the whole panel
2426
+ var segPrograms = $('seg-programs');
2427
+ if (segPrograms) {
2428
+ segPrograms.addEventListener('click', function (ev) {
2429
+ var node = ev.target;
2430
+ var program = null;
2431
+ while (node && node !== segPrograms) {
2432
+ if (node.getAttribute && node.getAttribute('data-program')) {
2433
+ program = node.getAttribute('data-program');
2434
+ break;
2435
+ }
2436
+ node = node.parentElement || null;
2437
+ }
2438
+ if (!program || program === state.program) return;
2439
+ setProgram(program);
2440
+ var label = program === 'deploy' ? 'Deploy fresh node'
2441
+ : (program === 'manage' ? 'Manage active node' : 'Migrate node');
2442
+ tip(node, label);
2443
+ });
2444
+ }
2445
+
2446
+ // project settings: the row below it is a pointer to the Manage active node
2447
+ // tab (monitoring, bond, exit) - there is no dashboard switch any more
2448
+ var gotoManage = panelId('btn-goto-manage');
2449
+ if (gotoManage) gotoManage.addEventListener('click', function (ev) {
2450
+ var ip = gateHost();
2451
+ setProgram('manage');
2452
+ if (ip) { var m = $('inp-new'); if (m) { m.value = ip; } }
2453
+ tip(ev.currentTarget, 'Manage active node');
2454
+ });
2455
+
2456
+ // the manage dashboard has its own buttons (bond / exit / monitor / status)
2457
+ var mBond = panelId('m-btn-bond');
2458
+ if (mBond) mBond.addEventListener('click', function (ev) { startManage('bond', ev.currentTarget); });
2459
+ var mExit = panelId('m-btn-exit');
2460
+ if (mExit) mExit.addEventListener('click', function (ev) { startManage('exit', ev.currentTarget); });
2461
+ var mDash = panelId('m-btn-dashboard');
2462
+ if (mDash) mDash.addEventListener('click', function (ev) { openMonitorModal(); });
2463
+ var mStatus = panelId('m-btn-status');
2464
+ if (mStatus) mStatus.addEventListener('click', function (ev) { requestRun('status', ev.currentTarget); });
2465
+ var mCheck = panelId('m-btn-check');
2466
+ if (mCheck) mCheck.addEventListener('click', function (ev) { runCheck(ev.currentTarget); });
2467
+ var mIp = panelId('m-inp-ip');
2468
+ if (mIp) mIp.addEventListener('input', function () { invalidateCheck(); });
2469
+
2470
+ // the update actions of this tab: Update node / Manual update / Prune
2471
+ var mUpgrade = panelId('m-btn-upgrade');
2472
+ if (mUpgrade) mUpgrade.addEventListener('click', function (ev) { requestRun('upgrade', ev.currentTarget); });
2473
+ var mManual = panelId('m-btn-manual');
2474
+ if (mManual) mManual.addEventListener('click', function (ev) { requestRun('manual', ev.currentTarget); });
2475
+ var mStorage = panelId('m-btn-storage');
2476
+ if (mStorage) mStorage.addEventListener('click', function (ev) { requestRun('storage', ev.currentTarget); });
2477
+ // "why is it not voting?" + the bounded repair path (2026-11-06)
2478
+ var mDiagnose = panelId('m-btn-diagnose');
2479
+ if (mDiagnose) mDiagnose.addEventListener('click', function (ev) { requestRun('diagnose', ev.currentTarget); });
2480
+ var mRecover = panelId('m-btn-recover');
2481
+ if (mRecover) mRecover.addEventListener('click', function (ev) { requestRun('recover', ev.currentTarget); });
2482
+
2483
+ // --- the manage modals (bond amount, exit confirmation, monitor options) --
2484
+ var bondOk = panelId('bond-ok');
2485
+ if (bondOk) bondOk.addEventListener('click', submitBond);
2486
+ var bondCancel = panelId('bond-cancel');
2487
+ if (bondCancel) bondCancel.addEventListener('click', closeBond);
2488
+ var bondAmount = panelId('bond-amount');
2489
+ if (bondAmount) {
2490
+ bondAmount.addEventListener('keydown', function (ev) {
2491
+ if (ev.key === 'Enter') { ev.preventDefault(); submitBond(); }
2492
+ if (ev.key === 'Escape') closeBond();
2493
+ });
2494
+ }
2495
+ var exitOk = panelId('exit-ok');
2496
+ if (exitOk) exitOk.addEventListener('click', submitExit);
2497
+ var exitCancel = panelId('exit-cancel');
2498
+ if (exitCancel) exitCancel.addEventListener('click', closeExit);
2499
+ var monitorOk = panelId('monitor-ok');
2500
+ if (monitorOk) monitorOk.addEventListener('click', submitMonitor);
2501
+ var monitorCancel = panelId('monitor-cancel');
2502
+ if (monitorCancel) monitorCancel.addEventListener('click', closeMonitor);
2503
+ var monitorPick = panelId('monitor-certdir-pick');
2504
+ if (monitorPick) monitorPick.addEventListener('click', pickCertDir);
2505
+ // editing a field that failed validation clears its error state
2506
+ [['monitor-domain', 'monitor-note'], ['monitor-certdir', 'monitor-note']]
2507
+ .forEach(function (pairIds) {
2508
+ var field = panelId(pairIds[0]);
2509
+ if (field) field.addEventListener('input', function () {
2510
+ clearFieldError(pairIds[0], pairIds[1]);
2511
+ });
2512
+ });
2513
+ ['monitor-ssl-cloudflare', 'monitor-ssl-certbot'].forEach(function (id) {
2514
+ var radio = panelId(id);
2515
+ if (radio) radio.addEventListener('change', syncMonitorSsl);
2516
+ });
2517
+
2518
+ // the deploy dashboard has its own buttons
2519
+ var dStart = panelId('d-btn-start');
2520
+ if (dStart) dStart.addEventListener('click', function (ev) { startMigration(ev.currentTarget); });
2521
+ var dStatus = panelId('d-btn-status');
2522
+ if (dStatus) dStatus.addEventListener('click', function (ev) { requestRun('status', ev.currentTarget); });
2523
+ var dCheck = panelId('d-btn-check');
2524
+ if (dCheck) dCheck.addEventListener('click', function (ev) { runCheck(ev.currentTarget); });
2525
+ var dIp = panelId('d-inp-ip');
2526
+ if (dIp) dIp.addEventListener('input', function () { invalidateCheck(); rememberHosts(); });
2527
+
2528
+ // auth rows are bound when a dashboard is rendered (bindAuthRows): the
2529
+ // program switcher swaps whole panels, so binding once at boot would only
2530
+ // cover the dashboard that happened to be open then.
2531
+
2532
+ wirePassToggles();
2533
+ bindFilePickers();
2534
+
2535
+ $('btn-stop').addEventListener('click', function (ev) {
2536
+ cancelRun(ev.currentTarget);
2537
+ });
2538
+
2539
+ $('btn-swap').addEventListener('click', function () {
2540
+ var src = val('inp-old'), dst = val('inp-new');
2541
+ if (!src || !dst) return;
2542
+ $('inp-old').value = dst;
2543
+ $('inp-new').value = src;
2544
+ invalidateCheck();
2545
+ pollNodes();
2546
+ safetyChecks();
2547
+ });
2548
+
2549
+ $('btn-check').addEventListener('click', function (ev) { runCheck(ev.currentTarget); });
2550
+
2551
+ // "target uses the same password" mirrors the source field into the target
2552
+ var sameBox = $('chk-same-pass');
2553
+ if (sameBox) {
2554
+ sameBox.addEventListener('change', function () {
2555
+ syncPasswordFields();
2556
+ syncAllAuthFields();
2557
+ invalidateCheck();
2558
+ });
2559
+ }
2560
+
2561
+ syncPasswordFields();
2562
+ syncAllAuthFields();
2563
+
2564
+ ['inp-old', 'inp-new'].forEach(function (id) {
2565
+ var input = $(id);
2566
+ if (!input) return;
2567
+ input.addEventListener('input', function () { renderRoute(); invalidateCheck(); });
2568
+ input.addEventListener('change', function () {
2569
+ rememberHosts();
2570
+ pollNodes();
2571
+ safetyChecks();
2572
+ });
2573
+ input.addEventListener('keydown', function (ev) {
2574
+ if (ev.key === 'Enter') { ev.preventDefault(); runCheck(input); }
2575
+ });
2576
+ });
2577
+
2578
+ // the monitoring dashboard (host name + certificate) is asked in the Manage
2579
+ // tab's own modal (Deploy monitor) - there is no domain popup here any more
2580
+ $('btn-run-all').addEventListener('click', function (ev) { startMigration(ev.currentTarget); });
2581
+ $('btn-run-rollback').addEventListener('click', function (ev) { requestRun('rollback', ev.currentTarget); });
2582
+ $('btn-status').addEventListener('click', function (ev) { requestRun('status', ev.currentTarget); });
2583
+
2584
+ $('confirm-cancel').addEventListener('click', closeConfirm);
2585
+ $('confirm-ok').addEventListener('click', submitConfirm);
2586
+ var settingsSave = $('btn-settings-save');
2587
+ if (settingsSave) settingsSave.addEventListener('click', function (ev) { saveSettings(ev.currentTarget); });
2588
+ var enrollCancel = $('enroll-cancel');
2589
+ if (enrollCancel) enrollCancel.addEventListener('click', closeEnroll);
2590
+ var enrollOk = $('enroll-ok');
2591
+ if (enrollOk) enrollOk.addEventListener('click', submitEnroll);
2592
+ var enrollFunded = $('enroll-funded');
2593
+ if (enrollFunded) {
2594
+ enrollFunded.addEventListener('change', function () {
2595
+ var ok = $('enroll-ok');
2596
+ if (ok) ok.disabled = !enrollFunded.checked;
2597
+ if (enrollFunded.checked) setText('enroll-note', '');
2598
+ });
2599
+ }
2600
+ // the activation popup (the console watched the chain admit the validator)
2601
+ var activationOk = $('activation-ok');
2602
+ if (activationOk) activationOk.addEventListener('click', closeActivation);
2603
+ var activationModal = $('activation-modal');
2604
+ if (activationModal) {
2605
+ activationModal.addEventListener('keydown', function (ev) {
2606
+ if (ev.key === 'Escape') closeActivation();
2607
+ });
2608
+ }
2609
+ // the exit popup (unlock epoch + the moment the stake is back)
2610
+ var exitOk = $('exit-state-ok');
2611
+ if (exitOk) exitOk.addEventListener('click', closeExitReport);
2612
+ var exitModal = $('exit-state-modal');
2613
+ if (exitModal) {
2614
+ exitModal.addEventListener('keydown', function (ev) {
2615
+ if (ev.key === 'Escape') closeExitReport();
2616
+ });
2617
+ }
2618
+ var confirmOk = $('confirm-ok');
2619
+ if (confirmOk) confirmOk.addEventListener('keydown', function (ev) {
2620
+ if (ev.key === 'Enter') { ev.preventDefault(); submitConfirm(); }
2621
+ if (ev.key === 'Escape') closeConfirm();
2622
+ });
2623
+
2624
+ var seg = document.querySelector('.mc-logbar .v2-seg');
2625
+ if (seg) {
2626
+ seg.addEventListener('click', function (ev) {
2627
+ var btn = ev.target.closest ? ev.target.closest('[data-level]') : null;
2628
+ if (!btn) return;
2629
+ Array.prototype.forEach.call(seg.querySelectorAll('.v2-seg__btn'), function (b) {
2630
+ b.classList.toggle('v2-seg__btn--active', b === btn);
2631
+ });
2632
+ state.logLevel = btn.getAttribute('data-level');
2633
+ applyLogFilter();
2634
+ });
2635
+ }
2636
+
2637
+ var hostSeg = $('seg-host');
2638
+ if (hostSeg) {
2639
+ hostSeg.addEventListener('click', function (ev) {
2640
+ var btn = ev.target.closest ? ev.target.closest('[data-host-filter]') : null;
2641
+ if (!btn) return;
2642
+ state.hostFilter = btn.getAttribute('data-host-filter');
2643
+ renderHostFilter();
2644
+ applyLogFilter();
2645
+ });
2646
+ }
2647
+
2648
+ $('inp-log-filter').addEventListener('input', function () {
2649
+ state.logQuery = val('inp-log-filter').toLowerCase();
2650
+ applyLogFilter();
2651
+ });
2652
+
2653
+ $('btn-log-pause').addEventListener('click', function (ev) {
2654
+ state.logPaused = !state.logPaused;
2655
+ ev.currentTarget.setAttribute('aria-pressed', String(state.logPaused));
2656
+ ev.currentTarget.textContent = state.logPaused ? 'resume' : 'pause';
2657
+ tip(ev.currentTarget, state.logPaused ? 'Log paused' : 'Log resumed', state.logPaused ? 'warn' : 'ok');
2658
+ });
2659
+
2660
+ $('btn-log-clear').addEventListener('click', clearLog);
2661
+ $('btn-log-download').addEventListener('click', downloadLog);
2662
+ }
2663
+
2664
+ function init() {
2665
+ wire();
2666
+ // project settings (swap / data volume / dashboard) - shared by both programs
2667
+ loadSettings();
2668
+ syncPasswordFields();
2669
+ syncAllAuthFields();
2670
+ restoreHosts();
2671
+ renderRoute();
2672
+ rememberHosts();
2673
+ applyGating();
2674
+ getJSON('/api/config').then(function (cfg) {
2675
+ state.cfg = cfg;
2676
+ state.program = restoreProgram(cfg);
2677
+ setText('hero-project', (cfg.projectRoot || '').replace(/^.*[\\/]/, '') || 'project');
2678
+ renderProgram();
2679
+
2680
+ var note = $('action-note');
2681
+ if (note && state.cfg && !state.cfg.dashboardPresent) {
2682
+ note.textContent = 'WARNING: dashboard assets are missing - the dashboard will not be deployed.';
2683
+ }
2684
+ applyGating();
2685
+
2686
+ pollState();
2687
+ pollNodes();
2688
+ connectLog();
2689
+ setInterval(pollState, POLL_STATE_MS);
2690
+ setInterval(pollNodes, POLL_NODES_MS);
2691
+ setInterval(function () {
2692
+ var s = state.runner;
2693
+ if (s && s.running && s.startedAt) {
2694
+ setText('hero-elapsed', fmtElapsed(Math.max(0, Math.round(Date.now() / 1000 - s.startedAt))));
2695
+ }
2696
+ }, 1000);
2697
+ }).catch(function (err) {
2698
+ var note = $('action-note');
2699
+ if (note) note.textContent = 'CONFIG ERROR: ' + err.message;
2700
+ setText('topbar-status-text', 'config error');
2701
+ badge('topbar-status', 'v2-badge--err');
2702
+ });
2703
+ }
2704
+
2705
+ if (document.readyState === 'loading') {
2706
+ document.addEventListener('DOMContentLoaded', init);
2707
+ } else {
2708
+ init();
2709
+ }
2710
+ })();
2711
+
2712
+