@andrian.yablonskyy/thub-coordinator 1.0.15 → 1.0.17

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/README.md CHANGED
@@ -157,11 +157,13 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
157
157
  | `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
158
158
  | `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
159
159
 
160
+ Notifications (results of dashboard actions, errors) appear as toasts in the top-right corner and close themselves by severity: **errors stay until closed**, warnings close after **30 s**, info and success after **10 s**. A one-time secret such as a rotated resource token never closes on its own.
161
+
160
162
  **Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, host uptime and current task/status with its duration (both from the Client's heartbeats, anchored to the Coordinator's clock; as of the last heartbeat for an OUT_OF_SERVICE resource), Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts. Admins get a **Cancel** button on it, enabled only while the Client is doing something: it cancels its current job, releases a manual local lock, or aborts a self-update hold on its host (the last two delivered as `unlock` / `cancel-update` heartbeat commands).
161
163
 
162
164
  The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
163
165
 
164
- **Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalMin` (default 15 minutes, `0` disables; the older `checkIntervalHours` is ignored; `updates.registry` for a private mirror) and on **Check for updates**. A newer Coordinator shows as an **Update app to X.Y.Z** button in the navbar for admins (a badge under the logo for viewers); Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
166
+ **Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalMin` (default 15 minutes, `0` disables; the older `checkIntervalHours` is ignored; `updates.registry` for a private mirror) and immediately with the navbar's **check for updates** button (admins; also on the Agents and Resources pages), which reports in a toast whether the Coordinator is current and which connected resources can update their Client. A newer Coordinator shows as an **Update app to X.Y.Z** button in the navbar for admins (a badge under the logo for viewers); Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
165
167
  - Clients get a `self-update` command on their next heartbeat and install it through their root `thub-client-update` helper, once none of the host's instances is busy.
166
168
  - Agents install it at the start of their next run (`GET /api/v1/agents/me/update`), then re-run the command on the new version.
167
169
  - The Coordinator itself: the **Update app** button writes `<dataDir>/update-request.json`; the root `thub-coordinator-update.path` unit (installed by `sudo npm i -g`) runs `npm i -g` for it, and the postinstall restarts the service (the dashboard and API are briefly down). Logs: `journalctl -u thub-coordinator-update`. Without that unit, use `thub-admin self-update` on the host.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
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": {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/toasts.js
3
+ * @description Dashboard: shows flash messages as Bootstrap toasts that close themselves by severity
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
+ // Auto-close delay per severity; null = stays until closed. Errors never
18
+ // close on their own, and neither does a toast marked sticky (e.g. a
19
+ // one-time token the user still has to copy).
20
+ const DELAY_MS = { danger: null, warning: 30_000, info: 10_000, success: 10_000 };
21
+
22
+ document.querySelectorAll('[data-thub-toast]').forEach((el) => {
23
+ const type = el.dataset.thubToast in DELAY_MS ? el.dataset.thubToast : 'info',
24
+ delay = el.dataset.sticky ? null : DELAY_MS[type],
25
+ toast = new bootstrap.Toast(el, { autohide: delay !== null, delay: delay ?? 0 });
26
+ // "Self-destroying": gone from the page once hidden, not just invisible.
27
+ el.addEventListener('hidden.bs.toast', () => {
28
+ toast.dispose();
29
+ el.remove();
30
+ });
31
+ toast.show();
32
+ });
33
+ })();
@@ -91,11 +91,22 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
91
91
  }
92
92
  spec.source = source;
93
93
 
