@andrian.yablonskyy/thub-coordinator 1.0.8 → 1.0.9
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 +12 -7
- package/bin/thub-admin.js +46 -3
- package/package.json +1 -1
- package/public/js/resource-card.js +38 -0
- package/src/api/agent.js +8 -0
- package/src/api/resource.js +21 -5
- package/src/auth.js +9 -2
- package/src/config.js +11 -0
- package/src/db/migrations/008_add_remote_addr.sql +5 -0
- package/src/db/migrations/009_add_app_versions.sql +5 -0
- package/src/db/migrations/010_add_update_to.sql +5 -0
- package/src/server.js +9 -2
- package/src/services/agents.js +34 -5
- package/src/services/registry.js +92 -11
- package/src/services/updates.js +83 -0
- package/src/web/routes.js +70 -5
- package/views/admin/agents.pug +9 -2
- package/views/index.pug +9 -1
- package/views/layout.pug +10 -1
- package/views/mixins/resource.pug +93 -0
- package/views/mixins/updates.pug +67 -0
- package/views/resources/list.pug +16 -28
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ The Coordinator loads a plain **JSON** config file (no YAML support). Resolution
|
|
|
35
35
|
{
|
|
36
36
|
"listen": "127.0.0.1:8080",
|
|
37
37
|
"publicUrl": "https://thub.example.com",
|
|
38
|
+
"trustProxy": "loopback",
|
|
38
39
|
"dataDir": "/var/lib/thub",
|
|
39
40
|
"sessionSecret": "change-me-to-a-long-random-string",
|
|
40
41
|
"clientJoinKey": "change-me-to-a-long-random-string",
|
|
@@ -46,7 +47,7 @@ The Coordinator loads a plain **JSON** config file (no YAML support). Resolution
|
|
|
46
47
|
}
|
|
47
48
|
```
|
|
48
49
|
|
|
49
|
-
`sessionSecret` signs the dashboard's session cookie and the HMAC on artifact download links; `clientJoinKey` is the shared secret Clients self-register with — omit or leave `null` to disable auto-registration entirely. Individual `THUB_LISTEN` / `THUB_PUBLIC_URL` / `THUB_DATA_DIR` / `THUB_SESSION_SECRET` / `THUB_CLIENT_JOIN_KEY` env vars override whatever the file set.
|
|
50
|
+
`sessionSecret` signs the dashboard's session cookie and the HMAC on artifact download links; `clientJoinKey` is the shared secret Clients self-register with — omit or leave `null` to disable auto-registration entirely. `trustProxy` is Express's [`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) setting (default `loopback`): which reverse proxies' `X-Forwarded-For` to believe for a Client's external address on the resource card — e.g. `"10.0.0.0/8"` or `1` for a proxy on another host, `false` to always use the socket address. Individual `THUB_LISTEN` / `THUB_PUBLIC_URL` / `THUB_DATA_DIR` / `THUB_SESSION_SECRET` / `THUB_CLIENT_JOIN_KEY` env vars override whatever the file set.
|
|
50
51
|
|
|
51
52
|
`npm install -g` creates `~/.config/thub/coordinator.json` for you if it doesn't already exist, with all the options above, `dataDir` set to `~/var/lib/thub` and a freshly generated random `sessionSecret` (not the placeholder above), readable only by its owner — a re-install never overwrites it or regenerates the secret. Set `publicUrl` and `clientJoinKey` yourself before relying on auto-registration.
|
|
52
53
|
|
|
@@ -105,7 +106,7 @@ Both `jobs reset` and `jobs clean` refuse to run without `--yes` — there's no
|
|
|
105
106
|
|
|
106
107
|
## API
|
|
107
108
|
|
|
108
|
-
All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <token>` (the dashboard is a separate session-based auth path).
|
|
109
|
+
All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <token>` (the dashboard is a separate session-based auth path). The Agent and Client send `User-Agent: thub-agent/<version>` / `thub-client/<version>`, and the Coordinator records that version for the dashboard.
|
|
109
110
|
|
|
110
111
|
**Agent endpoints** (used by [thub-agent](https://github.com/andrianyablonskyy/thub-agent)):
|
|
111
112
|
|
|
@@ -124,8 +125,8 @@ All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <tok
|
|
|
124
125
|
|
|
125
126
|
| Method | Path | Description |
|
|
126
127
|
|---|---|---|
|
|
127
|
-
| `POST` | `/resources/register` | Self-register (every start/restart) using the shared `clientJoinKey`; upserts by `clientId`. |
|
|
128
|
-
| `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state; response may carry `commands[]`. |
|
|
128
|
+
| `POST` | `/resources/register` | Self-register (every start/restart) using the shared `clientJoinKey`; upserts by `clientId`. `hostInfo.addresses` (`[{iface, address, family}]`) lists the host's non-loopback interface addresses; the request's source address is stored as the external one. |
|
|
129
|
+
| `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state, plus the current `addresses`; refreshes the external address; response may carry `commands[]`. |
|
|
129
130
|
| `POST` | `/resources/:id/status` | Explicit status change, e.g. a local lock. |
|
|
130
131
|
| `GET` | `/resources/:id/jobs/next?wait=30` | Long-poll for an assigned job; `204` when nothing arrived within `wait` seconds. |
|
|
131
132
|
| `POST` | `/jobs/:id/accept` | Acknowledge assignment. |
|
|
@@ -142,14 +143,18 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
142
143
|
|
|
143
144
|
| Page | Content |
|
|
144
145
|
|---|---|
|
|
145
|
-
| `/` | Resource cards by status, queue length, jobs in the last 24 h. |
|
|
146
|
-
| `/resources` | Type, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. |
|
|
146
|
+
| `/` | Resource cards by status, queue length, jobs in the last 24 h. Click a card for its resource card (below). |
|
|
147
|
+
| `/resources` | Type, Client version, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. Click a row for its resource card (below). |
|
|
147
148
|
| `/groups` | Resource groups — id/name/comment, member count; admin create/rename/delete. Membership itself is set per-resource in the Client's own config. |
|
|
148
149
|
| `/jobs` | Filterable job list (state, source, resource), with the job's `user` label if set; admin **Reset queue** / **Clean history**. |
|
|
149
150
|
| `/jobs/:id` | Spec, timeline, live log viewer with stream filter, artifact downloads, cancel button. |
|
|
150
|
-
| `/admin/agents` | Register/revoke CI and developer agents; token shown once. |
|
|
151
|
+
| `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
|
|
151
152
|
| `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
|
|
152
153
|
|
|
154
|
+
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, Client version, IP addresses, 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.
|
|
155
|
+
|
|
156
|
+
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
157
|
+
|
|
153
158
|
## Data storage
|
|
154
159
|
|
|
155
160
|
SQLite via `better-sqlite3`, WAL mode. Schema migrations are plain numbered SQL files in `src/db/migrations/`, applied automatically at startup. `job_logs` rows are compacted into `artifacts/<jobId>/console.log` when a job finishes and deleted after `logRetentionDays`; artifacts are deleted after `artifactRetentionDays`. See the [main repo's ER diagram](https://github.com/andrianyablonskyy/thub#9-data-storage-sqlite) for the full schema.
|
package/bin/thub-admin.js
CHANGED
|
@@ -32,7 +32,10 @@ const { loadConfig } = require('../src/config'),
|
|
|
32
32
|
{ createAdminUsersService } = require('../src/services/admin-users'),
|
|
33
33
|
{ createArtifactsService } = require('../src/services/artifacts'),
|
|
34
34
|
{ createJobsService } = require('../src/services/jobs'),
|
|
35
|
-
{ generateToken } = require('../src/services/tokens')
|
|
35
|
+
{ generateToken } = require('../src/services/tokens'),
|
|
36
|
+
{ PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin } = require('@andrian.yablonskyy/thub-common'),
|
|
37
|
+
{ spawnSync } = require('node:child_process'),
|
|
38
|
+
{ version: installedVersion } = require('../package.json');
|
|
36
39
|
|
|
37
40
|
function usage(){
|
|
38
41
|
console.log(`Usage:
|
|
@@ -45,6 +48,9 @@ function usage(){
|
|
|
45
48
|
thub-admin group add <name> [--comment <text>]
|
|
46
49
|
thub-admin group list
|
|
47
50
|
thub-admin group remove <groupId>
|
|
51
|
+
thub-admin check-update Compare this Coordinator with the latest published version
|
|
52
|
+
thub-admin self-update [--to X.Y.Z]
|
|
53
|
+
Update this Coordinator (sudo npm i -g; restarts the service)
|
|
48
54
|
`);
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -79,9 +85,46 @@ function parseFlags(args){
|
|
|
79
85
|
return { positional, flags };
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
// The Coordinator is only ever updated by hand (README §10.2) — never
|
|
89
|
+
// from the dashboard. Neither command needs the database.
|
|
90
|
+
async function updateCommand(cmd, args){
|
|
91
|
+
const { flags } = parseFlags(args),
|
|
92
|
+
latest = await fetchLatestVersion(PACKAGES.coordinator);
|
|
93
|
+
if (cmd === 'check-update'){
|
|
94
|
+
console.log(`Installed: v${installedVersion} Latest: v${latest}`);
|
|
95
|
+
console.log(isNewer(latest, installedVersion) ? 'Update available: thub-admin self-update' : 'Up to date.');
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
const target = flags.to || latest;
|
|
99
|
+
if (!isValidVersion(target)){
|
|
100
|
+
console.error(`Invalid version "${target}"`);
|
|
101
|
+
return 4;
|
|
102
|
+
}
|
|
103
|
+
if (!flags.to && !isNewer(target, installedVersion)){
|
|
104
|
+
console.log(`Already on v${installedVersion} (latest v${latest}).`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
// Root, via sudo, so the postinstall re-renders and restarts the
|
|
108
|
+
// thub-coordinator service for the right user (SUDO_USER).
|
|
109
|
+
const npmArgs = ['i', '-g', `${PACKAGES.coordinator}@${target}`],
|
|
110
|
+
isRoot = process.getuid?.() === 0,
|
|
111
|
+
[bin, argv] = isRoot ? [npmBin(), npmArgs] : ['sudo', [npmBin(), ...npmArgs]];
|
|
112
|
+
console.log(`Updating Coordinator v${installedVersion} -> v${target}: ${[bin, ...argv].join(' ')}`);
|
|
113
|
+
return spawnSync(bin, argv, { stdio: 'inherit' }).status ?? 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
function main(){
|
|
83
|
-
const [, , cmd, sub, ...rest] = process.argv
|
|
84
|
-
|
|
117
|
+
const [, , cmd, sub, ...rest] = process.argv;
|
|
118
|
+
if (cmd === 'check-update' || cmd === 'self-update'){
|
|
119
|
+
return updateCommand(cmd, [sub, ...rest].filter(Boolean))
|
|
120
|
+
.then((code) => process.exit(code))
|
|
121
|
+
.catch((err) => {
|
|
122
|
+
console.error(`Error: ${err.message}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const config = loadConfig(),
|
|
85
128
|
db = openDb(config.dbPath),
|
|
86
129
|
events = createEventsService(db),
|
|
87
130
|
registry = createRegistryService(db, { bus, events }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/resource-card.js
|
|
3
|
+
* @description Dashboard: opens a resource's info card (modal) when its row/card is clicked or activated by keyboard
|
|
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
|
+
function open(el){
|
|
18
|
+
const modal = document.getElementById(el.dataset.resourceCard);
|
|
19
|
+
if (modal){
|
|
20
|
+
bootstrap.Modal.getOrCreateInstance(modal).show();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
document.querySelectorAll('[data-resource-card]').forEach((el) => {
|
|
25
|
+
el.addEventListener('click', (e) => {
|
|
26
|
+
// The Resources table's own action buttons stay plain actions.
|
|
27
|
+
if (!e.target.closest('form, button, a, input')){
|
|
28
|
+
open(el);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
el.addEventListener('keydown', (e) => {
|
|
32
|
+
if ((e.key === 'Enter' || e.key === ' ') && e.target === el){
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
open(el);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
})();
|
package/src/api/agent.js
CHANGED
|
@@ -92,6 +92,14 @@ function createAgentRouter({ services, config }){
|
|
|
92
92
|
res.json({ resources: services.registry.list().map(publicResource) });
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
// Checked by the Agent at the start of every run (README §10.2): the
|
|
96
|
+
// version an admin asked it to self-update to, if still newer than the
|
|
97
|
+
// one it runs (auth has already recorded that from its User-Agent).
|
|
98
|
+
router.get('/agents/me/update', auth, (req, res) => {
|
|
99
|
+
const agent = services.agents.get(req.agent.id);
|
|
100
|
+
res.json({ updateTo: agent.update_to || null, latest: services.updates.status().latest.agent || null });
|
|
101
|
+
});
|
|
102
|
+
|
|
95
103
|
return router;
|
|
96
104
|
}
|
|
97
105
|
|
package/src/api/resource.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
const express = require('express'),
|
|
17
17
|
multer = require('multer'),
|
|
18
|
-
{ requireRole, requireJoinKey } = require('../auth'),
|
|
18
|
+
{ requireRole, requireJoinKey, appVersion } = require('../auth'),
|
|
19
19
|
{ JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
20
20
|
|
|
21
21
|
const upload = multer({ dest: require('node:os').tmpdir() });
|
|
@@ -64,7 +64,9 @@ function createResourceRouter({ services, config }){
|
|
|
64
64
|
type,
|
|
65
65
|
labels: labels || capabilities?.labels || [],
|
|
66
66
|
groups: groups || [],
|
|
67
|
-
hostInfo
|
|
67
|
+
hostInfo,
|
|
68
|
+
remoteAddr: req.ip,
|
|
69
|
+
clientVersion: appVersion(req, 'client')
|
|
68
70
|
});
|
|
69
71
|
res.json({ resourceId, resourceToken, heartbeatIntervalSec: config.heartbeat.intervalSec });
|
|
70
72
|
}
|
|
@@ -81,9 +83,23 @@ function createResourceRouter({ services, config }){
|
|
|
81
83
|
|
|
82
84
|
router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
|
|
83
85
|
try {
|
|
84
|
-
const { state, activeJobId, localLock, metrics } = req.body;
|
|
85
|
-
services.registry.heartbeat(req.params.id, {
|
|
86
|
-
|
|
86
|
+
const { state, activeJobId, localLock, metrics, addresses } = req.body;
|
|
87
|
+
services.registry.heartbeat(req.params.id, {
|
|
88
|
+
state,
|
|
89
|
+
activeJobId,
|
|
90
|
+
localLock,
|
|
91
|
+
metrics,
|
|
92
|
+
addresses,
|
|
93
|
+
remoteAddr: req.ip,
|
|
94
|
+
clientVersion: appVersion(req, 'client')
|
|
95
|
+
});
|
|
96
|
+
const commands = services.commands.drain(req.params.id),
|
|
97
|
+
// Repeated on every heartbeat until the Client reports the new
|
|
98
|
+
// version (README §10.2) — the Client ignores repeats itself.
|
|
99
|
+
updateTo = services.registry.pendingUpdate(req.params.id);
|
|
100
|
+
if (updateTo){
|
|
101
|
+
commands.push({ command: 'self-update', version: updateTo });
|
|
102
|
+
}
|
|
87
103
|
res.json({ serverTime: new Date().toISOString(), commands });
|
|
88
104
|
}
|
|
89
105
|
catch (err){
|
package/src/auth.js
CHANGED
|
@@ -17,6 +17,13 @@ const crypto = require('node:crypto'),
|
|
|
17
17
|
{ hashToken } = require('./services/tokens');
|
|
18
18
|
|
|
19
19
|
// §3.1 / §12: bearer tokens are hashed with SHA-256 and carry a role
|
|
20
|
+
// App version from a "thub-<app>/<version>" User-Agent (shared ApiClient),
|
|
21
|
+
// or null for anything else (curl, an older Agent/Client).
|
|
22
|
+
function appVersion(req, app){
|
|
23
|
+
const m = new RegExp(`^thub-${app}/([0-9A-Za-z.+-]{1,32})(?:\\s|$)`).exec(req.get('user-agent') || '');
|
|
24
|
+
return m ? m[1] : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
// (agent | resource); admin is authenticated via the dashboard session
|
|
21
28
|
// instead of an API token (§10).
|
|
22
29
|
function requireRole(role){
|
|
@@ -33,7 +40,7 @@ function requireRole(role){
|
|
|
33
40
|
if (!agent){
|
|
34
41
|
return res.status(401).json({ error: 'Invalid or revoked agent token' });
|
|
35
42
|
}
|
|
36
|
-
req.app.locals.services.agents.touchLastUsed(agent.id);
|
|
43
|
+
req.app.locals.services.agents.touchLastUsed(agent.id, appVersion(req, 'agent'));
|
|
37
44
|
req.agent = agent;
|
|
38
45
|
return next();
|
|
39
46
|
}
|
|
@@ -91,4 +98,4 @@ function requireAdminRole(req, res, next){
|
|
|
91
98
|
next();
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
module.exports = { requireRole, requireJoinKey, requireAdminSession, requireAdminRole };
|
|
101
|
+
module.exports = { requireRole, requireJoinKey, requireAdminSession, requireAdminRole, appVersion };
|
package/src/config.js
CHANGED
|
@@ -21,6 +21,10 @@ const fs = require('node:fs'),
|
|
|
21
21
|
const DEFAULTS = {
|
|
22
22
|
listen: '127.0.0.1:8080',
|
|
23
23
|
publicUrl: 'http://localhost:8080',
|
|
24
|
+
// Express `trust proxy`: which proxies' X-Forwarded-For to believe for
|
|
25
|
+
// a Client's external address (resource card). 'loopback' fits the
|
|
26
|
+
// default setup — listening on 127.0.0.1 behind a local reverse proxy.
|
|
27
|
+
trustProxy: 'loopback',
|
|
24
28
|
dataDir: path.join(process.cwd(), '.data'),
|
|
25
29
|
sessionSecret: 'dev-only-change-me',
|
|
26
30
|
// Shared secret Clients present to self-register (see api/resource.js).
|
|
@@ -48,6 +52,13 @@ const DEFAULTS = {
|
|
|
48
52
|
artifacts: {
|
|
49
53
|
maxUploadMb: 512,
|
|
50
54
|
linkTtlHours: 168
|
|
55
|
+
},
|
|
56
|
+
// New-version check of the Coordinator/Agent/Client packages (README
|
|
57
|
+
// §10.2). 0 disables the periodic check; registry null = the public npm
|
|
58
|
+
// registry (or npm_config_registry).
|
|
59
|
+
updates: {
|
|
60
|
+
checkIntervalHours: 6,
|
|
61
|
+
registry: null
|
|
51
62
|
}
|
|
52
63
|
};
|
|
53
64
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Address the Coordinator last saw this resource's Client connect from
|
|
2
|
+
-- (register or heartbeat) — its external IP from the Coordinator's point
|
|
3
|
+
-- of view, shown on the dashboard's resource card next to the Client's own
|
|
4
|
+
-- interface addresses (host_info.addresses).
|
|
5
|
+
ALTER TABLE resources ADD COLUMN remote_addr TEXT;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- App version each Agent / Client last reported (User-Agent header
|
|
2
|
+
-- "thub-agent/<version>" / "thub-client/<version>"), shown on the
|
|
3
|
+
-- dashboard's Agents page and resource card (README §10).
|
|
4
|
+
ALTER TABLE agents ADD COLUMN version TEXT;
|
|
5
|
+
ALTER TABLE resources ADD COLUMN client_version TEXT;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Self-update requests (README §10.2): the version an admin asked this
|
|
2
|
+
-- Agent / Client to update to, cleared once it reports that version (or
|
|
3
|
+
-- newer). NULL = no update pending.
|
|
4
|
+
ALTER TABLE agents ADD COLUMN update_to TEXT;
|
|
5
|
+
ALTER TABLE resources ADD COLUMN update_to TEXT;
|
package/src/server.js
CHANGED
|
@@ -34,6 +34,7 @@ const path = require('node:path'),
|
|
|
34
34
|
{ createArtifactsService } = require('./services/artifacts'),
|
|
35
35
|
{ createCommandsService } = require('./services/commands'),
|
|
36
36
|
{ createRetentionService } = require('./services/retention'),
|
|
37
|
+
{ createUpdatesService } = require('./services/updates'),
|
|
37
38
|
|
|
38
39
|
{ createAgentRouter } = require('./api/agent'),
|
|
39
40
|
{ createResourceRouter } = require('./api/resource'),
|
|
@@ -53,9 +54,11 @@ function buildServices(config){
|
|
|
53
54
|
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
54
55
|
scheduler = createScheduler(db, { bus, events, registry, config }),
|
|
55
56
|
heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
|
|
56
|
-
retention = createRetentionService(db, { bus, logs, artifacts, config })
|
|
57
|
+
retention = createRetentionService(db, { bus, logs, artifacts, config }),
|
|
58
|
+
updates = createUpdatesService({ config });
|
|
57
59
|
|
|
58
60
|
jobs.reconcileOnStartup();
|
|
61
|
+
updates.start();
|
|
59
62
|
|
|
60
63
|
const sweepInterval = setInterval(() => {
|
|
61
64
|
jobs.checkAssignAcks();
|
|
@@ -77,7 +80,8 @@ function buildServices(config){
|
|
|
77
80
|
artifacts,
|
|
78
81
|
scheduler,
|
|
79
82
|
heartbeatMonitor,
|
|
80
|
-
retention
|
|
83
|
+
retention,
|
|
84
|
+
updates
|
|
81
85
|
};
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -85,7 +89,10 @@ function createApp(config, services){
|
|
|
85
89
|
const app = express();
|
|
86
90
|
app.set('view engine', 'pug');
|
|
87
91
|
app.set('views', path.join(__dirname, '..', 'views'));
|
|
92
|
+
app.set('trust proxy', config.trustProxy);
|
|
88
93
|
app.locals.services = services;
|
|
94
|
+
// Shown in every dashboard page's footer (layout.pug).
|
|
95
|
+
app.locals.coordinatorVersion = require('../package.json').version;
|
|
89
96
|
|
|
90
97
|
app.use(express.json({ limit: '2mb' }));
|
|
91
98
|
app.use(express.urlencoded({ extended: true }));
|
package/src/services/agents.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const {
|
|
16
|
+
const { compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ v4: uuid } = require('uuid'),
|
|
17
18
|
{ generateToken, hashToken } = require('./tokens');
|
|
18
19
|
|
|
19
20
|
function createAgentsService(db, { events }){
|
|
@@ -28,7 +29,7 @@ function createAgentsService(db, { events }){
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function list(){
|
|
31
|
-
return db.prepare('SELECT id, name, kind, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
|
|
32
|
+
return db.prepare('SELECT id, name, kind, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function create({ name, kind }){
|
|
@@ -46,11 +47,39 @@ function createAgentsService(db, { events }){
|
|
|
46
47
|
events.record('agent', id, 'agent.revoked', {});
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
// `version` only when the Agent reported one — keeps the last known one
|
|
51
|
+
// otherwise. Reporting the requested update's version (or newer) is what
|
|
52
|
+
// completes a self-update request.
|
|
53
|
+
function touchLastUsed(id, version = null){
|
|
54
|
+
db.prepare('UPDATE agents SET last_used_at = ?, version = COALESCE(?, version) WHERE id = ?').run(new Date().toISOString(), version, id);
|
|
55
|
+
const agent = get(id);
|
|
56
|
+
if (agent?.update_to && agent.version && compareVersions(agent.version, agent.update_to) >= 0){
|
|
57
|
+
db.prepare('UPDATE agents SET update_to = NULL WHERE id = ?').run(id);
|
|
58
|
+
events.record('agent', id, 'agent.updated', { version: agent.version });
|
|
59
|
+
}
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
// Self-update request (README §10.2): the Agent picks it up on its next
|
|
63
|
+
// run. `version` null cancels a pending one.
|
|
64
|
+
function setUpdateTo(id, version){
|
|
65
|
+
const agent = get(id);
|
|
66
|
+
if (!agent || agent.revoked_at){
|
|
67
|
+
throw Object.assign(new Error('Unknown or revoked agent'), { status: 404 });
|
|
68
|
+
}
|
|
69
|
+
db.prepare('UPDATE agents SET update_to = ? WHERE id = ?').run(version, id);
|
|
70
|
+
events.record('agent', id, version ? 'agent.update_requested' : 'agent.update_canceled', { version });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every active agent not already on `version` or newer.
|
|
74
|
+
function requestUpdateAll(version){
|
|
75
|
+
const ids = db.prepare('SELECT id, version FROM agents WHERE revoked_at IS NULL').all()
|
|
76
|
+
.filter((a) => !a.version || compareVersions(a.version, version) < 0)
|
|
77
|
+
.map((a) => a.id);
|
|
78
|
+
ids.forEach((id) => setUpdateTo(id, version));
|
|
79
|
+
return ids.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { get, getByTokenHash, list, create, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
module.exports = { createAgentsService };
|
package/src/services/registry.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { v4: uuid } = require('uuid'),
|
|
17
|
-
{ RESOURCE_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ RESOURCE_STATES, BUSY_SOURCES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
18
18
|
{ generateToken, hashToken } = require('./tokens');
|
|
19
19
|
|
|
20
20
|
function rowToResource(row){
|
|
@@ -29,6 +29,28 @@ function rowToResource(row){
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// host_info.addresses comes straight from the Client, so keep only
|
|
33
|
+
// well-formed { iface, address, family } entries and cap the list.
|
|
34
|
+
const MAX_ADDRESSES = 64;
|
|
35
|
+
function sanitizeAddresses(addresses){
|
|
36
|
+
if (!Array.isArray(addresses)){
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return addresses
|
|
40
|
+
.filter((a) => a && typeof a.address === 'string' && /^[0-9A-Fa-f:.%\w-]{2,64}$/.test(a.address))
|
|
41
|
+
.slice(0, MAX_ADDRESSES)
|
|
42
|
+
.map((a) => ({
|
|
43
|
+
iface: typeof a.iface === 'string' ? a.iface.slice(0, 64) : '',
|
|
44
|
+
address: a.address,
|
|
45
|
+
family: a.family === 'IPv6' ? 'IPv6' : 'IPv4'
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Express reports IPv4 clients of a dual-stack socket as ::ffff:a.b.c.d.
|
|
50
|
+
function normalizeRemoteAddr(addr){
|
|
51
|
+
return typeof addr === 'string' && addr ? addr.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/, '$1') : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
function createRegistryService(db, { bus, events }){
|
|
33
55
|
function get(id){
|
|
34
56
|
return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
|
|
@@ -84,8 +106,10 @@ function createRegistryService(db, { bus, events }){
|
|
|
84
106
|
// this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
|
|
85
107
|
// MAINTENANCE, REGISTERED-but-just-created) means a process might still
|
|
86
108
|
// be actively using that identity, so it stays a hard conflict.
|
|
87
|
-
function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo }){
|
|
88
|
-
const resourceToken = generateToken('res')
|
|
109
|
+
function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, remoteAddr, clientVersion = null }){
|
|
110
|
+
const resourceToken = generateToken('res'),
|
|
111
|
+
remote = normalizeRemoteAddr(remoteAddr);
|
|
112
|
+
hostInfo = { ...(hostInfo || {}), addresses: sanitizeAddresses(hostInfo?.addresses) || [] };
|
|
89
113
|
let existing = getByClientId(clientId);
|
|
90
114
|
|
|
91
115
|
if (!existing){
|
|
@@ -133,7 +157,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
133
157
|
db.prepare(
|
|
134
158
|
`UPDATE resources
|
|
135
159
|
SET token_hash = ?, client_id = ?, name = ?, type = ?, labels = ?, group_ids = ?, host_info = ?,
|
|
136
|
-
status = ?, busy_source = NULL, busy_reason = NULL
|
|
160
|
+
remote_addr = ?, client_version = ?, status = ?, busy_source = NULL, busy_reason = NULL
|
|
137
161
|
WHERE id = ?`
|
|
138
162
|
).run(
|
|
139
163
|
hashToken(resourceToken),
|
|
@@ -142,11 +166,14 @@ function createRegistryService(db, { bus, events }){
|
|
|
142
166
|
type,
|
|
143
167
|
JSON.stringify(labels),
|
|
144
168
|
JSON.stringify(groups),
|
|
145
|
-
JSON.stringify(hostInfo
|
|
169
|
+
JSON.stringify(hostInfo),
|
|
170
|
+
remote,
|
|
171
|
+
clientVersion,
|
|
146
172
|
nextStatus,
|
|
147
173
|
existing.id
|
|
148
174
|
);
|
|
149
175
|
|
|
176
|
+
completeUpdateIfDone(existing.id);
|
|
150
177
|
events.record('resource', existing.id, 'resource.registered', {
|
|
151
178
|
hostInfo,
|
|
152
179
|
name,
|
|
@@ -160,8 +187,8 @@ function createRegistryService(db, { bus, events }){
|
|
|
160
187
|
|
|
161
188
|
const id = `res_${uuid()}`;
|
|
162
189
|
db.prepare(
|
|
163
|
-
`INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, token_hash, client_id, created_at)
|
|
164
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
190
|
+
`INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, remote_addr, client_version, token_hash, client_id, created_at)
|
|
191
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
165
192
|
).run(
|
|
166
193
|
id,
|
|
167
194
|
name,
|
|
@@ -169,7 +196,9 @@ function createRegistryService(db, { bus, events }){
|
|
|
169
196
|
RESOURCE_STATES.REGISTERED,
|
|
170
197
|
JSON.stringify(labels),
|
|
171
198
|
JSON.stringify(groups),
|
|
172
|
-
JSON.stringify(hostInfo
|
|
199
|
+
JSON.stringify(hostInfo),
|
|
200
|
+
remote,
|
|
201
|
+
clientVersion,
|
|
173
202
|
hashToken(resourceToken),
|
|
174
203
|
clientId,
|
|
175
204
|
new Date().toISOString()
|
|
@@ -185,7 +214,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
185
214
|
}
|
|
186
215
|
|
|
187
216
|
// Reconcile status from a heartbeat's self-reported state (§5.1).
|
|
188
|
-
function heartbeat(resourceId, { state, activeJobId, localLock, metrics } = {}){
|
|
217
|
+
function heartbeat(resourceId, { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null } = {}){
|
|
189
218
|
const resource = get(resourceId);
|
|
190
219
|
if (!resource){
|
|
191
220
|
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
@@ -217,11 +246,26 @@ function createRegistryService(db, { bus, events }){
|
|
|
217
246
|
busyReason = null;
|
|
218
247
|
}
|
|
219
248
|
|
|
249
|
+
// Older Clients don't send addresses — keep whatever registration stored.
|
|
250
|
+
const cleanAddresses = sanitizeAddresses(addresses),
|
|
251
|
+
hostInfo = cleanAddresses ? { ...(resource.host_info || {}), addresses: cleanAddresses } : resource.host_info;
|
|
252
|
+
|
|
220
253
|
db.prepare(
|
|
221
254
|
`UPDATE resources
|
|
222
|
-
SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason =
|
|
255
|
+
SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason = ?, host_info = ?,
|
|
256
|
+
remote_addr = COALESCE(?, remote_addr), client_version = COALESCE(?, client_version)
|
|
223
257
|
WHERE id = ?`
|
|
224
|
-
).run(
|
|
258
|
+
).run(
|
|
259
|
+
new Date().toISOString(),
|
|
260
|
+
nextStatus,
|
|
261
|
+
busySource,
|
|
262
|
+
busyReason,
|
|
263
|
+
hostInfo ? JSON.stringify(hostInfo) : null,
|
|
264
|
+
normalizeRemoteAddr(remoteAddr),
|
|
265
|
+
clientVersion,
|
|
266
|
+
resourceId
|
|
267
|
+
);
|
|
268
|
+
completeUpdateIfDone(resourceId);
|
|
225
269
|
|
|
226
270
|
if (resource.status !== nextStatus){
|
|
227
271
|
events.record('resource', resourceId, 'resource.status_changed', {
|
|
@@ -285,6 +329,40 @@ function createRegistryService(db, { bus, events }){
|
|
|
285
329
|
bus.emit('resource.idle', { resourceId });
|
|
286
330
|
}
|
|
287
331
|
|
|
332
|
+
// Self-update requests (README §10.2): delivered as a `self-update`
|
|
333
|
+
// command on every heartbeat until the Client reports `update_to` (or
|
|
334
|
+
// newer). `version` null cancels a pending one.
|
|
335
|
+
function setUpdateTo(resourceId, version){
|
|
336
|
+
if (!get(resourceId)){
|
|
337
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
338
|
+
}
|
|
339
|
+
db.prepare('UPDATE resources SET update_to = ? WHERE id = ?').run(version, resourceId);
|
|
340
|
+
events.record('resource', resourceId, version ? 'resource.update_requested' : 'resource.update_canceled', { version });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Every resource not already on `version` or newer.
|
|
344
|
+
function requestUpdateAll(version){
|
|
345
|
+
const ids = list()
|
|
346
|
+
.filter((r) => !r.client_version || compareVersions(r.client_version, version) < 0)
|
|
347
|
+
.map((r) => r.id);
|
|
348
|
+
ids.forEach((id) => setUpdateTo(id, version));
|
|
349
|
+
return ids.length;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function completeUpdateIfDone(resourceId){
|
|
353
|
+
const r = get(resourceId);
|
|
354
|
+
if (r?.update_to && r.client_version && compareVersions(r.client_version, r.update_to) >= 0){
|
|
355
|
+
db.prepare('UPDATE resources SET update_to = NULL WHERE id = ?').run(resourceId);
|
|
356
|
+
events.record('resource', resourceId, 'resource.updated', { version: r.client_version });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// The version a heartbeating Client should update to, if any.
|
|
361
|
+
function pendingUpdate(resourceId){
|
|
362
|
+
const r = get(resourceId);
|
|
363
|
+
return r?.update_to && (!r.client_version || compareVersions(r.client_version, r.update_to) < 0) ? r.update_to : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
288
366
|
function setMaintenance(resourceId, enabled){
|
|
289
367
|
const resource = get(resourceId);
|
|
290
368
|
if (!resource){
|
|
@@ -338,6 +416,9 @@ function createRegistryService(db, { bus, events }){
|
|
|
338
416
|
markOutOfService,
|
|
339
417
|
markIdleAfterJob,
|
|
340
418
|
setMaintenance,
|
|
419
|
+
setUpdateTo,
|
|
420
|
+
requestUpdateAll,
|
|
421
|
+
pendingUpdate,
|
|
341
422
|
assignToJob,
|
|
342
423
|
findIdleCandidates,
|
|
343
424
|
everSatisfiable
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/updates.js
|
|
3
|
+
* @description Periodic new-version check of the Coordinator, Agent and Client npm packages (README §10.2)
|
|
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 { PACKAGES, fetchLatestVersion, defaultRegistry, isNewer } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ version: coordinatorVersion } = require('../../package.json');
|
|
18
|
+
|
|
19
|
+
// Latest published versions, refreshed every `updates.checkIntervalHours`
|
|
20
|
+
// and on demand from the dashboard. Kept in memory only — a restart just
|
|
21
|
+
// checks again.
|
|
22
|
+
function createUpdatesService({ config }){
|
|
23
|
+
const registry = config.updates.registry || defaultRegistry(),
|
|
24
|
+
state = { latest: {}, checkedAt: null, error: null };
|
|
25
|
+
let inFlight = null;
|
|
26
|
+
|
|
27
|
+
async function checkNow(){
|
|
28
|
+
// Concurrent callers (timer + a dashboard click) share one check.
|
|
29
|
+
inFlight = inFlight || (async () => {
|
|
30
|
+
const latest = {},
|
|
31
|
+
errors = [];
|
|
32
|
+
await Promise.all(Object.entries(PACKAGES).map(async ([app, pkg]) => {
|
|
33
|
+
try {
|
|
34
|
+
latest[app] = await fetchLatestVersion(pkg, { registry });
|
|
35
|
+
}
|
|
36
|
+
catch (err){
|
|
37
|
+
errors.push(err.message);
|
|
38
|
+
}
|
|
39
|
+
}));
|
|
40
|
+
state.latest = { ...state.latest, ...latest };
|
|
41
|
+
state.checkedAt = new Date().toISOString();
|
|
42
|
+
state.error = errors.length ? errors.join('; ') : null;
|
|
43
|
+
return status();
|
|
44
|
+
})().finally(() => {
|
|
45
|
+
inFlight = null;
|
|
46
|
+
});
|
|
47
|
+
return inFlight;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function status(){
|
|
51
|
+
return {
|
|
52
|
+
...state,
|
|
53
|
+
coordinatorVersion,
|
|
54
|
+
coordinatorUpdate: isNewer(state.latest.coordinator, coordinatorVersion) ? state.latest.coordinator : null
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The version a self-update request targets: the latest known one,
|
|
59
|
+
// checking first if nothing has been fetched yet.
|
|
60
|
+
async function targetVersion(app){
|
|
61
|
+
if (!state.latest[app]){
|
|
62
|
+
await checkNow();
|
|
63
|
+
}
|
|
64
|
+
if (!state.latest[app]){
|
|
65
|
+
throw Object.assign(new Error(`Latest ${app} version unknown (${state.error || 'registry unreachable'})`), { status: 503 });
|
|
66
|
+
}
|
|
67
|
+
return state.latest[app];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function start(){
|
|
71
|
+
const hours = Number(config.updates.checkIntervalHours);
|
|
72
|
+
if (!(hours > 0)){
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
checkNow().catch(() => {});
|
|
76
|
+
const timer = setInterval(() => checkNow().catch(() => {}), hours * 3600 * 1000);
|
|
77
|
+
timer.unref?.();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { checkNow, status, targetVersion, start };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { createUpdatesService };
|
package/src/web/routes.js
CHANGED
|
@@ -20,7 +20,7 @@ const fs = require('node:fs'),
|
|
|
20
20
|
multer = require('multer'),
|
|
21
21
|
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
22
22
|
{ attachJobStream } = require('../api/sse'),
|
|
23
|
-
{ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
23
|
+
{ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer } = require('@andrian.yablonskyy/thub-common');
|
|
24
24
|
|
|
25
25
|
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
26
26
|
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
@@ -45,10 +45,21 @@ function createWebRouter({ services, config }){
|
|
|
45
45
|
req.session.flash.push({ type, text });
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Where a resource action (maintenance, rotate token) came from — the
|
|
49
|
+
// Resources page or the Overview's resource card. Only a local path,
|
|
50
|
+
// never another site.
|
|
51
|
+
function returnTo(req, fallback){
|
|
52
|
+
const target = req.body.returnTo;
|
|
53
|
+
return typeof target === 'string' && /^\/(?![\/\\])/.test(target) ? target : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
router.use((req, res, next) => {
|
|
49
57
|
const user = req.session?.user || null;
|
|
50
58
|
res.locals.user = user;
|
|
51
59
|
res.locals.messages = req.session?.flash || [];
|
|
60
|
+
// Latest published versions (README §10.2) — navbar, Agents, Resources.
|
|
61
|
+
res.locals.updates = services.updates.status();
|
|
62
|
+
res.locals.isNewer = isNewer;
|
|
52
63
|
if (req.session){
|
|
53
64
|
req.session.flash = [];
|
|
54
65
|
}
|
|
@@ -198,8 +209,9 @@ function createWebRouter({ services, config }){
|
|
|
198
209
|
jobsLast24h = services.jobs
|
|
199
210
|
.list({ limit: 1000 })
|
|
200
211
|
.filter((j) => j.created_at >= dayAgo).length,
|
|
201
|
-
onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length
|
|
202
|
-
|
|
212
|
+
onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length,
|
|
213
|
+
groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
|
|
214
|
+
res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount, groupsById });
|
|
203
215
|
});
|
|
204
216
|
|
|
205
217
|
router.get('/resources', (req, res) => {
|
|
@@ -214,7 +226,7 @@ function createWebRouter({ services, config }){
|
|
|
214
226
|
|
|
215
227
|
router.post('/resources/:id/maintenance', requireAdminRole, (req, res) => {
|
|
216
228
|
services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
|
|
217
|
-
res.redirect('/resources');
|
|
229
|
+
res.redirect(returnTo(req, '/resources'));
|
|
218
230
|
});
|
|
219
231
|
|
|
220
232
|
router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
@@ -223,9 +235,62 @@ function createWebRouter({ services, config }){
|
|
|
223
235
|
services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
|
|
224
236
|
services.events.record('resource', req.params.id, 'resource.token_rotated', {});
|
|
225
237
|
flash(req, 'warning', `New resource token (copy it now): ${token}`);
|
|
226
|
-
res.redirect('/resources');
|
|
238
|
+
res.redirect(returnTo(req, '/resources'));
|
|
227
239
|
});
|
|
228
240
|
|
|
241
|
+
// Self-update (README §10.2). Clients get a `self-update` heartbeat
|
|
242
|
+
// command, Agents update on their next run; both target the latest
|
|
243
|
+
// published version. The Coordinator itself is only ever updated by hand.
|
|
244
|
+
function updateAction(fn){
|
|
245
|
+
return async (req, res) => {
|
|
246
|
+
try {
|
|
247
|
+
flash(req, 'info', await fn(req));
|
|
248
|
+
}
|
|
249
|
+
catch (err){
|
|
250
|
+
flash(req, 'danger', err.message);
|
|
251
|
+
}
|
|
252
|
+
res.redirect(returnTo(req, '/'));
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
router.post('/updates/check', requireAdminRole, updateAction(async () => {
|
|
257
|
+
const s = await services.updates.checkNow(),
|
|
258
|
+
found = Object.entries(s.latest).map(([app, v]) => `${app} v${v}`).join(', ');
|
|
259
|
+
return s.error ? `Version check: ${found || 'nothing found'} (errors: ${s.error})` : `Latest versions: ${found}.`;
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
router.post('/resources/update-all', requireAdminRole, updateAction(async () => {
|
|
263
|
+
const version = await services.updates.targetVersion('client'),
|
|
264
|
+
n = services.registry.requestUpdateAll(version);
|
|
265
|
+
return `Requested update to client v${version} on ${n} resource(s).`;
|
|
266
|
+
}));
|
|
267
|
+
|
|
268
|
+
router.post('/resources/:id/update', requireAdminRole, updateAction(async (req) => {
|
|
269
|
+
if (req.body.cancel === '1'){
|
|
270
|
+
services.registry.setUpdateTo(req.params.id, null);
|
|
271
|
+
return 'Update request canceled.';
|
|
272
|
+
}
|
|
273
|
+
const version = await services.updates.targetVersion('client');
|
|
274
|
+
services.registry.setUpdateTo(req.params.id, version);
|
|
275
|
+
return `Requested update to client v${version} — it starts on the Client's next heartbeat.`;
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
router.post('/admin/agents/update-all', requireAdminRole, updateAction(async () => {
|
|
279
|
+
const version = await services.updates.targetVersion('agent'),
|
|
280
|
+
n = services.agents.requestUpdateAll(version);
|
|
281
|
+
return `Requested update to agent v${version} for ${n} agent(s) — each updates on its next run.`;
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
router.post('/admin/agents/:id/update', requireAdminRole, updateAction(async (req) => {
|
|
285
|
+
if (req.body.cancel === '1'){
|
|
286
|
+
services.agents.setUpdateTo(req.params.id, null);
|
|
287
|
+
return 'Update request canceled.';
|
|
288
|
+
}
|
|
289
|
+
const version = await services.updates.targetVersion('agent');
|
|
290
|
+
services.agents.setUpdateTo(req.params.id, version);
|
|
291
|
+
return `Requested update to agent v${version} — it updates on its next run.`;
|
|
292
|
+
}));
|
|
293
|
+
|
|
229
294
|
router.get('/jobs', (req, res) => {
|
|
230
295
|
const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
|
|
231
296
|
jobs = services.jobs.list({ ...filters, limit: 200 }).map((j) => ({
|
package/views/admin/agents.pug
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
extends ../layout
|
|
2
|
+
include ../mixins/updates
|
|
2
3
|
|
|
3
4
|
block content
|
|
4
5
|
.d-flex.justify-content-between.align-items-center.mb-3
|
|
5
6
|
h1.h4.mb-0 Agents (CI / developers)
|
|
6
7
|
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
|
|
8
|
+
.mb-3
|
|
9
|
+
+updatesToolbar('agent', 'agents', '/admin/agents')
|
|
7
10
|
|
|
8
11
|
if newToken
|
|
9
12
|
.alert.alert-warning
|
|
@@ -16,6 +19,7 @@ block content
|
|
|
16
19
|
tr
|
|
17
20
|
th Name
|
|
18
21
|
th Kind
|
|
22
|
+
th Version
|
|
19
23
|
th Created
|
|
20
24
|
th Last used
|
|
21
25
|
th Status
|
|
@@ -25,13 +29,16 @@ block content
|
|
|
25
29
|
tr(class=a.revoked_at ? 'opacity-50' : '')
|
|
26
30
|
td= a.name
|
|
27
31
|
td.text-uppercase= a.kind
|
|
32
|
+
td: +versionCell(a.version, updates.latest.agent, a.update_to)
|
|
28
33
|
td= fmtDate(a.created_at)
|
|
29
34
|
td= fmtDate(a.last_used_at, 'never')
|
|
30
35
|
td= a.revoked_at ? 'revoked' : 'active'
|
|
31
36
|
td
|
|
32
37
|
if !a.revoked_at
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
.d-flex.justify-content-end
|
|
39
|
+
+updateButton(`/admin/agents/${a.id}/update`, a.update_to, '/admin/agents')
|
|
40
|
+
form.ms-1(method="post" action=`/admin/agents/${a.id}/revoke` onsubmit="return confirm('Revoke this agent token?')")
|
|
41
|
+
button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
|
|
35
42
|
|
|
36
43
|
.modal(tabindex="-1" id="addAgentModal")
|
|
37
44
|
.modal-dialog
|
package/views/index.pug
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
extends layout
|
|
2
2
|
include mixins/status-badge
|
|
3
|
+
include mixins/resource
|
|
4
|
+
include mixins/updates
|
|
3
5
|
|
|
4
6
|
block content
|
|
5
7
|
h1.h4.mb-2 Overview
|
|
@@ -24,7 +26,7 @@ block content
|
|
|
24
26
|
.row.g-3
|
|
25
27
|
each r in resources
|
|
26
28
|
.col-sm-6.col-md-4.col-lg-3
|
|
27
|
-
.card.h-100(class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
|
|
29
|
+
.card.h-100(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}` class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
|
|
28
30
|
.card-body
|
|
29
31
|
h5.card-title= r.name
|
|
30
32
|
p.card-text.mb-1
|
|
@@ -35,3 +37,9 @@ block content
|
|
|
35
37
|
p.card-text.small.text-body-secondary busy: #{r.busy_source} #{r.busy_reason ? '— ' + r.busy_reason : ''}
|
|
36
38
|
p.card-text.small.text-body-secondary
|
|
37
39
|
| heartbeat: #{fmtDate(r.last_heartbeat_at, 'never')}
|
|
40
|
+
|
|
41
|
+
each r in resources
|
|
42
|
+
+resourceCard(r, groupsById, '/')
|
|
43
|
+
|
|
44
|
+
block scripts
|
|
45
|
+
script(src="/js/resource-card.js")
|
package/views/layout.pug
CHANGED
|
@@ -10,7 +10,16 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
10
10
|
body
|
|
11
11
|
nav.navbar.navbar-expand-lg.bg-body-tertiary.border-bottom
|
|
12
12
|
.container-fluid
|
|
13
|
-
a.navbar-brand(href="/")
|
|
13
|
+
a.navbar-brand.d-flex.flex-column.lh-1.py-1(href="/")
|
|
14
|
+
span TestHub
|
|
15
|
+
small.text-body-secondary.fw-normal.mt-1(style="font-size: .7rem;")
|
|
16
|
+
| ver. #{coordinatorVersion}
|
|
17
|
+
if user && updates.coordinatorUpdate
|
|
18
|
+
span.badge.text-bg-warning.ms-1(
|
|
19
|
+
data-bs-toggle="tooltip"
|
|
20
|
+
data-bs-placement="bottom"
|
|
21
|
+
data-bs-title="Update the Coordinator by hand on its host: thub-admin self-update"
|
|
22
|
+
)= `v${updates.coordinatorUpdate} available`
|
|
14
23
|
button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
|
|
15
24
|
span.navbar-toggler-icon
|
|
16
25
|
#nav.collapse.navbar-collapse
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//- Resource actions and the resource info card (modal), shared by the
|
|
2
|
+
//- Overview and Resources pages. `returnTo` is the page the action forms
|
|
3
|
+
//- redirect back to.
|
|
4
|
+
|
|
5
|
+
mixin resourceActions(r, returnTo)
|
|
6
|
+
form.d-inline(method="post" action=`/resources/${r.id}/maintenance`)
|
|
7
|
+
input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
|
|
8
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
9
|
+
if r.status === 'MAINTENANCE'
|
|
10
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
11
|
+
type="submit"
|
|
12
|
+
aria-label="Enable"
|
|
13
|
+
data-bs-toggle="tooltip"
|
|
14
|
+
data-bs-title="Bring this resource back into rotation so the scheduler can assign it jobs again"
|
|
15
|
+
)
|
|
16
|
+
i.bi.bi-play-circle
|
|
17
|
+
else
|
|
18
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
19
|
+
type="submit"
|
|
20
|
+
aria-label="Disable"
|
|
21
|
+
data-bs-toggle="tooltip"
|
|
22
|
+
data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
|
|
23
|
+
)
|
|
24
|
+
i.bi.bi-tools
|
|
25
|
+
form.d-inline.ms-1(method="post" action=`/resources/${r.id}/rotate-token` onsubmit="return confirm('Rotate token? The Client will need the new one.')")
|
|
26
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
27
|
+
button.btn.btn-sm.btn-outline-warning(
|
|
28
|
+
type="submit"
|
|
29
|
+
aria-label="Rotate token"
|
|
30
|
+
data-bs-toggle="tooltip"
|
|
31
|
+
data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
|
|
32
|
+
)
|
|
33
|
+
i.bi.bi-arrow-repeat
|
|
34
|
+
+updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
|
|
35
|
+
|
|
36
|
+
mixin resourceCard(r, groupsById, returnTo)
|
|
37
|
+
- const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
|
|
38
|
+
- const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
|
|
39
|
+
- const external = r.remote_addr && !isLoopback(r.remote_addr) ? r.remote_addr : null
|
|
40
|
+
.modal.fade(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
|
|
41
|
+
.modal-dialog.modal-dialog-centered
|
|
42
|
+
.modal-content
|
|
43
|
+
.modal-header
|
|
44
|
+
h5.modal-title(id=`resource-card-${r.id}-title`)= r.name
|
|
45
|
+
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
46
|
+
.modal-body
|
|
47
|
+
dl.row.mb-0
|
|
48
|
+
dt.col-sm-4 Name
|
|
49
|
+
dd.col-sm-8= r.name
|
|
50
|
+
dt.col-sm-4 Type
|
|
51
|
+
dd.col-sm-8.text-uppercase= r.type
|
|
52
|
+
dt.col-sm-4 Status
|
|
53
|
+
dd.col-sm-8: +resourceBadge(r.status)
|
|
54
|
+
dt.col-sm-4 Client version
|
|
55
|
+
dd.col-sm-8: +versionCell(r.client_version, updates.latest.client, r.update_to)
|
|
56
|
+
dt.col-sm-4 IP addresses
|
|
57
|
+
dd.col-sm-8
|
|
58
|
+
if !local.length && !external
|
|
59
|
+
span.text-body-secondary —
|
|
60
|
+
ul.list-unstyled.mb-0
|
|
61
|
+
each a in local
|
|
62
|
+
li
|
|
63
|
+
code= a.address
|
|
64
|
+
span.text-body-secondary.small.ms-2= a.iface ? `${a.iface} · ${a.family}` : a.family
|
|
65
|
+
if external
|
|
66
|
+
li
|
|
67
|
+
code= external
|
|
68
|
+
span.text-body-secondary.small.ms-2(
|
|
69
|
+
data-bs-toggle="tooltip"
|
|
70
|
+
data-bs-title="The address the Coordinator last saw this Client connect from"
|
|
71
|
+
) external
|
|
72
|
+
dt.col-sm-4 Labels
|
|
73
|
+
dd.col-sm-8
|
|
74
|
+
if r.labels.length
|
|
75
|
+
each l in r.labels
|
|
76
|
+
span.badge.text-bg-light.border.me-1= l
|
|
77
|
+
else
|
|
78
|
+
span.text-body-secondary —
|
|
79
|
+
dt.col-sm-4 Groups
|
|
80
|
+
dd.col-sm-8
|
|
81
|
+
if r.group_ids.length
|
|
82
|
+
each gid in r.group_ids
|
|
83
|
+
span.badge.text-bg-info.me-1(title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
|
|
84
|
+
else
|
|
85
|
+
span.text-body-secondary —
|
|
86
|
+
dt.col-sm-4 Busy source / reason
|
|
87
|
+
dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
|
|
88
|
+
dt.col-sm-4 Last heartbeat
|
|
89
|
+
dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
|
|
90
|
+
if user.role === 'admin'
|
|
91
|
+
dt.col-sm-4 Actions
|
|
92
|
+
dd.col-sm-8.mb-0
|
|
93
|
+
+resourceActions(r, returnTo)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//- Version display and self-update controls (README §10.2), shared by
|
|
2
|
+
//- the Agents and Resources pages and the resource card.
|
|
3
|
+
|
|
4
|
+
//- An app's reported version, plus a pending update request or a newer
|
|
5
|
+
//- published version.
|
|
6
|
+
mixin versionCell(current, latest, updateTo)
|
|
7
|
+
span= current ? `v${current}` : '—'
|
|
8
|
+
if updateTo
|
|
9
|
+
span.badge.text-bg-warning.ms-1(
|
|
10
|
+
data-bs-toggle="tooltip"
|
|
11
|
+
data-bs-title="Self-update requested — cleared once it reports this version"
|
|
12
|
+
)= `→ v${updateTo}`
|
|
13
|
+
else if current && isNewer(latest, current)
|
|
14
|
+
span.badge.text-bg-secondary.ms-1(
|
|
15
|
+
data-bs-toggle="tooltip"
|
|
16
|
+
data-bs-title=`v${latest} is available`
|
|
17
|
+
) update available
|
|
18
|
+
|
|
19
|
+
//- Latest published version of `app` (agent | client), "check now" and,
|
|
20
|
+
//- for admins, "update all".
|
|
21
|
+
mixin updatesToolbar(app, label, returnTo)
|
|
22
|
+
.d-flex.flex-wrap.align-items-center.gap-2.small.text-body-secondary
|
|
23
|
+
span
|
|
24
|
+
| Latest #{app}:
|
|
25
|
+
= ' '
|
|
26
|
+
strong= updates.latest[app] ? `v${updates.latest[app]}` : 'unknown'
|
|
27
|
+
if updates.checkedAt
|
|
28
|
+
= ` · checked ${fmtDate(updates.checkedAt)}`
|
|
29
|
+
if updates.error
|
|
30
|
+
i.bi.bi-exclamation-triangle.text-warning.ms-1(data-bs-toggle="tooltip" data-bs-title=updates.error)
|
|
31
|
+
if user.role === 'admin'
|
|
32
|
+
form.d-inline(method="post" action="/updates/check")
|
|
33
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
34
|
+
button.btn.btn-sm.btn-outline-secondary(type="submit")
|
|
35
|
+
i.bi.bi-arrow-clockwise.me-1
|
|
36
|
+
| Check for updates
|
|
37
|
+
form.d-inline(
|
|
38
|
+
method="post"
|
|
39
|
+
action=app === 'agent' ? '/admin/agents/update-all' : '/resources/update-all'
|
|
40
|
+
onsubmit=`return confirm('Request a self-update of every ${label} to the latest version?')`
|
|
41
|
+
)
|
|
42
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
43
|
+
button.btn.btn-sm.btn-outline-primary(type="submit" disabled=!updates.latest[app])
|
|
44
|
+
i.bi.bi-cloud-download.me-1
|
|
45
|
+
= `Update all ${label}`
|
|
46
|
+
|
|
47
|
+
//- Per-item "Update" / "Cancel update" button.
|
|
48
|
+
mixin updateButton(action, pending, returnTo)
|
|
49
|
+
form.d-inline.ms-1(method="post" action=action)
|
|
50
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
51
|
+
if pending
|
|
52
|
+
input(type="hidden" name="cancel" value="1")
|
|
53
|
+
button.btn.btn-sm.btn-outline-warning(
|
|
54
|
+
type="submit"
|
|
55
|
+
aria-label="Cancel update"
|
|
56
|
+
data-bs-toggle="tooltip"
|
|
57
|
+
data-bs-title=`Cancel the pending self-update to v${pending}`
|
|
58
|
+
)
|
|
59
|
+
i.bi.bi-x-circle
|
|
60
|
+
else
|
|
61
|
+
button.btn.btn-sm.btn-outline-primary(
|
|
62
|
+
type="submit"
|
|
63
|
+
aria-label="Update"
|
|
64
|
+
data-bs-toggle="tooltip"
|
|
65
|
+
data-bs-title="Self-update to the latest published version"
|
|
66
|
+
)
|
|
67
|
+
i.bi.bi-cloud-download
|
package/views/resources/list.pug
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
extends ../layout
|
|
2
2
|
include ../mixins/status-badge
|
|
3
|
+
include ../mixins/resource
|
|
4
|
+
include ../mixins/updates
|
|
3
5
|
|
|
4
6
|
block content
|
|
5
7
|
h1.h4.mb-3 Resources
|
|
6
8
|
p.text-body-secondary.small.
|
|
7
|
-
Resources register themselves — start a Client with the shared join key and it appears here.
|
|
9
|
+
Resources register themselves — start a Client with the shared join key and it appears here. Click a resource for its details, including IP addresses.
|
|
10
|
+
|
|
11
|
+
.mb-3
|
|
12
|
+
+updatesToolbar('client', 'clients', '/resources')
|
|
8
13
|
|
|
9
14
|
table.table.table-hover.align-middle
|
|
10
15
|
thead
|
|
11
16
|
tr
|
|
12
17
|
th Name
|
|
13
18
|
th Type
|
|
19
|
+
th Version
|
|
14
20
|
th Status
|
|
15
21
|
th Labels
|
|
16
22
|
th Groups
|
|
@@ -20,9 +26,10 @@ block content
|
|
|
20
26
|
th Actions
|
|
21
27
|
tbody
|
|
22
28
|
each r in resources
|
|
23
|
-
tr
|
|
29
|
+
tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
|
|
24
30
|
td= r.name
|
|
25
31
|
td.text-uppercase= r.type
|
|
32
|
+
td: +versionCell(r.client_version, updates.latest.client, r.update_to)
|
|
26
33
|
td: +resourceBadge(r.status)
|
|
27
34
|
td
|
|
28
35
|
each l in r.labels
|
|
@@ -37,29 +44,10 @@ block content
|
|
|
37
44
|
td= fmtDate(r.last_heartbeat_at, 'never')
|
|
38
45
|
if user.role === 'admin'
|
|
39
46
|
td
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
data-bs-title="Bring this resource back into rotation so the scheduler can assign it jobs again"
|
|
48
|
-
)
|
|
49
|
-
i.bi.bi-play-circle
|
|
50
|
-
else
|
|
51
|
-
button.btn.btn-sm.btn-outline-secondary(
|
|
52
|
-
type="submit"
|
|
53
|
-
aria-label="Disable"
|
|
54
|
-
data-bs-toggle="tooltip"
|
|
55
|
-
data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
|
|
56
|
-
)
|
|
57
|
-
i.bi.bi-tools
|
|
58
|
-
form.d-inline.ms-1(method="post" action=`/resources/${r.id}/rotate-token` onsubmit="return confirm('Rotate token? The Client will need the new one.')")
|
|
59
|
-
button.btn.btn-sm.btn-outline-warning(
|
|
60
|
-
type="submit"
|
|
61
|
-
aria-label="Rotate token"
|
|
62
|
-
data-bs-toggle="tooltip"
|
|
63
|
-
data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
|
|
64
|
-
)
|
|
65
|
-
i.bi.bi-arrow-repeat
|
|
47
|
+
+resourceActions(r, '/resources')
|
|
48
|
+
|
|
49
|
+
each r in resources
|
|
50
|
+
+resourceCard(r, groupsById, '/resources')
|
|
51
|
+
|
|
52
|
+
block scripts
|
|
53
|
+
script(src="/js/resource-card.js")
|