@andrian.yablonskyy/thub-coordinator 1.0.19 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
5
5
  "main": "src/server.js",
6
6
  "engines": {
@@ -4,3 +4,27 @@
4
4
  white-space: pre-wrap;
5
5
  word-break: break-all;
6
6
  }
7
+
8
+ /* Toast auto-close countdown (public/js/toasts.js): a thin bar along the
9
+ bottom that shrinks over the toast's delay; the toast closes when it
10
+ runs out. Hovering or focusing the toast pauses it. currentColor keeps
11
+ it readable on every text-bg-* variant. */
12
+ .thub-toast-progress {
13
+ position: absolute;
14
+ inset: auto 0 0 0;
15
+ height: 3px;
16
+ background: currentColor;
17
+ opacity: 0.45;
18
+ transform-origin: left center;
19
+ animation: thub-toast-countdown var(--thub-toast-delay, 10s) linear forwards;
20
+ }
21
+
22
+ .toast:hover .thub-toast-progress,
23
+ .toast:focus-within .thub-toast-progress {
24
+ animation-play-state: paused;
25
+ }
26
+
27
+ @keyframes thub-toast-countdown {
28
+ from { transform: scaleX(1); }
29
+ to { transform: scaleX(0); }
30
+ }
@@ -22,12 +22,28 @@
22
22
  document.querySelectorAll('[data-thub-toast]').forEach((el) => {
23
23
  const type = el.dataset.thubToast in DELAY_MS ? el.dataset.thubToast : 'info',
24
24
  delay = el.dataset.sticky ? null : DELAY_MS[type],
25
- toast = new bootstrap.Toast(el, { autohide: delay !== null, delay: delay ?? 0 });
25
+ // Our own countdown instead of Bootstrap's autohide: that one restarts
26
+ // the full delay after every hover, which a progress bar can't show.
27
+ toast = new bootstrap.Toast(el, { autohide: false });
26
28
  // "Self-destroying": gone from the page once hidden, not just invisible.
27
29
  el.addEventListener('hidden.bs.toast', () => {
28
30
  toast.dispose();
29
31
  el.remove();
30
32
  });
33
+
34
+ // The bar is the timer: a CSS animation over `delay` (paused while the
35
+ // toast is hovered or focused, see thub.css) — the toast closes when
36
+ // it ends. Added here, not in the markup, so without this script no
37
+ // bar suggests a close that would never happen.
38
+ if (delay !== null){
39
+ const bar = document.createElement('div');
40
+ bar.className = 'thub-toast-progress';
41
+ bar.setAttribute('aria-hidden', 'true');
42
+ bar.style.setProperty('--thub-toast-delay', `${delay}ms`);
43
+ bar.addEventListener('animationend', () => toast.hide(), { once: true });
44
+ el.classList.add('position-relative', 'overflow-hidden');
45
+ el.appendChild(bar);
46
+ }
31
47
  toast.show();
32
48
  });
33
49
  })();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/update-watch.js