94
- if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group)){
94
+ // `target.client` (thub run --client) accepts a resource id or name;
95
+ // store the id so the job stays pinned to the same Client across renames.
96
+ if (spec.target.client){
97
+ const resource = registry.get(spec.target.client) || registry.getByName(spec.target.client);
98
+ if (!resource){
99
+ throw Object.assign(new Error(`Unknown client "${spec.target.client}"`), { status: 422 });
100
+ }
101
+ spec.target.client = resource.id;
102
+ }
103
+
104
+ if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group, spec.target.client)){
95
105
  throw Object.assign(
96
106
  new Error(
97
107
  `No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
98
- (spec.target.group ? ` group=${spec.target.group}` : '')
108
+ (spec.target.group ? ` group=${spec.target.group}` : '') +
109
+ (spec.target.client ? ` client=${spec.target.client}` : '')
99
110
  ),
100
111
  { status: 422 }
101
112
  );
@@ -468,21 +468,23 @@ function createRegistryService(db, { bus, events }){
468
468
  );
469
469
  }
470
470
 
471
- function findIdleCandidates(type, labels, groupId){
471
+ function matchesTarget(r, labels, groupId, resourceId){
472
+ return labels.every((l) => r.labels.includes(l)) &&
473
+ (!groupId || r.group_ids.includes(groupId)) &&
474
+ (!resourceId || r.id === resourceId);
475
+ }
476
+
477
+ function findIdleCandidates(type, labels, groupId, resourceId){
472
478
  const rows = db
473
479
  .prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
474
480
  .all(RESOURCE_STATES.IDLE, type)
475
481
  .map(rowToResource);
476
- return rows.filter(
477
- (r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
478
- );
482
+ return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
479
483
  }
480
484
 
481
- function everSatisfiable(type, labels, groupId){
485
+ function everSatisfiable(type, labels, groupId, resourceId){
482
486
  const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
483
- return rows.some(
484
- (r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
485
- );
487
+ return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
486
488
  }
487
489
 
488
490
  return {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/src/services/scheduler.js
3
- * @description Scheduler: matches queued jobs to idle resources by type/labels/group (README §5.4)
3
+ * @description Scheduler: matches queued jobs to idle resources by type/labels/group/client (README §5.4)
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -34,21 +34,28 @@ function createScheduler(db, { bus, events, registry, config }){
34
34
  .map(rowToJob),
35
35
 
36
36
  assignments = [],
37
- claimedResourceIds = new Set();
37
+ claimedResourceIds = new Set(),
38
+ // Resources that some queued job is pinned to (`target.client`) —
39
+ // an unpinned job only takes one of these when nothing else fits,
40
+ // so it doesn't jump the pinned job's per-client queue.
41
+ reservedResourceIds = new Set(queued.map((j) => j.spec.target.client).filter(Boolean));
38
42
 
39
43
  for (const job of queued){
40
- const candidates = registry
41
- .findIdleCandidates(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group)
42
- .filter((r) => !claimedResourceIds.has(r.id));
44
+ const { type, labels = [], group, client } = job.spec.target,
45
+ candidates = registry
46
+ .findIdleCandidates(type, labels, group, client)
47
+ .filter((r) => !claimedResourceIds.has(r.id));
43
48
  if (candidates.length === 0){
44
49
  continue;
45
50
  }
46
51
 
47
- // Least-recently-used: spread wear across benches (§5.4).
52
+ // Unreserved first, then least-recently-used: spread wear across benches (§5.4).
48
53
  candidates.sort((a, b) => {
49
- const ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
54
+ const ra = reservedResourceIds.has(a.id) ? 1 : 0,
55
+ rb = reservedResourceIds.has(b.id) ? 1 : 0,
56
+ ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
50
57
  tb = b.last_job_finished_at ? new Date(b.last_job_finished_at).getTime() : 0;
51
- return ta - tb;
58
+ return ra - rb || ta - tb;
52
59
  });
53
60
  const resource = candidates[0];
54
61
  claimedResourceIds.add(resource.id);
@@ -46,7 +46,8 @@ function createUpdatesService({ config }){
46
46
  state.latest = { ...state.latest, ...latest };
47
47
  state.checkedAt = new Date().toISOString();
48
48
  state.error = errors.length ? errors.join('; ') : null;
49
- return status();
49
+ // What this check itself got — `latest` keeps earlier results.
50
+ return { ...status(), fetched: Object.keys(latest) };
50
51
  })().finally(() => {
51
52
  inFlight = null;
52
53
  });
package/src/web/routes.js CHANGED
@@ -40,9 +40,11 @@ const AVATAR_MAX_BYTES = 2 * 1024 * 1024,
40
40
  function createWebRouter({ services, config }){
41
41
  const router = express.Router();
42
42
 
43
- function flash(req, type, text){
43
+ // Shown as a toast (layout.pug): `danger` stays until closed, `warning`
44
+ // closes after 30 s, `info`/`success` after 10 s — unless `sticky`.
45
+ function flash(req, type, text, { sticky = false } = {}){
44
46
  req.session.flash = req.session.flash || [];
45
- req.session.flash.push({ type, text });
47
+ req.session.flash.push({ type, text, ...(sticky ? { sticky } : {}) });
46
48
  }
47
49
 
48
50
  // Where a resource action (maintenance, rotate token) came from — the
@@ -107,7 +109,7 @@ function createWebRouter({ services, config }){
107
109
  const { username, password } = req.body,
108
110
  user = services.adminUsers.verify(username, password);
109
111
  if (!user){
110
- return res.status(401).render('login', { title: 'Sign in', error: 'Invalid credentials' });
112
+ return res.status(401).render('login', { title: 'Sign in', messages: [{ type: 'danger', text: 'Invalid credentials' }] });
111
113
  }
112
114
  req.session.user = user;
113
115
  req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
@@ -287,7 +289,8 @@ function createWebRouter({ services, config }){
287
289
  const token = generateToken('res');
288
290
  services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
289
291
  services.events.record('resource', req.params.id, 'resource.token_rotated', {});
290
- flash(req, 'warning', `New resource token (copy it now): ${token}`);
292
+ // Sticky: shown only once, so it mustn't close before it's copied.
293
+ flash(req, 'warning', `New resource token (copy it now): ${token}`, { sticky: true });
291
294
  res.redirect(returnTo(req, '/resources'));
292
295
  });
293
296
 
@@ -297,7 +300,10 @@ function createWebRouter({ services, config }){
297
300
  function updateAction(fn){
298
301
  return async (req, res) => {
299
302
  try {
300
- flash(req, 'info', await fn(req));
303
+ // `fn` returns the message, or { type, text } for another severity.
304
+ const result = await fn(req),
305
+ { type, text } = typeof result === 'string' ? { type: 'info', text: result } : result;
306
+ flash(req, type, text);
301
307
  }
302
308
  catch (err){
303
309
  flash(req, 'danger', err.message);
@@ -306,10 +312,45 @@ function createWebRouter({ services, config }){
306
312
  };
307
313
  }
308
314
 
315
+ // Check now (navbar, Agents and Resources pages): fetch the latest
316
+ // versions, then report whether the Coordinator and the connected
317
+ // (heartbeating) Clients are behind — as a warning if anything is.
309
318
  router.post('/updates/check', requireAdminRole, updateAction(async () => {
310
319
  const s = await services.updates.checkNow(),
311
- found = Object.entries(s.latest).map(([app, v]) => `${app} v${v}`).join(', ');
312
- return s.error ? `Version check: ${found || 'nothing found'} (errors: ${s.error})` : `Latest versions: ${found}.`;
320
+ parts = [];
321
+ if (!s.fetched.length){
322
+ return { type: 'danger', text: `Update check failed — the npm registry couldn't be reached (${s.error}). Nothing was checked.` };
323
+ }
324
+
325
+ parts.push(s.coordinatorUpdate
326
+ ? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available (Update app button).`
327
+ : `Coordinator is up to date (v${s.coordinatorVersion}).`);
328
+
329
+ const connected = services.registry.list().filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.last_heartbeat_at),
330
+ outdated = connected.filter((r) => r.client_version && isNewer(s.latest.client, r.client_version)),
331
+ unknown = connected.filter((r) => !r.client_version),
332
+ names = (list) => list.slice(0, 5).map((r) => `${r.name}${r.client_version ? ` (v${r.client_version})` : ''}`).join(', ') +
333
+ (list.length > 5 ? `, +${list.length - 5} more` : '');
334
+ if (!s.latest.client){
335
+ parts.push('Latest Client version unknown.');
336
+ }
337
+ else if (!connected.length){
338
+ parts.push('No connected resources.');
339
+ }
340
+ else if (outdated.length){
341
+ parts.push(`${outdated.length} of ${connected.length} connected resource(s) can update to Client v${s.latest.client}: ${names(outdated)}.`);
342
+ }
343
+ else {
344
+ parts.push(`All ${connected.length} connected resource(s) run the latest Client (v${s.latest.client}).`);
345
+ }
346
+ if (unknown.length){
347
+ parts.push(`Version unknown (Client too old to report it): ${names(unknown)}.`);
348
+ }
349
+ if (s.error){
350
+ parts.push(`Some checks failed: ${s.error}`);
351
+ }
352
+ const behind = s.coordinatorUpdate || outdated.length || unknown.length;
353
+ return { type: behind || s.error ? 'warning' : 'success', text: parts.join(' ') };
313
354
  }));
314
355
 
315
356
  router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
@@ -392,6 +433,7 @@ function createWebRouter({ services, config }){
392
433
  title: job.id,
393
434
  active: 'jobs',
394
435
  job,
436
+ pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
395
437
  artifacts,
396
438
  jobActive,
397
439
  canCancel: req.session.user.role === 'admin' && jobActive
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/test/scheduler.test.js
3
- * @description Tests: scheduler, registry, groups, job id allocation, and admin queue operations
3
+ * @description Tests: scheduler, registry, groups, client pinning, job id allocation, and admin queue operations
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -349,6 +349,76 @@ test('a job with target.group is rejected (422) when no resource is ever a membe
349
349
  );
350
350
  });
351
351
 
352
+ test('a job with target.client (by name) waits for that client even when another matching one is idle', () => {
353
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
354
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
355
+ pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
356
+ other = registerResource(registry, { name: 'lab-other', type: 'sw' });
357
+ registry.heartbeat(other.id, { state: 'idle' }); // pinned is still REGISTERED, not IDLE
358
+
359
+ const job = jobs.create({
360
+ agentId: agent.id,
361
+ source: 'cli',
362
+ spec: makeSpec({ target: { type: 'sw', labels: [], client: 'lab-pinned' } })
363
+ });
364
+ assert.equal(job.spec.target.client, pinned.id); // name resolved to the stable resource id
365
+
366
+ scheduler.runPass();
367
+ assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED); // not grabbed by lab-other
368
+
369
+ registry.heartbeat(pinned.id, { state: 'idle' });
370
+ scheduler.runPass();
371
+ const assigned = jobs.get(job.id);
372
+ assert.equal(assigned.state, JOB_STATES.ASSIGNED);
373
+ assert.equal(assigned.resource_id, pinned.id);
374
+ });
375
+
376
+ test('an unpinned job prefers a resource no queued job is pinned to', () => {
377
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
378
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
379
+ pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
380
+ other = registerResource(registry, { name: 'lab-other', type: 'sw' });
381
+ registry.heartbeat(pinned.id, { state: 'idle' });
382
+ registry.heartbeat(other.id, { state: 'idle' });
383
+
384
+ // Unpinned job has higher priority, so it's considered first; it must not
385
+ // take lab-pinned (which is also least-recently-used) away from the pinned job.
386
+ const unpinned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ priority: 90 }) }),
387
+ pinnedJob = jobs.create({
388
+ agentId: agent.id,
389
+ source: 'cli',
390
+ spec: makeSpec({ target: { type: 'sw', labels: [], client: pinned.id }, priority: 10 })
391
+ });
392
+ scheduler.runPass();
393
+
394
+ assert.equal(jobs.get(unpinned.id).resource_id, other.id);
395
+ assert.equal(jobs.get(pinnedJob.id).resource_id, pinned.id);
396
+ });
397
+
398
+ test('a job with target.client is rejected (422) when the client is unknown or cannot satisfy the target', () => {
399
+ const { registry, agents, jobs } = buildTestServices(),
400
+ { agent } = agents.create({ name: 'ci', kind: 'ci' });
401
+ registerResource(registry, { name: 'lab-sw', type: 'sw', labels: ['board:a'] });
402
+
403
+ assert.throws(
404
+ () => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: 'nope' } }) }),
405
+ /Unknown client "nope"/
406
+ );
407
+ assert.throws(
408
+ () => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'hw', client: 'lab-sw' } }) }),
409
+ /No registered resource/
410
+ );
411
+ assert.throws(
412
+ () =>
413
+ jobs.create({
414
+ agentId: agent.id,
415
+ source: 'cli',
416
+ spec: makeSpec({ target: { type: 'sw', labels: ['board:b'], client: 'lab-sw' } })
417
+ }),
418
+ /No registered resource/
419
+ );
420
+ });
421
+
352
422
  test('a resource can belong to several groups at once', () => {
353
423
  const { registry, groups } = buildTestServices(),
354
424
  g1 = groups.create({ name: 'g1' }),
@@ -22,6 +22,9 @@ block content
22
22
  dd.col-sm-10= job.resource ? job.resource.name : '—'
23
23
  dt.col-sm-2 Target
24
24
  dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
25
+ if job.spec.target.client
26
+ dt.col-sm-2 Client
27
+ dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
25
28
  dt.col-sm-2 Created
26
29
  dd.col-sm-10= fmtDate(job.created_at)
27
30
  if job.message
package/views/layout.pug CHANGED
@@ -37,6 +37,18 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
37
37
  li.nav-item
38
38
  a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
39
39
  ul.navbar-nav
40
+ if user && user.role === 'admin'
41
+ li.nav-item.d-flex.align-items-center.me-2
42
+ form(method="post" action="/updates/check" onsubmit="this.querySelector('button').disabled = true; this.querySelector('i').className = 'spinner-border spinner-border-sm';")
43
+ input(type="hidden" name="returnTo" value=currentPath)
44
+ button.btn.btn-sm.btn-outline-secondary(
45
+ type="submit"
46
+ aria-label="Check for updates"
47
+ data-bs-toggle="tooltip"
48
+ data-bs-placement="bottom"
49
+ data-bs-title=updates.checkedAt ? `Check for updates now — the Coordinator and connected resources (last checked ${fmtDate(updates.checkedAt)})` : 'Check for updates now — the Coordinator and connected resources'
50
+ )
51
+ i.bi.bi-arrow-repeat
40
52
  if user && user.role === 'admin' && updates.coordinatorUpdate
41
53
  li.nav-item.d-flex.align-items-center.me-3
42
54
  if updates.coordinatorPending
@@ -91,13 +103,37 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
91
103
  data-bs-title="Sign out of the dashboard"
92
104
  )
93
105
  i.bi.bi-box-arrow-right
94
- main.container.py-4
95
- if messages && messages.length
106
+ //- Flash messages as toasts (public/js/toasts.js): errors stay until
107
+ //- closed, warnings close after 30 s, info/success after 10 s. Rendered
108
+ //- visible (.show), so they're readable even if the script fails.
109
+ if messages && messages.length
110
+ .toast-container.position-fixed.end-0.p-3(style="top: 3.5rem; z-index: 1090;")
96
111
  each m in messages
97
- .alert(class=`alert-${m.type || 'info'}`)= m.text
112
+ - const type = ['danger', 'warning', 'info', 'success'].includes(m.type) ? m.type : 'info'
113
+ - const icon = { danger: 'x-octagon-fill', warning: 'exclamation-triangle-fill', info: 'info-circle-fill', success: 'check-circle-fill' }[type]
114
+ .toast.show.border-0(
115
+ class=`text-bg-${type}`
116
+ role=type === 'danger' ? 'alert' : 'status'
117
+ aria-live=type === 'danger' ? 'assertive' : 'polite'
118
+ aria-atomic="true"
119
+ data-thub-toast=type
120
+ data-sticky=m.sticky ? '1' : false
121
+ )
122
+ .d-flex
123
+ .toast-body.d-flex.gap-2
124
+ i.bi(class=`bi-${icon}`)
125
+ span= m.text
126
+ button.btn-close.me-2.m-auto(
127
+ type="button"
128
+ class=type === 'danger' || type === 'success' ? 'btn-close-white' : ''
129
+ data-bs-dismiss="toast"
130
+ aria-label="Close"
131
+ )
132
+ main.container.py-4
98
133
  block content
99
134
  script(src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js")
100
135
  script(src="/js/theme.js")
101
136
  script(src="/js/tooltips.js")
137
+ script(src="/js/toasts.js")
102
138
  script(src="/js/copy-to-clipboard.js")
103
139
  block scripts
package/views/login.pug CHANGED
@@ -4,8 +4,6 @@ block content
4
4
  .row.justify-content-center
5
5
  .col-md-4
6
6
  h1.h4.mb-3 Sign in
7
- if error
8
- .alert.alert-danger= error
9
7
  form(method="post" action="/login")
10
8
  .mb-3
11
9
  label.form-label(for="username") Username