@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.40
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/bin/thub-admin.js +26 -5
- package/config.json +5 -6
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/log-viewer.js +188 -56
- package/public/js/settings.js +66 -0
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +28 -22
- package/src/api/resource.js +11 -14
- package/src/api/sse.js +13 -10
- package/src/config.js +33 -10
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/db/migrations/024_add_settings.sql +10 -0
- package/src/db/migrations/025_add_job_artifacts.sql +4 -0
- package/src/dev/virtual.js +10 -6
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +104 -0
- package/src/server.js +53 -23
- package/src/services/admin-users.js +32 -19
- package/src/services/cleanup.js +2 -2
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +17 -9
- package/src/services/logs.js +12 -14
- package/src/services/registry.js +34 -0
- package/src/services/retention.js +5 -14
- package/src/services/session-store.js +127 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +230 -21
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- package/test/cleanup.test.js +1 -5
- package/test/groups-tab.test.js +97 -0
- package/test/job-artifacts.test.js +115 -0
- package/test/login-guard.test.js +139 -0
- package/test/logs.test.js +150 -0
- package/test/rate-limit.test.js +106 -0
- package/test/scheduler.test.js +10 -12
- package/test/security.test.js +107 -0
- package/test/session.test.js +132 -0
- package/test/settings.test.js +206 -0
- package/views/admin/settings.pug +133 -0
- package/views/groups/list.pug +1 -1
- package/views/help/_agent-cli.pug +2 -2
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +18 -7
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +2 -1
- package/views/help/_git.pug +3 -2
- package/views/help/_overview.pug +2 -2
- package/views/help/_troubleshooting.pug +4 -0
- package/views/jobs/list.pug +5 -5
- package/views/jobs/show.pug +33 -7
- package/views/layout.pug +4 -1
- package/views/mixins/client-config.pug +65 -3
- package/views/mixins/list-controls.pug +1 -1
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/resource.pug +1 -8
- package/src/services/artifacts.js +0 -149
package/src/services/registry.js
CHANGED
|
@@ -696,6 +696,39 @@ function createRegistryService(db, { bus, events }){
|
|
|
696
696
|
return { resource: get(resourceId), ignored };
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
+
// Groups tab (resource card): the Client's group membership. It belongs to
|
|
700
|
+
// the Client's config file (`groups`, which every registration reports),
|
|
701
|
+
// so it goes out as the next config revision — like an Import's top-level
|
|
702
|
+
// fields — for the Client to write there and restart with. The Coordinator
|
|
703
|
+
// applies it at once too, so scheduling follows without waiting for that.
|
|
704
|
+
// Fields of an Import not applied yet are carried along, not lost.
|
|
705
|
+
function setClientGroups(resourceId, groupIds, { by, knownGroupIds } = {}){
|
|
706
|
+
const r = get(resourceId);
|
|
707
|
+
if (!r){
|
|
708
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
709
|
+
}
|
|
710
|
+
const ids = [...new Set(groupIds)],
|
|
711
|
+
unknown = knownGroupIds ? ids.filter((id) => !knownGroupIds.includes(id) && !r.group_ids.includes(id)) : [];
|
|
712
|
+
if (unknown.length){
|
|
713
|
+
throw Object.assign(new Error(`Unknown group: ${unknown.join(', ')}`), { status: 400 });
|
|
714
|
+
}
|
|
715
|
+
// Writing top-level fields needs a Client that reports its config file
|
|
716
|
+
// (the same that Import needs); an older one would drop them.
|
|
717
|
+
const desired = r.config_desired || r.client_config;
|
|
718
|
+
if (!desired || !r.client_config_file){
|
|
719
|
+
throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
|
|
720
|
+
}
|
|
721
|
+
const revision = r.config_revision + 1,
|
|
722
|
+
fields = { ...(pendingImport(r) || {}), groups: ids };
|
|
723
|
+
db.transaction(() => {
|
|
724
|
+
db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?
|
|
725
|
+
WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), resourceId);
|
|
726
|
+
setGroups(resourceId, ids);
|
|
727
|
+
})();
|
|
728
|
+
events.record('resource', resourceId, 'resource.groups_set', { by, revision, groups: ids });
|
|
729
|
+
return get(resourceId);
|
|
730
|
+
}
|
|
731
|
+
|
|
699
732
|
// Export (resource card): the Client's config file as it last reported it
|
|
700
733
|
// (secrets excluded), with what's saved for it but not applied yet — so
|
|
701
734
|
// exporting right after an edit or import gives what the Client will run.
|
|
@@ -796,6 +829,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
796
829
|
setRebootSchedule,
|
|
797
830
|
pendingRebootSchedule,
|
|
798
831
|
setClientConfig,
|
|
832
|
+
setClientGroups,
|
|
799
833
|
pendingConfig,
|
|
800
834
|
importClientConfig,
|
|
801
835
|
exportClientConfig,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/src/services/retention.js
|
|
3
|
-
* @description
|
|
3
|
+
* @description Nightly consistent database backup (VACUUM INTO, README §9)
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -17,20 +17,11 @@ const path = require('node:path');
|
|
|
17
17
|
|
|
18
18
|
const DAY_MS = 86400 * 1000;
|
|
19
19
|
|
|
20
|
-
// §9:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
function createRetentionService(db, {
|
|
24
|
-
bus.on('job.finished', ({ jobId }) => {
|
|
25
|
-
const text = logs.renderConsoleLog(jobId);
|
|
26
|
-
if (text){
|
|
27
|
-
artifacts.storeGenerated(jobId, 'console.log', Buffer.from(text, 'utf8'), 'text/plain');
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
|
|
20
|
+
// §9: a nightly consistent backup via VACUUM INTO. (A job's log lines live
|
|
21
|
+
// as long as the job itself — retention.jobRetention / Clean up database,
|
|
22
|
+
// services/cleanup.js — and there are no artifacts to purge any more.)
|
|
23
|
+
function createRetentionService(db, { config }){
|
|
31
24
|
function runDaily(){
|
|
32
|
-
logs.purgeOlderThan(config.retention.logRetentionDays);
|
|
33
|
-
artifacts.purgeOlderThan(config.retention.artifactRetentionDays);
|
|
34
25
|
const backupPath = path.join(config.dataDir, `backup-${new Date().toISOString().slice(0, 10)}.db`);
|
|
35
26
|
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, '\'\'')}'`);
|
|
36
27
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/session-store.js
|
|
3
|
+
* @description Dashboard session store in the Coordinator's SQLite database: survives restarts, prunes expired sessions
|
|
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 session = require('express-session');
|
|
17
|
+
|
|
18
|
+
// express-session's default MemoryStore loses every session on a restart,
|
|
19
|
+
// never drops expired ones (memory only grows) and warns against itself in
|
|
20
|
+
// production. This one keeps them in the `sessions` table (migration 022),
|
|
21
|
+
// so signing in survives a Coordinator restart or self-update, and an
|
|
22
|
+
// expired session is deleted when read and by a periodic sweep.
|
|
23
|
+
const PRUNE_INTERVAL_MS = 15 * 60 * 1000,
|
|
24
|
+
// A session without a cookie expiry (never the case here: routes.js sets
|
|
25
|
+
// each user's idle timeout) is kept this long.
|
|
26
|
+
FALLBACK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
class SqliteSessionStore extends session.Store{
|
|
29
|
+
constructor(db, { pruneIntervalMs = PRUNE_INTERVAL_MS, now = Date.now } = {}){
|
|
30
|
+
super();
|
|
31
|
+
this.now = now;
|
|
32
|
+
this.stmts = {
|
|
33
|
+
get: db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?'),
|
|
34
|
+
set: db.prepare(
|
|
35
|
+
`INSERT INTO sessions (sid, sess, expires) VALUES (?, ?, ?)
|
|
36
|
+
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires`
|
|
37
|
+
),
|
|
38
|
+
touch: db.prepare('UPDATE sessions SET sess = ?, expires = ? WHERE sid = ?'),
|
|
39
|
+
destroy: db.prepare('DELETE FROM sessions WHERE sid = ?'),
|
|
40
|
+
prune: db.prepare('DELETE FROM sessions WHERE expires <= ?'),
|
|
41
|
+
length: db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE expires > ?'),
|
|
42
|
+
clear: db.prepare('DELETE FROM sessions')
|
|
43
|
+
};
|
|
44
|
+
if (pruneIntervalMs > 0){
|
|
45
|
+
this.pruneTimer = setInterval(() => this.prune(), pruneIntervalMs);
|
|
46
|
+
this.pruneTimer.unref?.();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expiresOf(sess){
|
|
51
|
+
const at = sess?.cookie?.expires ? new Date(sess.cookie.expires).getTime() : NaN;
|
|
52
|
+
return Number.isFinite(at) ? at : this.now() + FALLBACK_TTL_MS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Each method answers through `cb`, as express-session expects; errors
|
|
56
|
+
// (a locked or broken database) go there too instead of throwing.
|
|
57
|
+
run(cb, fn){
|
|
58
|
+
let result;
|
|
59
|
+
try {
|
|
60
|
+
result = fn();
|
|
61
|
+
}
|
|
62
|
+
catch (err){
|
|
63
|
+
return cb?.(err);
|
|
64
|
+
}
|
|
65
|
+
cb?.(null, result);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get(sid, cb){
|
|
69
|
+
this.run(cb, () => {
|
|
70
|
+
const row = this.stmts.get.get(sid);
|
|
71
|
+
if (!row){
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (row.expires <= this.now()){
|
|
75
|
+
this.stmts.destroy.run(sid);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return JSON.parse(row.sess);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
set(sid, sess, cb){
|
|
83
|
+
this.run(cb, () => {
|
|
84
|
+
this.stmts.set.run(sid, JSON.stringify(sess), this.expiresOf(sess));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Called for a request that didn't change the session: moves its expiry
|
|
89
|
+
// (rolling idle timeout, §10.1). A passive live-update request leaves the
|
|
90
|
+
// cookie's expiry as it was (server.js), so this writes the same value.
|
|
91
|
+
touch(sid, sess, cb){
|
|
92
|
+
this.run(cb, () => {
|
|
93
|
+
this.stmts.touch.run(JSON.stringify(sess), this.expiresOf(sess), sid);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
destroy(sid, cb){
|
|
98
|
+
this.run(cb, () => {
|
|
99
|
+
this.stmts.destroy.run(sid);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
length(cb){
|
|
104
|
+
this.run(cb, () => this.stmts.length.get(this.now()).n);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
clear(cb){
|
|
108
|
+
this.run(cb, () => {
|
|
109
|
+
this.stmts.clear.run();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
prune(){
|
|
114
|
+
try {
|
|
115
|
+
return this.stmts.prune.run(this.now()).changes;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return 0; // the next sweep tries again
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close(){
|
|
123
|
+
clearInterval(this.pruneTimer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { SqliteSessionStore };
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/settings.js
|
|
3
|
+
* @description Coordinator settings editable from the dashboard (/admin/settings): stored in the database, layered over the config file
|
|
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 { JOB_RETENTION } = require('./cleanup');
|
|
17
|
+
|
|
18
|
+
// README §13.2. Precedence, lowest first: built-in default < config file <
|
|
19
|
+
// dashboard setting (the `settings` table) < environment variable. The
|
|
20
|
+
// dashboard can't write the config file — the systemd unit only lets the
|
|
21
|
+
// service write its data directory — so its changes live in the database.
|
|
22
|
+
//
|
|
23
|
+
// `applies`: "now" — the code reads it on every use, so saving takes effect
|
|
24
|
+
// at once; "restart" — read once at startup (timers, middleware), so it
|
|
25
|
+
// takes effect when the Coordinator restarts. Settings that could cut the
|
|
26
|
+
// dashboard off from the inside (listen, dataDir, sessionSecret) stay in
|
|
27
|
+
// the config file.
|
|
28
|
+
const int = (min, max) => (raw) => {
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
if (!Number.isInteger(n) || n < min || n > max){
|
|
31
|
+
throw new Error(`must be a whole number from ${min} to ${max}`);
|
|
32
|
+
}
|
|
33
|
+
return n;
|
|
34
|
+
},
|
|
35
|
+
bool = (raw) => raw === true || raw === 'true' || raw === 'on' || raw === '1',
|
|
36
|
+
oneOf = (values) => (raw) => {
|
|
37
|
+
const v = values.find((x) => String(x) === String(raw));
|
|
38
|
+
if (v === undefined){
|
|
39
|
+
throw new Error(`must be one of: ${values.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
return v;
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
SETTINGS = [
|
|
45
|
+
{
|
|
46
|
+
key: 'publicUrl', group: 'General', label: 'Public URL', applies: 'now', env: 'THUB_PUBLIC_URL', type: 'text',
|
|
47
|
+
help: 'The https:// address Agents, Clients and browsers use. Job links and the dashboard\'s same-origin check use it.',
|
|
48
|
+
parse: (raw) => {
|
|
49
|
+
const s = String(raw).trim().replace(/\/+$/, '');
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(s);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error('must be a URL like https://thub.example.com');
|
|
56
|
+
}
|
|
57
|
+
if (!/^https?:$/.test(url.protocol) || url.pathname !== '/' || url.search || url.hash){
|
|
58
|
+
throw new Error('must be just http(s)://host[:port], without a path');
|
|
59
|
+
}
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: 'clientJoinKey', group: 'General', label: 'Client join key', applies: 'now', env: 'THUB_CLIENT_JOIN_KEY', type: 'secret',
|
|
65
|
+
help: 'The shared secret Clients self-register with (joinKey in their config). Empty turns self-registration off. ' +
|
|
66
|
+
'Running Clients keep working; one with an old key can\'t register again after its next restart.',
|
|
67
|
+
parse: (raw) => {
|
|
68
|
+
const s = String(raw ?? '').trim();
|
|
69
|
+
if (s && s.length < 16){
|
|
70
|
+
throw new Error('must be at least 16 characters (or empty to turn registration off)');
|
|
71
|
+
}
|
|
72
|
+
return s || null;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'trustProxy', group: 'General', label: 'Trusted proxies', applies: 'restart', type: 'text',
|
|
77
|
+
help: 'Whose X-Forwarded-For/-Proto to believe: loopback (a proxy on this host), a CIDR such as 10.0.0.0/8, ' +
|
|
78
|
+
'a number of hops, or false. Wrong values break HTTPS detection and sign-in behind a proxy.',
|
|
79
|
+
parse: (raw) => {
|
|
80
|
+
const s = String(raw).trim();
|
|
81
|
+
if (s === 'false' || s === 'true'){
|
|
82
|
+
return s === 'true';
|
|
83
|
+
}
|
|
84
|
+
if (/^\d+$/.test(s)){
|
|
85
|
+
return Number(s);
|
|
86
|
+
}
|
|
87
|
+
if (!/^[\w.:/,\s-]+$/.test(s) || !s){
|
|
88
|
+
throw new Error('must be loopback, uniquelocal, linklocal, IP addresses/CIDRs (comma-separated), a hop count, true or false');
|
|
89
|
+
}
|
|
90
|
+
return s;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: 'session.secureCookie', group: 'General', label: 'Secure session cookie', applies: 'restart', type: 'select',
|
|
95
|
+
options: ['auto', 'true', 'false'],
|
|
96
|
+
help: 'auto: HTTPS-only whenever the public URL is https://. true/false force it.',
|
|
97
|
+
parse: (raw) => ({ auto: 'auto', true: true, false: false })[String(raw)] ?? oneOf(['auto', 'true', 'false'])(raw)
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: 'heartbeat.intervalSec', group: 'Heartbeat', label: 'Heartbeat interval (s)', applies: 'now', type: 'number',
|
|
101
|
+
help: 'How often Clients report. They switch to a new value with their next heartbeat (lowering it, the old one is ' +
|
|
102
|
+
'still allowed for a while). Clients older than this setting keep their own heartbeatIntervalSec (10 by default): ' +
|
|
103
|
+
'don\'t go below that while any are connected.',
|
|
104
|
+
parse: int(2, 300)
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: 'heartbeat.missedLimit', group: 'Heartbeat', label: 'Missed heartbeats allowed', applies: 'now', type: 'number',
|
|
108
|
+
help: 'A Client that misses this many in a row goes OUT_OF_SERVICE, and its running job becomes LOST.',
|
|
109
|
+
parse: int(2, 20)
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: 'heartbeat.sweepIntervalSec', group: 'Heartbeat', label: 'Check every (s)', applies: 'restart', type: 'number',
|
|
113
|
+
help: 'How often missed heartbeats, unaccepted assignments and job timeouts are checked.',
|
|
114
|
+
parse: int(1, 60)
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
key: 'rateLimit.requestsPerMinute', group: 'Rate limits', label: 'Requests per minute', applies: 'now', type: 'number',
|
|
118
|
+
help: 'Per caller: an agent or resource token, a dashboard user, else the IP address. Short bursts are fine; ' +
|
|
119
|
+
'past that, requests get 429 with Retry-After. A Client streaming logs makes up to ~240 a minute. 0: no limit.',
|
|
120
|
+
parse: int(0, 1_000_000)
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
key: 'rateLimit.loginPerMinute', group: 'Rate limits', label: 'Sign-in attempts per minute', applies: 'now', type: 'number',
|
|
124
|
+
help: 'Per IP address, against password guessing. 0: no limit.',
|
|
125
|
+
parse: int(0, 10_000)
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: 'jobs.defaultTimeoutSec', group: 'Jobs', label: 'Default job timeout (s)', applies: 'now', type: 'number',
|
|
129
|
+
help: 'For a job submitted without --timeout.', parse: int(60, 7 * 86400)
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: 'jobs.maxTimeoutSec', group: 'Jobs', label: 'Maximum job timeout (s)', applies: 'now', type: 'number',
|
|
133
|
+
help: 'A longer --timeout is cut to this.', parse: int(60, 7 * 86400)
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: 'scheduler.assignAckTimeoutSec', group: 'Scheduler', label: 'Accept timeout (s)', applies: 'now', type: 'number',
|
|
137
|
+
help: 'How long an assigned job waits for its Client to accept it before going back to the queue.', parse: int(5, 600)
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: 'scheduler.requeueOnLost', group: 'Scheduler', label: 'Retry lost jobs', applies: 'now', type: 'checkbox',
|
|
141
|
+
help: 'Requeue a job once when its Client goes offline mid-job.', parse: bool
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: 'scheduler.maxQueuedPerAgent', group: 'Scheduler', label: 'Max queued jobs per agent', applies: 'now', type: 'number',
|
|
145
|
+
help: 'Further submissions from that agent are refused until some run.', parse: int(1, 10000)
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: 'scheduler.tickIntervalSec', group: 'Scheduler', label: 'Safety-net pass (s)', applies: 'restart', type: 'number',
|
|
149
|
+
help: 'The scheduler also runs on every queued job and free Client; this periodic pass only catches anything missed.',
|
|
150
|
+
parse: int(1, 3600)
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
key: 'retention.jobRetention', group: 'Retention', label: 'Keep finished jobs', applies: 'now', type: 'select',
|
|
154
|
+
options: Object.keys(JOB_RETENTION), labels: Object.fromEntries(Object.entries(JOB_RETENTION).map(([k, v]) => [k, v.label])),
|
|
155
|
+
help: 'Older finished jobs, with their logs, are deleted hourly. forever: only by Clean up database.',
|
|
156
|
+
parse: oneOf(Object.keys(JOB_RETENTION))
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: 'updates.checkIntervalMin', group: 'Updates', label: 'Version check every (min)', applies: 'restart', type: 'number',
|
|
160
|
+
help: 'How often to look for new Coordinator/Agent/Client releases. 0 turns the periodic check off.', parse: int(0, 1440)
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: 'updates.registry', group: 'Updates', label: 'npm registry', applies: 'restart', type: 'text',
|
|
164
|
+
help: 'Empty: the public npm registry (or npm_config_registry). Set it for a private mirror.',
|
|
165
|
+
parse: (raw) => {
|
|
166
|
+
const s = String(raw ?? '').trim();
|
|
167
|
+
if (s && !/^https?:\/\/\S+$/.test(s)){
|
|
168
|
+
throw new Error('must be an http(s):// URL, or empty');
|
|
169
|
+
}
|
|
170
|
+
return s || null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
],
|
|
174
|
+
BY_KEY = new Map(SETTINGS.map((s) => [s.key, s])),
|
|
175
|
+
|
|
176
|
+
getPath = (obj, key) => key.split('.').reduce((o, k) => o?.[k], obj),
|
|
177
|
+
setPath = (obj, key, value) => {
|
|
178
|
+
const parts = key.split('.'),
|
|
179
|
+
last = parts.pop(),
|
|
180
|
+
parent = parts.reduce((o, k) => (o[k] ??= {}), obj);
|
|
181
|
+
parent[last] = value;
|
|
182
|
+
},
|
|
183
|
+
same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
184
|
+
|
|
185
|
+
// `config` is the live object every service holds: "now" settings are
|
|
186
|
+
// written into it, so they take effect on the next use.
|
|
187
|
+
function createSettingsService(db, { config, env = process.env }){
|
|
188
|
+
const stmts = {
|
|
189
|
+
all: db.prepare('SELECT key, value, updated_at, updated_by FROM settings'),
|
|
190
|
+
upsert: db.prepare(
|
|
191
|
+
`INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?)
|
|
192
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`
|
|
193
|
+
),
|
|
194
|
+
remove: db.prepare('DELETE FROM settings WHERE key = ?')
|
|
195
|
+
},
|
|
196
|
+
// What the config file (plus defaults) said, before any dashboard
|
|
197
|
+
// setting: what "Reset" goes back to.
|
|
198
|
+
fileValues = Object.fromEntries(SETTINGS.map((s) => [s.key, structuredClone(getPath(config, s.key))]));
|
|
199
|
+
let running = null; // the values this process started with (for "after restart")
|
|
200
|
+
|
|
201
|
+
function stored(){
|
|
202
|
+
const rows = {};
|
|
203
|
+
for (const r of stmts.all.all()){
|
|
204
|
+
if (BY_KEY.has(r.key)){
|
|
205
|
+
rows[r.key] = { value: JSON.parse(r.value), updatedAt: r.updated_at, updatedBy: r.updated_by };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return rows;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const envLocked = (s) => Boolean(s.env && env[s.env]);
|
|
212
|
+
|
|
213
|
+
// What each setting resolves to right now: env > dashboard > file.
|
|
214
|
+
function desired(rows = stored()){
|
|
215
|
+
return Object.fromEntries(SETTINGS.map((s) => [
|
|
216
|
+
s.key,
|
|
217
|
+
envLocked(s) ? getPath(config, s.key) : s.key in rows ? rows[s.key].value : fileValues[s.key]
|
|
218
|
+
]));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// At startup: put the dashboard's settings into the config every service
|
|
222
|
+
// reads. Then remember what this process runs with.
|
|
223
|
+
function applyAtStartup(){
|
|
224
|
+
const rows = stored();
|
|
225
|
+
// A value saved by an older version that this one no longer accepts is
|
|
226
|
+
// skipped (with a warning), not allowed to stop the Coordinator.
|
|
227
|
+
for (const [key, { value }]of Object.entries(rows)){
|
|
228
|
+
try {
|
|
229
|
+
BY_KEY.get(key).parse(value === null ? '' : value);
|
|
230
|
+
}
|
|
231
|
+
catch (err){
|
|
232
|
+
console.warn(`Ignoring dashboard setting ${key}=${JSON.stringify(value)}: ${err.message}`);
|
|
233
|
+
delete rows[key];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const want = desired(rows);
|
|
237
|
+
for (const s of SETTINGS){
|
|
238
|
+
setPath(config, s.key, structuredClone(want[s.key]));
|
|
239
|
+
}
|
|
240
|
+
running = structuredClone(want);
|
|
241
|
+
return service;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// For the settings page.
|
|
245
|
+
function list(){
|
|
246
|
+
const rows = stored(),
|
|
247
|
+
want = desired(rows);
|
|
248
|
+
return SETTINGS.map((s) => ({
|
|
249
|
+
...s,
|
|
250
|
+
value: want[s.key],
|
|
251
|
+
fileValue: fileValues[s.key],
|
|
252
|
+
source: envLocked(s) ? 'env' : s.key in rows ? 'dashboard' : 'file',
|
|
253
|
+
updatedAt: rows[s.key]?.updatedAt,
|
|
254
|
+
updatedBy: rows[s.key]?.updatedBy,
|
|
255
|
+
pendingRestart: s.applies === 'restart' && !same(want[s.key], running?.[s.key])
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// `changes`: { key: rawValue } for the settings to set, `reset`: keys to
|
|
260
|
+
// put back to the config file's value. Validates everything first, so a
|
|
261
|
+
// bad value saves nothing. Returns what changed.
|
|
262
|
+
function save({ changes = {}, reset = [] }, { by, guard } = {}){
|
|
263
|
+
const rows = stored(),
|
|
264
|
+
before = desired(rows),
|
|
265
|
+
parsed = {},
|
|
266
|
+
errors = [];
|
|
267
|
+
for (const [key, raw]of Object.entries(changes)){
|
|
268
|
+
const s = BY_KEY.get(key);
|
|
269
|
+
if (!s || envLocked(s) || reset.includes(key)){
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
parsed[key] = s.parse(raw);
|
|
274
|
+
}
|
|
275
|
+
catch (err){
|
|
276
|
+
errors.push(`${s.label}: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const after = { ...before, ...parsed };
|
|
280
|
+
for (const key of reset){
|
|
281
|
+
if (BY_KEY.has(key) && !envLocked(BY_KEY.get(key))){
|
|
282
|
+
after[key] = fileValues[key];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (after['jobs.defaultTimeoutSec'] > after['jobs.maxTimeoutSec']){
|
|
286
|
+
errors.push('Default job timeout: can\'t be longer than the maximum job timeout');
|
|
287
|
+
}
|
|
288
|
+
if (!errors.length && guard){
|
|
289
|
+
const problem = guard(after);
|
|
290
|
+
if (problem){
|
|
291
|
+
errors.push(problem);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (errors.length){
|
|
295
|
+
throw Object.assign(new Error(errors.join('\n')), { status: 400 });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const now = new Date().toISOString(),
|
|
299
|
+
changed = [];
|
|
300
|
+
db.transaction(() => {
|
|
301
|
+
for (const s of SETTINGS){
|
|
302
|
+
if (envLocked(s)){
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (reset.includes(s.key) && s.key in rows){
|
|
306
|
+
stmts.remove.run(s.key);
|
|
307
|
+
}
|
|
308
|
+
else if (s.key in parsed && !same(parsed[s.key], before[s.key])){
|
|
309
|
+
stmts.upsert.run(s.key, JSON.stringify(parsed[s.key]), now, by || null);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (!same(after[s.key], before[s.key])){
|
|
315
|
+
changed.push(s);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
})();
|
|
319
|
+
for (const s of changed){
|
|
320
|
+
if (s.applies === 'now'){
|
|
321
|
+
setPath(config, s.key, structuredClone(after[s.key]));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
changed: changed.map((s) => s.key),
|
|
326
|
+
now: changed.filter((s) => s.applies === 'now').map((s) => s.key),
|
|
327
|
+
restart: changed.filter((s) => s.applies === 'restart').map((s) => s.key)
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// thub-admin settings reset — the way back if a setting locked you out:
|
|
332
|
+
// the named settings (all when none) go back to the config file's value
|
|
333
|
+
// at the next start.
|
|
334
|
+
function reset(keys = []){
|
|
335
|
+
if (!keys.length){
|
|
336
|
+
return db.prepare('DELETE FROM settings').run().changes;
|
|
337
|
+
}
|
|
338
|
+
return keys.reduce((n, k) => n + stmts.remove.run(k).changes, 0);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const service = { list, save, reset, applyAtStartup, SETTINGS };
|
|
342
|
+
return service;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = { createSettingsService, SETTINGS };
|