cluster.html 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta name="mobile-web-app-capable" content="yes">
  5. <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
  6. <meta charset="UTF-8">
  7. <title>Cluster Settings</title>
  8. <script src="../../script/jquery.min.js"></script>
  9. <script src="../../script/ao_module.js"></script>
  10. <script src="../../script/applocale.js"></script>
  11. <script src="../cluster/cluster.common.js"></script>
  12. <link rel="stylesheet" href="../cluster/cluster.css">
  13. </head>
  14. <body>
  15. <div id="cl-root">
  16. <div id="cl-header">
  17. <img src="../cluster/img/small_icon.png" alt="">
  18. <div style="flex:1">
  19. <h1 locale="cluster/settings/title">Cluster Settings</h1>
  20. <p locale="cluster/settings/tagline">Set up this node and how the cluster stores files and runs work</p>
  21. </div>
  22. <span id="cl-refresh"></span>
  23. <div class="cl-links">
  24. <a href="../cluster/clusterinfo.html" onclick="return CL.openPage('Cluster Info', this.href)" locale="cluster/link/info">Cluster Info</a>
  25. <a href="../cluster/jobs.html" onclick="return CL.openPage('Cluster Jobs', this.href)" locale="cluster/link/jobs">Cluster Jobs</a>
  26. </div>
  27. </div>
  28. <div class="cl-grid">
  29. <!-- This node -->
  30. <div>
  31. <div class="cl-card">
  32. <div class="cl-card-head">
  33. <span><span locale="cluster/thisnode">This node</span><span class="cl-sub cl-mono" id="cl-nodeid"></span></span>
  34. </div>
  35. <div class="cl-card-body">
  36. <div class="cl-field">
  37. <label locale="cluster/cfg/name">Node name</label>
  38. <input type="text" id="cfg-name" maxlength="64">
  39. </div>
  40. <div class="cl-field">
  41. <label locale="cluster/cfg/url">Advertised URL (how other nodes reach this one)</label>
  42. <div class="cl-row">
  43. <input type="text" id="cfg-url" placeholder="https://node-a.example.com">
  44. <button class="cl-btn cl-fixed" onclick="testUrl()"><span locale="cluster/cfg/test">Test</span></button>
  45. </div>
  46. <div class="cl-hint" locale="cluster/cfg/urlhint">Leave empty if this node has no public address. It will then keep a tunnel open to a reachable member, which also works through Cloudflare.</div>
  47. </div>
  48. <div class="cl-field" id="field-tunnel" style="display:none">
  49. <label locale="cluster/cfg/tunnel">Tunnel through</label>
  50. <select id="cfg-tunnelvia"><option value="" locale="cluster/auto">Automatic (any reachable member)</option></select>
  51. </div>
  52. <div class="cl-field">
  53. <label class="cl-check"><input type="checkbox" id="cfg-insecure"> <span locale="cluster/cfg/insecure">Accept self-signed certificates from other nodes (LAN only)</span></label>
  54. </div>
  55. <div class="cl-row">
  56. <div></div>
  57. <button class="cl-btn primary cl-fixed" onclick="saveConfig()"><span locale="cluster/save">Save</span></button>
  58. </div>
  59. <div class="cl-msg" id="msg-config"></div>
  60. </div>
  61. </div>
  62. </div>
  63. <!-- Cluster membership -->
  64. <div>
  65. <div class="cl-card" id="card-standalone" style="display:none">
  66. <div class="cl-card-head"><span locale="cluster/notmember">Not part of a cluster</span></div>
  67. <div class="cl-card-body">
  68. <div class="cl-field">
  69. <label locale="cluster/create">Create a new cluster</label>
  70. <div class="cl-row">
  71. <input type="text" id="create-name" placeholder="Home Lab" maxlength="64">
  72. <button class="cl-btn primary cl-fixed" onclick="createCluster()"><span locale="cluster/createbtn">Create</span></button>
  73. </div>
  74. <div class="cl-hint" locale="cluster/createhint">This node becomes the first member. Generate join tokens here afterwards to add more nodes.</div>
  75. </div>
  76. <div class="cl-sep"></div>
  77. <div class="cl-field">
  78. <label locale="cluster/join">Join an existing cluster</label>
  79. <textarea id="join-token" placeholder="aroz-join:..."></textarea>
  80. <div class="cl-hint" locale="cluster/joinhint">Paste a join token generated on a reachable member of the cluster.</div>
  81. </div>
  82. <div class="cl-row">
  83. <div></div>
  84. <button class="cl-btn primary cl-fixed" onclick="joinCluster()"><span locale="cluster/joinbtn">Join</span></button>
  85. </div>
  86. <div class="cl-msg" id="msg-cluster"></div>
  87. </div>
  88. </div>
  89. <div class="cl-card" id="card-member" style="display:none">
  90. <div class="cl-card-head">
  91. <span id="v-clustername">Cluster</span>
  92. <button class="cl-btn small danger" onclick="leaveCluster()"><span locale="cluster/leave">Leave cluster</span></button>
  93. </div>
  94. <div class="cl-card-body">
  95. <div class="cl-field">
  96. <label locale="cluster/invite">Add a node</label>
  97. <div class="cl-row">
  98. <select id="token-ttl" class="cl-fixed" style="flex:0 0 auto; width:auto">
  99. <option value="1" locale="cluster/ttl/1h">1 hour</option>
  100. <option value="24" selected locale="cluster/ttl/24h">24 hours</option>
  101. <option value="168" locale="cluster/ttl/7d">7 days</option>
  102. </select>
  103. <button class="cl-btn primary" onclick="newToken()" id="btn-token"><span locale="cluster/gentoken">Generate join token</span></button>
  104. </div>
  105. <div class="cl-hint" locale="cluster/tokenhint">Tokens can only be generated on a node with an advertised URL. Paste the token into the other node's Cluster Settings.</div>
  106. <div id="token-out" style="display:none">
  107. <div class="cl-token-box" id="token-text"></div>
  108. <div class="cl-row" style="margin-top:6px">
  109. <div class="cl-hint" id="token-exp"></div>
  110. <button class="cl-btn small cl-fixed" onclick="copyToken()"><span locale="cluster/copy">Copy</span></button>
  111. </div>
  112. </div>
  113. </div>
  114. <ul class="cl-inline-list" id="token-list"></ul>
  115. <div class="cl-sep"></div>
  116. <div class="cl-field">
  117. <label locale="cluster/id/title">Identity (single sign-on)</label>
  118. <div class="cl-row">
  119. <select id="id-origin"><option value="" locale="cluster/id/disabled">Disabled (each node checks its own accounts)</option></select>
  120. <button class="cl-btn cl-fixed" onclick="setIdentityOrigin()"><span locale="cluster/id/apply">Apply</span></button>
  121. <button class="cl-btn cl-fixed" id="btn-idsync" onclick="syncIdentity()"><span locale="cluster/id/sync">Sync now</span></button>
  122. </div>
  123. <div class="cl-hint" locale="cluster/id/hint">The identity origin verifies every login in the cluster. Accounts are copied to the other nodes so they still work when the origin is unreachable. Groups are matched by name, so create the same permission groups on every node.</div>
  124. <dl class="cl-kv" id="id-details" style="margin-top:10px"></dl>
  125. </div>
  126. <div class="cl-msg" id="msg-member"></div>
  127. </div>
  128. </div>
  129. </div>
  130. </div>
  131. <!-- Storage -->
  132. <div class="cl-card" id="card-storage" style="display:none">
  133. <div class="cl-card-head">
  134. <span><span locale="cluster/storage">Storage</span><span class="cl-sub" id="storage-summary"></span></span>
  135. <button class="cl-btn small" onclick="rescanStorage()"><span locale="cluster/vol/rescan">Rescan this node</span></button>
  136. </div>
  137. <div class="cl-card-body">
  138. <div class="cl-field">
  139. <label locale="cluster/vol/add">Contribute a folder from this node</label>
  140. <div class="cl-row">
  141. <select id="vol-fsh" class="cl-fixed" style="flex:0 0 auto; width:auto"></select>
  142. <input type="text" id="vol-path" placeholder="/cluster" value="/cluster">
  143. <input type="text" id="vol-name" placeholder="Name (optional)" style="max-width:220px">
  144. <button class="cl-btn primary cl-fixed" onclick="addVolume()"><span locale="cluster/vol/addbtn">Add volume</span></button>
  145. </div>
  146. <div class="cl-hint" locale="cluster/vol/hint">Files written to cluster:/ are stored as ordinary files inside contributed folders. Files already in the folder are adopted into the namespace on the next rescan. The cluster:/ drive appears in File Manager once the cluster has at least one volume.</div>
  147. </div>
  148. <div class="cl-field cl-toggle">
  149. <input type="checkbox" id="auto-ro">
  150. <div>
  151. <label for="auto-ro" class="cl-check" style="display:inline" locale="cluster/vol/autoro">Make nearly full volumes read only automatically</label>
  152. <div class="cl-hint" locale="cluster/vol/autorohint">Applies to every node. A volume under 5% free stops taking new files and takes them again above 7%. Turn this off to let volumes fill up; volumes it had locked become writable again within a minute.</div>
  153. </div>
  154. </div>
  155. <div style="overflow-x:auto">
  156. <table class="cl-table">
  157. <thead><tr>
  158. <th locale="cluster/vol/volume">Volume</th><th locale="cluster/node">Node</th><th locale="cluster/vol/space">Free / total</th><th locale="cluster/vol/files">Files</th><th></th>
  159. </tr></thead>
  160. <tbody id="vol-body"></tbody>
  161. </table>
  162. </div>
  163. <div class="cl-msg" id="msg-storage"></div>
  164. </div>
  165. </div>
  166. <div class="cl-grid">
  167. <!-- Replica policies -->
  168. <div class="cl-card" id="card-policy" style="display:none">
  169. <div class="cl-card-head"><span locale="cluster/pol/title">Replica policies</span></div>
  170. <div class="cl-card-body">
  171. <div class="cl-field">
  172. <label locale="cluster/pol/label">Copies per top-level folder</label>
  173. <div class="cl-row">
  174. <input type="text" id="pol-folder" placeholder="/photos">
  175. <select id="pol-replicas" class="cl-fixed" style="flex:0 0 auto; width:auto">
  176. <option value="1" locale="cluster/pol/1">1 copy</option>
  177. <option value="2" selected locale="cluster/pol/2">2 copies</option>
  178. <option value="3" locale="cluster/pol/3">3 copies</option>
  179. <option value="4" locale="cluster/pol/4">4 copies</option>
  180. </select>
  181. <button class="cl-btn cl-fixed" onclick="setPolicy()"><span locale="cluster/pol/set">Set</span></button>
  182. </div>
  183. <div class="cl-hint" locale="cluster/pol/hint">Folders without a policy keep one copy. Extra copies are created by the replication service.</div>
  184. </div>
  185. <ul class="cl-inline-list" id="pol-list"></ul>
  186. <div class="cl-msg" id="msg-meta"></div>
  187. </div>
  188. </div>
  189. <!-- Members -->
  190. <div class="cl-card" id="card-nodes" style="display:none">
  191. <div class="cl-card-head"><span locale="cluster/members">Members</span></div>
  192. <div class="cl-card-body">
  193. <div class="cl-hint" style="margin:0 0 8px 0" locale="cluster/membershint">Maintenance takes a node out of scheduling. Draining also moves its copies to other nodes so it can be switched off safely.</div>
  194. <ul class="cl-inline-list" id="node-list"></ul>
  195. <div class="cl-msg" id="msg-nodes"></div>
  196. </div>
  197. </div>
  198. </div>
  199. <!-- Scheduling weights -->
  200. <div class="cl-card" id="card-sched" style="display:none">
  201. <div class="cl-card-head">
  202. <span locale="cluster/sched/title">Scheduling</span>
  203. <span>
  204. <button class="cl-btn small" onclick="resetWeights()"><span locale="cluster/sched/reset">Restore defaults</span></button>
  205. <button class="cl-btn small" onclick="saveWeights()"><span locale="cluster/sched/save">Save weights</span></button>
  206. </span>
  207. </div>
  208. <div class="cl-card-body">
  209. <div class="cl-hint" locale="cluster/sched/hint">Jobs, new files and extra copies go to the node with the highest score. Raise a weight to make that quality matter more. The resulting scores are shown on Cluster Info.</div>
  210. <div class="cl-grid" style="margin-top:10px" id="sched-weights"></div>
  211. <div class="cl-msg" id="msg-sched"></div>
  212. </div>
  213. </div>
  214. </div>
  215. <script>
  216. var t = CL.t, fmt = CL.fmt, esc = CL.esc;
  217. var API = '../../system/cluster/';
  218. var clusterStatus = null;
  219. var configDirty = false, identityDirty = false, schedDirty = false;
  220. CL.applyTheme('cl-root');
  221. function post(path, data, cb) { CL.apiPost(API + path, data, cb); }
  222. function fail(box, err, r) {
  223. var e = err || CL.apiResult(r);
  224. if (e) { CL.showMsg(box, e, false); return true; }
  225. return false;
  226. }
  227. function nodeName(id) {
  228. if (!clusterStatus) return id;
  229. for (var i = 0; i < clusterStatus.nodes.length; i++) if (clusterStatus.nodes[i].id === id) return clusterStatus.nodes[i].name;
  230. return String(id).substring(0, 8);
  231. }
  232. /* Node and membership */
  233. function render() {
  234. var s = clusterStatus, local = s.local;
  235. document.getElementById('cl-nodeid').textContent = ' ' + local.nodeId;
  236. if (!configDirty) {
  237. document.getElementById('cfg-name').value = local.config.name || '';
  238. document.getElementById('cfg-url').value = local.config.advertiseUrl || '';
  239. document.getElementById('cfg-insecure').checked = !!local.config.insecureTls;
  240. }
  241. renderTunnelSelect(local.config.tunnelVia || '');
  242. toggleTunnelField();
  243. document.getElementById('card-standalone').style.display = s.inCluster ? 'none' : '';
  244. document.getElementById('card-member').style.display = s.inCluster ? '' : 'none';
  245. ['card-storage', 'card-policy', 'card-nodes', 'card-sched'].forEach(function (id) {
  246. document.getElementById(id).style.display = s.inCluster ? '' : 'none';
  247. });
  248. if (s.inCluster) {
  249. document.getElementById('v-clustername').textContent = s.cluster.name;
  250. document.getElementById('btn-token').disabled = !local.reachable;
  251. renderTokens(s.tokens || []);
  252. renderNodes(s.nodes || []);
  253. }
  254. document.getElementById('cl-refresh').textContent = fmt(t('cluster/updated', 'Updated {0}'), new Date().toLocaleTimeString());
  255. }
  256. function renderTunnelSelect(current) {
  257. var sel = document.getElementById('cfg-tunnelvia');
  258. var prev = configDirty ? sel.value : current;
  259. var html = '<option value="">' + esc(t('cluster/auto', 'Automatic (any reachable member)')) + '</option>';
  260. (clusterStatus.nodes || []).forEach(function (n) {
  261. if (n.local || !n.advertiseUrl) return;
  262. html += '<option value="' + esc(n.id) + '"' + (n.id === prev ? ' selected' : '') + '>' + esc(n.name) + ' (' + esc(n.advertiseUrl) + ')</option>';
  263. });
  264. sel.innerHTML = html;
  265. sel.value = prev;
  266. }
  267. function toggleTunnelField() {
  268. var url = document.getElementById('cfg-url').value.trim();
  269. document.getElementById('field-tunnel').style.display = (url === '' && clusterStatus && clusterStatus.inCluster) ? '' : 'none';
  270. }
  271. function renderTokens(tokens) {
  272. var html = '';
  273. tokens.forEach(function (tk) {
  274. html += '<li><span class="cl-mono">' + esc(tk.id) + '</span><span style="color:var(--cl-dim)">' +
  275. esc(fmt(t('cluster/tokenline', 'expires {0} · {1} uses'), CL.fmtDate(tk.expires), tk.uses)) + '</span>' +
  276. '<button class="cl-btn small danger" onclick="revokeToken(\'' + esc(tk.id) + '\')">' + esc(t('cluster/revoke', 'Revoke')) + '</button></li>';
  277. });
  278. document.getElementById('token-list').innerHTML = html;
  279. }
  280. function renderNodes(nodes) {
  281. var html = '';
  282. nodes.forEach(function (n) {
  283. var actions = '';
  284. if (n.adminState === 'maintenance' || n.adminState === 'draining') {
  285. actions += '<button class="cl-btn small" onclick="setNodeState(\'' + esc(n.id) + '\', \'normal\')">' + esc(t('cluster/resume', 'Resume')) + '</button>';
  286. } else {
  287. actions += '<button class="cl-btn small" onclick="setNodeState(\'' + esc(n.id) + '\', \'maintenance\')">' + esc(t('cluster/maintenance', 'Maintenance')) + '</button>';
  288. actions += '<button class="cl-btn small" onclick="setNodeState(\'' + esc(n.id) + '\', \'draining\')">' + esc(t('cluster/drain', 'Drain')) + '</button>';
  289. }
  290. if (!n.local) {
  291. actions += '<button class="cl-btn small danger" onclick="removeNode(\'' + esc(n.id) + '\', \'' + esc(n.name) + '\')">' + esc(t('cluster/remove', 'Remove')) + '</button>';
  292. }
  293. html += '<li><span><span class="cl-node-name">' + esc(n.name) + '</span>' + (n.local ? ' <span class="cl-chip">' + esc(t('cluster/you', 'you')) + '</span>' : '') +
  294. ' <span class="cl-badge ' + esc(n.state) + '">' + esc(CL.state(n.state)) + '</span></span>' +
  295. '<span class="cl-actions">' + actions + '</span></li>';
  296. });
  297. document.getElementById('node-list').innerHTML = html || '<li class="cl-empty">' + esc(t('cluster/nonodes', 'No nodes')) + '</li>';
  298. }
  299. /* Identity */
  300. var identityStatus = null;
  301. function loadIdentity() {
  302. $.getJSON(API + 'identity/status', function (data) {
  303. if (CL.apiResult(data)) return;
  304. identityStatus = data;
  305. renderIdentity();
  306. });
  307. }
  308. function renderIdentity() {
  309. if (!clusterStatus || !clusterStatus.inCluster || !identityStatus) return;
  310. var st = identityStatus;
  311. var sel = document.getElementById('id-origin');
  312. var prev = identityDirty ? sel.value : (st.origin || '');
  313. var html = '<option value="">' + esc(t('cluster/id/disabled', 'Disabled (each node checks its own accounts)')) + '</option>';
  314. (clusterStatus.nodes || []).forEach(function (n) {
  315. html += '<option value="' + esc(n.id) + '"' + (n.id === prev ? ' selected' : '') + '>' + esc(n.name) + (n.local ? ' (' + esc(t('cluster/thisnode', 'This node')) + ')' : '') + '</option>';
  316. });
  317. sel.innerHTML = html;
  318. sel.value = prev;
  319. document.getElementById('btn-idsync').disabled = !st.enabled || st.isOrigin;
  320. var rows = '';
  321. var row = function (k, fb, v) { rows += '<dt>' + esc(t(k, fb)) + '</dt><dd>' + v + '</dd>'; };
  322. if (!st.enabled) {
  323. row('cluster/id/mode', 'Mode', esc(t('cluster/id/local', 'Each node verifies logins against its own accounts')));
  324. } else if (st.isOrigin) {
  325. row('cluster/id/mode', 'Mode', '<span class="cl-badge ONLINE">' + esc(t('cluster/id/origin', 'ORIGIN')) + '</span> ' + esc(t('cluster/id/originhere', 'This node verifies logins for the whole cluster')));
  326. row('cluster/id/accounts', 'Accounts', esc(fmt(t('cluster/id/published', '{0} published to members'), st.localAccounts)));
  327. } else {
  328. row('cluster/id/mode', 'Mode', '<span class="cl-badge ' + (st.lastForwardError ? 'DEGRADED' : 'ONLINE') + '">' + esc(t('cluster/id/forward', 'FORWARD')) + '</span> ' + esc(fmt(t('cluster/id/verifiedby', 'Logins verified by {0}'), st.originName)));
  329. row('cluster/id/replicated', 'Replicated', esc(fmt(t('cluster/id/mirrors', '{0} of {1} local accounts mirror the origin'), st.managedAccounts, st.localAccounts)));
  330. row('cluster/id/lastsync', 'Last sync', (st.lastSync ? esc(CL.fmtAgo(st.lastSync)) : esc(t('cluster/never', 'never'))) + (st.lastSyncError ? ' <span style="color:var(--cl-danger)">' + esc(CL.tr(st.lastSyncError)) + '</span>' : ''));
  331. if (st.lastForwardError) {
  332. row('cluster/id/lastlogin', 'Last login check', '<span style="color:var(--cl-warn)">' + esc(t('cluster/id/fallback', 'origin unreachable, local accounts used')) + '</span><div class="cl-node-meta">' + esc(CL.tr(st.lastForwardError)) + '</div>');
  333. }
  334. if (st.skipped && st.skipped.length) {
  335. row('cluster/id/skipped', 'Skipped', esc(st.skipped.join(', ')) + '<div class="cl-node-meta">' + esc(t('cluster/id/skippedwhy', 'none of their groups exist on this node')) + '</div>');
  336. }
  337. if (st.conflicts && st.conflicts.length) {
  338. row('cluster/id/conflicts', 'Local only', esc(st.conflicts.join(', ')) + '<div class="cl-node-meta">' + esc(t('cluster/id/conflictswhy', 'a local account with the same name is kept as is')) + '</div>');
  339. }
  340. }
  341. document.getElementById('id-details').innerHTML = rows;
  342. }
  343. function setIdentityOrigin() {
  344. post('identity/origin', { id: document.getElementById('id-origin').value }, function (err, r) {
  345. if (fail('msg-member', err, r)) return;
  346. identityDirty = false;
  347. CL.showMsg('msg-member', t('cluster/saved', 'Saved'), true);
  348. loadStatus();
  349. });
  350. }
  351. function syncIdentity() {
  352. post('identity/sync', {}, function (err, r) {
  353. if (fail('msg-member', err, r)) return;
  354. CL.showMsg('msg-member', t('cluster/id/synced', 'Accounts synchronised'), true);
  355. loadStatus();
  356. });
  357. }
  358. /* Storage */
  359. var storageDirty = false;
  360. function loadStorage() {
  361. $.getJSON(API + 'storage/status', function (st) {
  362. if (CL.apiResult(st)) return;
  363. var sel = document.getElementById('vol-fsh');
  364. var prev = sel.value;
  365. var html = '';
  366. (st.localRoots || []).forEach(function (r) {
  367. html += '<option value="' + esc(r.uuid) + '"' + (r.uuid === prev ? ' selected' : '') + '>' + esc(r.uuid) + ':/ (' + esc(r.path) + ')</option>';
  368. });
  369. sel.innerHTML = html;
  370. if (prev) sel.value = prev;
  371. document.getElementById('auto-ro').checked = st.autoReadOnly !== false;
  372. var rows = '', totalFree = 0;
  373. (st.volumes || []).forEach(function (v) {
  374. totalFree += v.free || 0;
  375. var actions = '';
  376. if (v.local && v.evacuating) {
  377. actions += '<span class="cl-badge DRAINING">' + esc(t('cluster/vol/evacuating', 'EVACUATING')) + '</span> ';
  378. actions += '<button class="cl-btn small" onclick="cancelEvacuate(\'' + esc(v.id) + '\')">' + esc(t('cluster/cancel', 'Cancel')) + '</button>';
  379. } else if (v.local) {
  380. actions += '<button class="cl-btn small" onclick="setVolumeRO(\'' + esc(v.id) + '\',' + (v.readOnly ? 'false' : 'true') + ')">' + esc(v.readOnly ? t('cluster/vol/writable', 'Make writable') : t('cluster/vol/readonly', 'Read only')) + '</button> ';
  381. actions += '<button class="cl-btn small" onclick="evacuateVolume(\'' + esc(v.id) + '\',\'' + esc(v.name) + '\')">' + esc(t('cluster/vol/evacuate', 'Evacuate')) + '</button> ';
  382. actions += '<button class="cl-btn small danger" onclick="removeVolume(\'' + esc(v.id) + '\',\'' + esc(v.name) + '\')">' + esc(t('cluster/remove', 'Remove')) + '</button>';
  383. }
  384. var flags = '';
  385. if (v.lowSpace) flags += ' <span class="cl-chip">' + esc(t('cluster/vol/full', 'nearly full')) + '</span>';
  386. else if (v.readOnly) flags += ' <span class="cl-chip">' + esc(t('cluster/vol/ro', 'read only')) + '</span>';
  387. var pct = v.capacity > 0 ? (v.capacity - v.free) / v.capacity * 100 : 0;
  388. //The node is named by its ID here: node names are operator set
  389. //and default to the same one on every host
  390. rows += '<tr><td><div class="cl-node-name">' + esc(v.name) + flags + '</div><div class="cl-node-meta cl-mono">' + esc(CL.nodePath(v.nodeId, v.fshUuid, v.subpath)) + '</div></td>' +
  391. '<td>' + esc(v.nodeName) + (v.local ? ' <span class="cl-chip">' + esc(t('cluster/you', 'you')) + '</span>' : '') + '<div class="cl-node-meta"><span class="cl-badge ' + (v.online ? 'ONLINE' : 'OFFLINE') + '">' + esc(CL.state(v.online ? 'online' : 'offline')) + '</span></div></td>' +
  392. '<td>' + CL.fmtBytes(v.free) + ' / ' + CL.fmtBytes(v.capacity) + CL.bar(pct) + '</td>' +
  393. '<td>' + v.files + '<div class="cl-node-meta">' + CL.fmtBytes(v.bytes) + '</div></td>' +
  394. '<td><div class="cl-actions">' + actions + '</div></td></tr>';
  395. });
  396. document.getElementById('vol-body').innerHTML = rows || '<tr><td colspan="5" class="cl-empty">' + esc(t('cluster/vol/none', 'No volumes yet. Add a folder from this node to start storing files in cluster:/')) + '</td></tr>';
  397. document.getElementById('storage-summary').textContent = ' ' + fmt(t('cluster/vol/summary', '{0} volumes, {1} free'), (st.volumes || []).length, CL.fmtBytes(totalFree));
  398. });
  399. }
  400. function addVolume() {
  401. post('storage/volume/add', { fsh: document.getElementById('vol-fsh').value, path: document.getElementById('vol-path').value, name: document.getElementById('vol-name').value }, function (err, r) {
  402. if (fail('msg-storage', err, r)) return;
  403. CL.showMsg('msg-storage', t('cluster/vol/added', 'Volume added'), true);
  404. loadStorage();
  405. });
  406. }
  407. function removeVolume(id, name) {
  408. if (!confirm(fmt(t('cluster/vol/confirmremove', 'Stop contributing "{0}"? Files stay on disk but copies only held there become unavailable.'), name))) return;
  409. post('storage/volume/remove', { id: id }, function (err, r) {
  410. if (fail('msg-storage', err, r)) return;
  411. loadStorage();
  412. });
  413. }
  414. function setVolumeRO(id, ro) {
  415. post('storage/volume/readonly', { id: id, readonly: ro }, function (err, r) {
  416. if (fail('msg-storage', err, r)) return;
  417. loadStorage();
  418. });
  419. }
  420. function setAutoReadOnly() {
  421. var on = document.getElementById('auto-ro').checked;
  422. post('storage/autoreadonly', { enabled: on ? 'true' : 'false' }, function (err, r) {
  423. if (fail('msg-storage', err, r)) { loadStorage(); return; }
  424. CL.showMsg('msg-storage', on ? t('cluster/vol/autoroon', 'Nearly full volumes will be made read only') : t('cluster/vol/autorooff', 'Volumes may now fill up completely'), true);
  425. loadStorage();
  426. });
  427. }
  428. function rescanStorage() {
  429. post('storage/rescan', {}, function (err, r) {
  430. if (fail('msg-storage', err, r)) return;
  431. var adopted = 0, stale = 0;
  432. (r || []).forEach(function (x) { adopted += x.adopted; stale += x.stale; });
  433. CL.showMsg('msg-storage', fmt(t('cluster/vol/rescanned', 'Rescan done: {0} adopted, {1} stale'), adopted, stale), true);
  434. loadStorage();
  435. });
  436. }
  437. function evacuateVolume(id, name) {
  438. if (!confirm(fmt(t('cluster/vol/confirmevac', 'Move every file off "{0}"? The volume becomes read only and is retired once all copies exist elsewhere.'), name))) return;
  439. post('storage/volume/evacuate', { id: id }, function (err, r) {
  440. if (fail('msg-storage', err, r)) return;
  441. CL.showMsg('msg-storage', fmt(t('cluster/vol/evacstarted', 'Evacuation started: {0} files to move'), r.remaining), true);
  442. loadStorage();
  443. });
  444. }
  445. function cancelEvacuate(id) {
  446. post('storage/volume/evacuate/cancel', { id: id }, function (err, r) {
  447. if (fail('msg-storage', err, r)) return;
  448. loadStorage();
  449. });
  450. }
  451. /* Replica policies */
  452. function loadPolicies() {
  453. $.getJSON(API + 'meta/policy/list', function (list) {
  454. if (CL.apiResult(list)) return;
  455. var html = '';
  456. (list || []).forEach(function (p) {
  457. html += '<li><span class="cl-mono">' + esc(p.folder) + '</span><span>' + esc(fmt(t('cluster/pol/copies', '{0} copies'), p.replicas)) + '</span></li>';
  458. });
  459. document.getElementById('pol-list').innerHTML = html;
  460. });
  461. }
  462. function setPolicy() {
  463. post('meta/policy/set', { folder: document.getElementById('pol-folder').value, replicas: document.getElementById('pol-replicas').value }, function (err, r) {
  464. if (fail('msg-meta', err, r)) return;
  465. CL.showMsg('msg-meta', t('cluster/saved', 'Saved'), true);
  466. loadPolicies();
  467. });
  468. }
  469. /* Scheduling weights */
  470. var WEIGHT_FIELDS = [
  471. ['locality', 'Data locality', 'Prefer a node that already holds the data'],
  472. ['cpu', 'Free CPU', 'Prefer an idle processor'],
  473. ['memory', 'Free memory', 'Prefer a node with spare RAM'],
  474. ['disk', 'Free disk', 'Prefer a node with room to grow'],
  475. ['queue', 'Queue depth', 'Avoid a node that is already busy'],
  476. ['latency', 'Network distance', 'Avoid a node far from the data'],
  477. ['degraded', 'Health', 'Avoid a node that is struggling'],
  478. ['preferred', 'Wanted features', 'Prefer a node with the optional features a job asks for'],
  479. ['diversity', 'Site diversity', 'Put a second copy far from the first, so one site going down loses nothing']
  480. ];
  481. function renderWeights(w) {
  482. var html = '';
  483. WEIGHT_FIELDS.forEach(function (f) {
  484. var key = f[0], val = (w[key] === undefined ? 0 : w[key]);
  485. html += '<div class="cl-field" style="margin-bottom:8px">' +
  486. '<label>' + esc(t('cluster/sched/' + key, f[1])) + ' <span class="cl-node-meta" id="wv-' + key + '">' + val.toFixed(2) + '</span></label>' +
  487. '<input type="range" min="0" max="1" step="0.05" id="w-' + key + '" value="' + val + '" style="width:100%">' +
  488. '<div class="cl-hint">' + esc(t('cluster/sched/' + key + '/hint', f[2])) + '</div></div>';
  489. });
  490. document.getElementById('sched-weights').innerHTML = html;
  491. WEIGHT_FIELDS.forEach(function (f) {
  492. var el = document.getElementById('w-' + f[0]);
  493. el.addEventListener('input', function () {
  494. schedDirty = true;
  495. document.getElementById('wv-' + f[0]).textContent = parseFloat(el.value).toFixed(2);
  496. });
  497. });
  498. }
  499. function loadWeights() {
  500. if (schedDirty) return;
  501. $.getJSON(API + 'sched/weights', function (w) {
  502. if (CL.apiResult(w) || schedDirty) return;
  503. renderWeights(w || {});
  504. });
  505. }
  506. function saveWeights() {
  507. var out = {};
  508. WEIGHT_FIELDS.forEach(function (f) { out[f[0]] = document.getElementById('w-' + f[0]).value; });
  509. post('sched/weights', out, function (err, r) {
  510. if (fail('msg-sched', err, r)) return;
  511. schedDirty = false;
  512. CL.showMsg('msg-sched', t('cluster/sched/saved', 'Weights saved for the whole cluster'), true);
  513. loadWeights();
  514. });
  515. }
  516. function resetWeights() {
  517. post('sched/weights', { reset: true }, function (err, r) {
  518. if (fail('msg-sched', err, r)) return;
  519. schedDirty = false;
  520. CL.showMsg('msg-sched', t('cluster/sched/restored', 'Default weights restored'), true);
  521. loadWeights();
  522. });
  523. }
  524. /* Actions */
  525. function loadStatus() {
  526. $.getJSON(API + 'status', function (data) {
  527. if (CL.apiResult(data)) return;
  528. clusterStatus = data;
  529. render();
  530. if (data.inCluster) { loadIdentity(); loadStorage(); loadPolicies(); loadWeights(); }
  531. });
  532. }
  533. function saveConfig() {
  534. post('config', {
  535. name: document.getElementById('cfg-name').value,
  536. url: document.getElementById('cfg-url').value,
  537. tunnelvia: document.getElementById('cfg-tunnelvia').value,
  538. insecure: document.getElementById('cfg-insecure').checked
  539. }, function (err, r) {
  540. if (fail('msg-config', err, r)) return;
  541. configDirty = false;
  542. CL.showMsg('msg-config', t('cluster/saved', 'Saved'), true);
  543. loadStatus();
  544. });
  545. }
  546. function testUrl() {
  547. var url = document.getElementById('cfg-url').value.trim();
  548. if (!url) { CL.showMsg('msg-config', t('cluster/enterurl', 'Enter a URL to test'), false); return; }
  549. CL.showMsg('msg-config', t('cluster/testing', 'Testing...'), true);
  550. post('testurl', { url: url }, function (err, r) {
  551. if (fail('msg-config', err, r)) return;
  552. CL.showMsg('msg-config', fmt(t('cluster/reachableok', 'Reachable, round trip {0} ms'), r.latencyMs), true);
  553. });
  554. }
  555. function createCluster() {
  556. var name = document.getElementById('create-name').value.trim();
  557. if (!name) { CL.showMsg('msg-cluster', t('cluster/entername', 'Enter a cluster name'), false); return; }
  558. post('create', { name: name }, function (err, r) {
  559. if (fail('msg-cluster', err, r)) return;
  560. loadStatus();
  561. });
  562. }
  563. function joinCluster() {
  564. var token = document.getElementById('join-token').value.trim();
  565. if (!token) { CL.showMsg('msg-cluster', t('cluster/entertoken', 'Paste a join token'), false); return; }
  566. CL.showMsg('msg-cluster', t('cluster/joining', 'Joining...'), true);
  567. post('join', { token: token }, function (err, r) {
  568. if (fail('msg-cluster', err, r)) return;
  569. document.getElementById('join-token').value = '';
  570. loadStatus();
  571. });
  572. }
  573. function leaveCluster() {
  574. if (!confirm(t('cluster/confirmleave', 'Leave the cluster? Files and jobs on other nodes will no longer be reachable from here.'))) return;
  575. post('leave', {}, function (err, r) {
  576. if (fail('msg-member', err, r)) return;
  577. document.getElementById('token-out').style.display = 'none';
  578. loadStatus();
  579. });
  580. }
  581. function newToken() {
  582. post('token/new', { ttl: document.getElementById('token-ttl').value }, function (err, r) {
  583. if (fail('msg-member', err, r)) return;
  584. document.getElementById('token-text').textContent = r.token;
  585. document.getElementById('token-exp').textContent = fmt(t('cluster/expiresat', 'expires {0}'), CL.fmtDate(r.expires));
  586. document.getElementById('token-out').style.display = '';
  587. loadStatus();
  588. });
  589. }
  590. function copyToken() {
  591. var text = document.getElementById('token-text').textContent;
  592. var done = function () { CL.showMsg('msg-member', t('cluster/copied', 'Token copied to clipboard'), true); };
  593. if (navigator.clipboard && navigator.clipboard.writeText) {
  594. navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text); done(); });
  595. } else { fallbackCopy(text); done(); }
  596. }
  597. function fallbackCopy(text) {
  598. var ta = document.createElement('textarea');
  599. ta.value = text; document.body.appendChild(ta); ta.select();
  600. try { document.execCommand('copy'); } catch (e) {}
  601. document.body.removeChild(ta);
  602. }
  603. function revokeToken(id) {
  604. post('token/revoke', { id: id }, function (err, r) {
  605. if (fail('msg-member', err, r)) return;
  606. loadStatus();
  607. });
  608. }
  609. function setNodeState(id, state) {
  610. post('node/state', { id: id, state: state }, function (err, r) {
  611. if (fail('msg-nodes', err, r)) return;
  612. loadStatus();
  613. });
  614. }
  615. function removeNode(id, name) {
  616. if (!confirm(fmt(t('cluster/confirmremove', 'Remove node "{0}"?'), name))) return;
  617. post('node/remove', { id: id }, function (err, r) {
  618. if (fail('msg-nodes', err, r)) return;
  619. loadStatus();
  620. });
  621. }
  622. /* Wire up */
  623. ['cfg-name', 'cfg-url', 'cfg-tunnelvia', 'cfg-insecure'].forEach(function (id) {
  624. document.getElementById(id).addEventListener('input', function () { configDirty = true; toggleTunnelField(); });
  625. document.getElementById(id).addEventListener('change', function () { configDirty = true; toggleTunnelField(); });
  626. });
  627. document.getElementById('id-origin').addEventListener('change', function () { identityDirty = true; });
  628. document.getElementById('vol-fsh').addEventListener('change', function () { storageDirty = true; });
  629. document.getElementById('auto-ro').addEventListener('change', setAutoReadOnly);
  630. CL.init(function () {
  631. loadStatus();
  632. CL.every(loadStatus, 10000, 'cl-root');
  633. });
  634. </script>
  635. </body>
  636. </html>