3
+ * @description Dashboard: while a Coordinator self-update is pending, polls its status and reloads the page when done
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ (function (){
17
+ const marker = document.getElementById('coordinator-update-pending');
18
+ if (!marker){
19
+ return;
20
+ }
21
+ const currentVersion = marker.dataset.currentVersion,
22
+ POLL_MS = 3000;
23
+
24
+ // Reload once the Coordinator runs another version (the update is done),
25
+ // or no longer has one pending (it failed — the reloaded page says why).
26
+ // While it restarts, requests fail or hit the proxy's error page: keep
27
+ // polling. A reply that isn't our JSON (e.g. the login page, sessions
28
+ // having been reset by the restart) also means it's back — reload.
29
+ async function poll(){
30
+ let res;
31
+ try {
32
+ res = await fetch('/updates/status', { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'application/json' } });
33
+ }
34
+ catch {
35
+ return setTimeout(poll, POLL_MS); // down mid-restart
36
+ }
37
+ if (!res.ok){
38
+ return setTimeout(poll, POLL_MS); // e.g. 502 from the reverse proxy
39
+ }
40
+ let status;
41
+ try {
42
+ status = await res.json();
43
+ }
44
+ catch {
45
+ return location.reload();
46
+ }
47
+ if (status.coordinatorVersion !== currentVersion || !status.pending){
48
+ return location.reload();
49
+ }
50
+ setTimeout(poll, POLL_MS);
51
+ }
52
+
53
+ setTimeout(poll, POLL_MS);
54
+ })();
@@ -48,6 +48,7 @@ function renderUpdateUnits(user, paths){
48
48
  unitPath = (name) => path.join(__dirname, '..', 'systemd', name);
49
49
  return {
50
50
  [UPDATE_PATH_UNIT]: fs.readFileSync(unitPath(UPDATE_PATH_UNIT), 'utf8')
51
+ .replace(/^PathExists=.*$/m, `PathExists=${paths.updateRequestFile}`)
51
52
  .replace(/^PathModified=.*$/m, `PathModified=${paths.updateRequestFile}`),
52
53
  // npm is a `#!/usr/bin/env node` script, so this node goes first on PATH.
53
54
  [UPDATE_SERVICE_UNIT]: fs.readFileSync(unitPath(UPDATE_SERVICE_UNIT), 'utf8')
@@ -15,9 +15,33 @@
15
15
  'use strict';
16
16
 
17
17
  const fs = require('node:fs'),
18
+ path = require('node:path'),
19
+ { execFileSync } = require('node:child_process'),
18
20
  { PACKAGES, isValidVersion, compareVersions, npmInstallGlobal } = require('@andrian.yablonskyy/thub-common'),
19
21
  { version: installedVersion } = require('../package.json');
20
22
 
23
+ // Progress for the dashboard (services/updates.js reads it), next to the
24
+ // request: {version, state, message, at} with state installing |
25
+ // restarting | failed. Without it the dashboard can't tell a failed or
26
+ // never-started update from one still running, and waits forever.
27
+ let statusFile = null;
28
+ function report(version, state, message = null){
29
+ if (!statusFile){
30
+ return;
31
+ }
32
+ try {
33
+ fs.writeFileSync(statusFile, JSON.stringify({ version, state, message, at: new Date().toISOString() }) + '\n', { mode: 0o644 });
34
+ }
35
+ catch (err){
36
+ console.error(`thub-coordinator-update: could not write ${statusFile}: ${err.message}`);
37
+ }
38
+ }
39
+
40
+ // The request only ever comes from a Coordinator older than `version`.
41
+ function restartCoordinator(){
42
+ execFileSync('systemctl', ['restart', 'thub-coordinator.service'], { stdio: 'inherit' });
43
+ }
44
+
21
45
  // argv: <request file> <user the Coordinator runs as>. The request file
22
46
  // is written by the (unprivileged) Coordinator, so only its `version` is
23
47
  // used, and only if it's a plain semver — the package is fixed.
@@ -27,6 +51,7 @@ function main(){
27
51
  console.error('usage: self-update-helper.js <request file> <user>');
28
52
  return 2;
29
53
  }
54
+ statusFile = path.join(path.dirname(requestFile), 'update-status.json');
30
55
 
31
56
  let request;
32
57
  try {
@@ -46,20 +71,41 @@ function main(){
46
71
  const target = request?.version;
47
72
  if (!isValidVersion(target)){
48
73
  console.error('thub-coordinator-update: ignoring request with an invalid version');
74
+ report(String(target), 'failed', 'The update request had an invalid version.');
49
75
  return 1;
50
76
  }
77
+ // Already installed on disk (e.g. a manual `npm i -g` whose restart never
78
+ // happened) while the running Coordinator is older — it asked, after all.
79
+ // Just restart it onto what's installed instead of leaving it waiting.
51
80
  if (compareVersions(installedVersion, target) >= 0){
52
- console.log(`thub-coordinator-update: already on v${installedVersion} (requested v${target})`);
53
- return 0;
81
+ console.log(`thub-coordinator-update: v${installedVersion} is already installed (requested v${target}) — restarting the Coordinator onto it`);
82
+ report(target, 'restarting');
83
+ try {
84
+ restartCoordinator();
85
+ return 0;
86
+ }
87
+ catch (err){
88
+ report(target, 'failed', `v${installedVersion} is installed, but restarting the Coordinator failed: ${err.message}`);
89
+ return 1;
90
+ }
54
91
  }
55
92
 
56
93
  console.log(`thub-coordinator-update: v${installedVersion} -> v${target} (requested by ${request.requestedBy || 'the dashboard'})`);
94
+ report(target, 'installing');
57
95
  // SUDO_USER makes the postinstall scripts target the Coordinator's user,
58
96
  // as a `sudo npm i -g` by that user would (install-target.js); the
59
97
  // postinstall then restarts thub-coordinator on the new version.
60
- const status = npmInstallGlobal(PACKAGES.coordinator, target, { env: { ...process.env, SUDO_USER: user } });
98
+ let status;
99
+ try {
100
+ status = npmInstallGlobal(PACKAGES.coordinator, target, { env: { ...process.env, SUDO_USER: user } });
101
+ }
102
+ catch (err){
103
+ report(target, 'failed', `Could not run npm: ${err.message}`);
104
+ return 1;
105
+ }
61
106
  if (status !== 0){
62
107
  console.error(`thub-coordinator-update: npm i -g failed (exit ${status})`);
108
+ report(target, 'failed', `npm i -g failed (exit ${status}) — see: journalctl -u thub-coordinator-update`);
63
109
  }
64
110
  return status;
65
111
  }
@@ -15,21 +15,82 @@
15
15
 
16
16
  const fs = require('node:fs'),
17
17
  path = require('node:path'),
18
- { PACKAGES, fetchLatestVersion, defaultRegistry, isNewer } = require('@andrian.yablonskyy/thub-common'),
18
+ { PACKAGES, fetchLatestVersion, defaultRegistry, isNewer, compareVersions } = require('@andrian.yablonskyy/thub-common'),
19
19
  { version: coordinatorVersion } = require('../../package.json');
20
20
 
21
21
  // Installed by scripts/install-systemd-unit.js; runs the actual `npm i -g`
22
22
  // as root when the dashboard writes an update request (README §10.2).
23
- const UPDATE_PATH_UNIT = '/etc/systemd/system/thub-coordinator-update.path';
23
+ const UPDATE_PATH_UNIT = '/etc/systemd/system/thub-coordinator-update.path',
24
+ // No sign of the root helper by then: the request wasn't picked up.
25
+ PICKUP_TIMEOUT_MS = 60_000,
26
+ // Longer than thub-coordinator-update.service's TimeoutStartSec (15 min).
27
+ INSTALL_TIMEOUT_MS = 20 * 60_000;
24
28
 
25
29
  // Latest published versions, refreshed every `updates.checkIntervalMin`
26
30
  // and on demand from the dashboard. Kept in memory only — a restart just
27
31
  // checks again.
28
- function createUpdatesService({ config }){
32
+ // `pathUnit`, `fetchLatest` and `now` are overridable for tests only.
33
+ function createUpdatesService({ config, pathUnit = UPDATE_PATH_UNIT, fetchLatest = fetchLatestVersion, now = Date.now }){
29
34
  const registry = config.updates.registry || defaultRegistry(),
30
- state = { latest: {}, checkedAt: null, error: null, coordinatorPending: null };
35
+ state = { latest: {}, checkedAt: null, error: null, coordinatorPending: null, coordinatorRequestedAt: null, coordinatorUpdateError: null },
36
+ requestFile = path.join(config.dataDir, 'update-request.json'),
37
+ // Written by the root helper (scripts/self-update-helper.js).
38
+ statusFile = path.join(config.dataDir, 'update-status.json');
31
39
  let inFlight = null;
32
40
 
41
+ function readHelperStatus(){
42
+ try {
43
+ return JSON.parse(fs.readFileSync(statusFile, 'utf8'));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function failPending(message){
51
+ state.coordinatorUpdateError = { version: state.coordinatorPending, message, at: new Date().toISOString() };
52
+ state.coordinatorPending = null;
53
+ state.coordinatorRequestedAt = null;
54
+ console.error(`Coordinator self-update failed: ${message}`);
55
+ }
56
+
57
+ // A pending update ends with this process being restarted onto the new
58
+ // version. Anything else — the helper reporting failure, never picking
59
+ // the request up, or taking far too long — must end the wait too, or the
60
+ // dashboard shows "Updating…" forever.
61
+ function refreshPending(){
62
+ if (!state.coordinatorPending){
63
+ return;
64
+ }
65
+ const requestedAt = Date.parse(state.coordinatorRequestedAt),
66
+ helper = readHelperStatus(),
67
+ fresh = helper && Date.parse(helper.at) >= requestedAt - 1000;
68
+ if (fresh && helper.state === 'failed'){
69
+ failPending(helper.message || 'The update helper reported a failure — see: journalctl -u thub-coordinator-update');
70
+ }
71
+ else if (!fresh && now() - requestedAt > PICKUP_TIMEOUT_MS){
72
+ failPending(
73
+ fs.existsSync(requestFile)
74
+ ? `The update request wasn't picked up within ${PICKUP_TIMEOUT_MS / 1000} s — is the watcher running? ` +
75
+ 'sudo systemctl enable --now thub-coordinator-update.path'
76
+ : 'The update helper started but never reported progress — see: journalctl -u thub-coordinator-update'
77
+ );
78
+ }
79
+ else if (fresh && now() - Date.parse(helper.at) > INSTALL_TIMEOUT_MS){
80
+ failPending(`The update has been "${helper.state}" for over ${INSTALL_TIMEOUT_MS / 60_000} min — see: journalctl -u thub-coordinator-update`);
81
+ }
82
+ }
83
+
84
+ // On startup: a helper status for this version (or older) means the
85
+ // update that restarted us is done — clear it.
86
+ function settleHelperStatus(){
87
+ const helper = readHelperStatus();
88
+ if (helper && helper.state !== 'failed' && compareVersions(coordinatorVersion, String(helper.version)) >= 0){
89
+ fs.rmSync(statusFile, { force: true });
90
+ console.log(`Coordinator self-update to v${helper.version} completed`);
91
+ }
92
+ }
93
+
33
94
  async function checkNow(){
34
95
  // Concurrent callers (timer + a dashboard click) share one check.
35
96
  inFlight = inFlight || (async () => {
@@ -37,7 +98,7 @@ function createUpdatesService({ config }){
37
98
  errors = [];
38
99
  await Promise.all(Object.entries(PACKAGES).map(async ([app, pkg]) => {
39
100
  try {
40
- latest[app] = await fetchLatestVersion(pkg, { registry });
101
+ latest[app] = await fetchLatest(pkg, { registry });
41
102
  }
42
103
  catch (err){
43
104
  errors.push(err.message);
@@ -55,6 +116,7 @@ function createUpdatesService({ config }){
55
116
  }
56
117
 
57
118
  function status(){
119
+ refreshPending();
58
120
  return {
59
121
  ...state,
60
122
  coordinatorVersion,
@@ -70,17 +132,19 @@ function createUpdatesService({ config }){
70
132
  if (!target){
71
133
  throw Object.assign(new Error(`Already on the latest version (v${coordinatorVersion}).`), { status: 409 });
72
134
  }
73
- if (!fs.existsSync(UPDATE_PATH_UNIT)){
135
+ if (!fs.existsSync(pathUnit)){
74
136
  throw Object.assign(
75
- new Error(`Self-update isn't installed on this host (${UPDATE_PATH_UNIT}) — update by hand: thub-admin self-update`),
137
+ new Error(`Self-update isn't installed on this host (${pathUnit}) — update by hand: thub-admin self-update`),
76
138
  { status: 501 }
77
139
  );
78
140
  }
79
- fs.writeFileSync(
80
- path.join(config.dataDir, 'update-request.json'),
81
- JSON.stringify({ version: target, requestedBy, requestedAt: new Date().toISOString() }) + '\n'
82
- );
141
+ const requestedAt = new Date(now()).toISOString();
142
+ // A previous attempt's result mustn't be mistaken for this one's.
143
+ fs.rmSync(statusFile, { force: true });
144
+ fs.writeFileSync(requestFile, JSON.stringify({ version: target, requestedBy, requestedAt }) + '\n');
83
145
  state.coordinatorPending = target;
146
+ state.coordinatorRequestedAt = requestedAt;
147
+ state.coordinatorUpdateError = null;
84
148
  return target;
85
149
  }
86
150
 
@@ -97,6 +161,7 @@ function createUpdatesService({ config }){
97
161
  }
98
162
 
99
163
  function start(){
164
+ settleHelperStatus();
100
165
  const minutes = Number(config.updates.checkIntervalMin);
101
166
  if (!(minutes > 0)){
102
167
  return;
package/src/web/routes.js CHANGED
@@ -103,6 +103,18 @@ function createWebRouter({ services, config }){
103
103
  res.locals.messages = req.session?.flash || [];
104
104
  // Latest published versions (README §10.2) — navbar, Agents, Resources.
105
105
  res.locals.updates = services.updates.status();
106
+ // A failed Coordinator self-update: shown to each admin once, as an
107
+ // error toast (never auto-closes) — on a page view, not the polling
108
+ // endpoint or a POST whose redirect would drop it.
109
+ const updateError = res.locals.updates.coordinatorUpdateError;
110
+ if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' &&
111
+ req.session.seenUpdateError !== updateError.at){
112
+ req.session.seenUpdateError = updateError.at;
113
+ res.locals.messages = [
114
+ ...res.locals.messages,
115
+ { type: 'danger', text: `Coordinator update to v${updateError.version} failed: ${updateError.message}` }
116
+ ];
117
+ }
106
118
  res.locals.isNewer = isNewer;
107
119
  res.locals.currentPath = req.originalUrl;
108
120
  if (req.session){
@@ -427,9 +439,21 @@ function createWebRouter({ services, config }){
427
439
  router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
428
440
  const version = services.updates.requestCoordinatorUpdate(req.session.user.username);
429
441
  console.log(`Coordinator self-update to v${version} requested by ${req.session.user.username}`);
430
- return `Updating the Coordinator to v${version} — the dashboard restarts in a minute or two; reload the page then.`;
442
+ return `Updating the Coordinator to v${version} — this page reloads by itself once it's back up.`;
431
443
  }));
432
444
 
445
+ // Polled by public/js/update-watch.js while a Coordinator update is
446
+ // pending: a new `coordinatorVersion` (the restart happened) or a cleared
447
+ // `pending` (it failed) tells the page to reload.
448
+ router.get('/updates/status', (req, res) => {
449
+ const s = services.updates.status();
450
+ res.set('Cache-Control', 'no-store').json({
451
+ coordinatorVersion: s.coordinatorVersion,
452
+ pending: s.coordinatorPending,
453
+ error: s.coordinatorUpdateError
454
+ });
455
+ });
456
+
433
457
  router.post('/resources/update-all', requireAdminRole, updateAction(async () => {
434
458
  const version = await services.updates.targetVersion('client'),
435
459
  n = services.registry.requestUpdateAll(version);
@@ -1,11 +1,14 @@
1
1
  [Unit]
2
2
  Description=TestHub Coordinator self-update request watcher
3
3
 
4
- # PathModified= is rewritten by scripts/install-systemd-unit.js to the
5
- # update-request.json in the Coordinator's dataDir. The dashboard's
4
+ # PathExists=/PathModified= are rewritten by scripts/install-systemd-unit.js
5
+ # to the update-request.json in the Coordinator's dataDir. The dashboard's
6
6
  # "Update app" button writes it; this starts thub-coordinator-update.service
7
- # (as root) to install the requested version.
7
+ # (as root) to install the requested version. PathExists= also catches a
8
+ # request written while this watch wasn't active (e.g. mid-reinstall) — the
9
+ # helper deletes the file, so it doesn't re-trigger.
8
10
  [Path]
11
+ PathExists=/var/lib/thub/.thub/update-request.json
9
12
  PathModified=/var/lib/thub/.thub/update-request.json
10
13
  Unit=thub-coordinator-update.service
11
14
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file packages/coordinator/test/updates.test.js
3
+ * @description Tests: Coordinator self-update request lifecycle — the dashboard never waits forever on a stuck update
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const test = require('node:test'),
17
+ assert = require('node:assert/strict'),
18
+ fs = require('node:fs'),
19
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { createUpdatesService } = require('../src/services/updates'),
22
+ { version: installed } = require('../package.json');
23
+
24
+ const NEWER = installed.replace(/\d+$/, (n) => String(Number(n) + 1));
25
+
26
+ async function setup(){
27
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-updates-')),
28
+ pathUnit = path.join(dataDir, 'fake.path'),
29
+ clock = { t: Date.parse('2026-09-28T12:00:00Z') };
30
+ fs.writeFileSync(pathUnit, '');
31
+ const updates = createUpdatesService({
32
+ config: { dataDir, updates: {} },
33
+ pathUnit,
34
+ fetchLatest: async () => NEWER,
35
+ now: () => clock.t
36
+ });
37
+ await updates.checkNow();
38
+ const helperSays = (state, message) => fs.writeFileSync(
39
+ path.join(dataDir, 'update-status.json'),
40
+ JSON.stringify({ version: NEWER, state, message, at: new Date(clock.t).toISOString() })
41
+ );
42
+ return { updates, dataDir, clock, helperSays };
43
+ }
44
+
45
+ test('a request is written for the root helper and shows as pending', async () => {
46
+ const { updates, dataDir } = await setup();
47
+ assert.equal(updates.requestCoordinatorUpdate('alice'), NEWER);
48
+ assert.equal(JSON.parse(fs.readFileSync(path.join(dataDir, 'update-request.json'), 'utf8')).version, NEWER);
49
+ assert.equal(updates.status().coordinatorPending, NEWER);
50
+ });
51
+
52
+ test('a request nobody picks up stops pending after the pickup timeout, with the reason', async () => {
53
+ const { updates, clock } = await setup();
54
+ updates.requestCoordinatorUpdate('alice');
55
+ clock.t += 30_000;
56
+ assert.equal(updates.status().coordinatorPending, NEWER); // still within the timeout
57
+ clock.t += 31_000;
58
+ const s = updates.status();
59
+ assert.equal(s.coordinatorPending, null);
60
+ assert.match(s.coordinatorUpdateError.message, /wasn't picked up.*thub-coordinator-update\.path/);
61
+ });
62
+
63
+ test('a helper failure ends the wait at once with its message', async () => {
64
+ const { updates, clock, helperSays } = await setup();
65
+ updates.requestCoordinatorUpdate('alice');
66
+ clock.t += 2000;
67
+ helperSays('installing');
68
+ assert.equal(updates.status().coordinatorPending, NEWER);
69
+ helperSays('failed', 'npm i -g failed (exit 1)');
70
+ const s = updates.status();
71
+ assert.equal(s.coordinatorPending, null);
72
+ assert.deepEqual([s.coordinatorUpdateError.version, s.coordinatorUpdateError.message], [NEWER, 'npm i -g failed (exit 1)']);
73
+ });
74
+
75
+ test('an install that runs far too long times out; a normal one keeps pending', async () => {
76
+ const { updates, clock, helperSays } = await setup();
77
+ updates.requestCoordinatorUpdate('alice');
78
+ helperSays('installing');
79
+ clock.t += 5 * 60_000;
80
+ assert.equal(updates.status().coordinatorPending, NEWER);
81
+ clock.t += 16 * 60_000;
82
+ assert.match(updates.status().coordinatorUpdateError.message, /"installing" for over 20 min/);
83
+ });
84
+
85
+ test('a stale failure from an earlier attempt is not mistaken for the new one', async () => {
86
+ const { updates, clock, helperSays } = await setup();
87
+ helperSays('failed', 'old failure');
88
+ clock.t += 60_000;
89
+ updates.requestCoordinatorUpdate('alice'); // removes the old status
90
+ assert.equal(updates.status().coordinatorPending, NEWER);
91
+ assert.equal(updates.status().coordinatorUpdateError, null);
92
+ });
93
+
94
+ test('on startup, a finished update\'s status is cleared; a failure is kept for the dashboard', async () => {
95
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-updates-')),
96
+ statusFile = path.join(dataDir, 'update-status.json'),
97
+ make = () => createUpdatesService({ config: { dataDir, updates: {} }, fetchLatest: async () => installed });
98
+
99
+ fs.writeFileSync(statusFile, JSON.stringify({ version: installed, state: 'installing', at: new Date().toISOString() }));
100
+ make().start();
101
+ assert.equal(fs.existsSync(statusFile), false);
102
+
103
+ fs.writeFileSync(statusFile, JSON.stringify({ version: NEWER, state: 'failed', at: new Date().toISOString() }));
104
+ make().start();
105
+ assert.equal(fs.existsSync(statusFile), true);
106
+ });
package/views/layout.pug CHANGED
@@ -52,7 +52,14 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
52
52
  if user && user.role === 'admin' && updates.coordinatorUpdate
53
53
  li.nav-item.d-flex.align-items-center.me-3
54
54
  if updates.coordinatorPending
55
- button.btn.btn-sm.btn-warning(type="button" disabled)
55
+ //- public/js/update-watch.js polls /updates/status and
56
+ //- reloads the page once the new version is up (or the
57
+ //- update failed, to show why).
58
+ button.btn.btn-sm.btn-warning#coordinator-update-pending(
59
+ type="button"
60
+ disabled
61
+ data-current-version=coordinatorVersion
62
+ )
56
63
  span.spinner-border.spinner-border-sm.me-1(aria-hidden="true")
57
64
  = `Updating app to ${updates.coordinatorPending}…`
58
65
  else
@@ -104,12 +111,16 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
104
111
  )
105
112
  i.bi.bi-box-arrow-right
106
113
  //- Flash messages as toasts (public/js/toasts.js): errors stay until
107
- //- closed, warnings close after 30 s, info/success after 10 s. Rendered
114
+ //- closed, warnings close after 30 s, info/success after 10 s, with a
115
+ //- countdown bar along the bottom (paused on hover). Rendered
108
116
  //- visible (.show), so they're readable even if the script fails.
109
117
  if messages && messages.length
110
118
  .toast-container.position-fixed.end-0.p-3(style="top: 3.5rem; z-index: 1090;")
111
119
  each m in messages
112
- - const type = ['danger', 'warning', 'info', 'success'].includes(m.type) ? m.type : 'info'
120
+ //- Error-level aliases map to danger (never auto-closes); anything
121
+ //- else unknown is shown as info.
122
+ - const alias = { error: 'danger', critical: 'danger', fatal: 'danger' }[m.type] || m.type
123
+ - const type = ['danger', 'warning', 'info', 'success'].includes(alias) ? alias : 'info'
113
124
  - const icon = { danger: 'x-octagon-fill', warning: 'exclamation-triangle-fill', info: 'info-circle-fill', success: 'check-circle-fill' }[type]
114
125
  .toast.show.border-0(
115
126
  class=`text-bg-${type}`
@@ -136,4 +147,6 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
136
147
  script(src="/js/tooltips.js")
137
148
  script(src="/js/toasts.js")
138
149
  script(src="/js/copy-to-clipboard.js")
150
+ if user && user.role === 'admin' && updates.coordinatorPending
151
+ script(src="/js/update-watch.js")
139
152
  block scripts