@andrian.yablonskyy/thub-coordinator 1.0.23 → 1.0.25
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 +17 -4
- package/package.json +1 -1
- package/src/api/admin.js +8 -2
- package/src/config.js +15 -2
- package/src/server.js +4 -0
- package/src/services/cleanup.js +147 -0
- package/src/services/jobs.js +48 -13
- package/src/services/registry.js +18 -6
- package/src/services/scheduler.js +6 -1
- package/src/web/routes.js +55 -5
- package/test/cleanup.test.js +125 -0
- package/test/scheduler.test.js +40 -0
- package/views/jobs/list.pug +50 -15
- package/views/mixins/confirm-modal.pug +3 -2
package/bin/thub-admin.js
CHANGED
|
@@ -32,8 +32,9 @@ 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
|
+
{ createCleanupService } = require('../src/services/cleanup'),
|
|
35
36
|
{ generateToken } = require('../src/services/tokens'),
|
|
36
|
-
{ PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin } = require('@andrian.yablonskyy/thub-common'),
|
|
37
|
+
{ PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
|
|
37
38
|
{ spawnSync } = require('node:child_process'),
|
|
38
39
|
{ version: installedVersion } = require('../package.json');
|
|
39
40
|
|
|
@@ -44,7 +45,10 @@ function usage(){
|
|
|
44
45
|
thub-admin join-key generate
|
|
45
46
|
thub-admin resource maintenance <resourceId> --on|--off
|
|
46
47
|
thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
|
|
47
|
-
thub-admin jobs clean --yes
|
|
48
|
+
thub-admin jobs clean --yes [--before "dd/mm/yyyy HH:MM:SS"]
|
|
49
|
+
Permanently delete finished jobs (with logs and artifacts) and
|
|
50
|
+
history from before then (local time; default: now), then
|
|
51
|
+
compact the database file
|
|
48
52
|
thub-admin group add <name> [--comment <text>]
|
|
49
53
|
thub-admin group list
|
|
50
54
|
thub-admin group remove <groupId>
|
|
@@ -192,8 +196,17 @@ function main(){
|
|
|
192
196
|
console.error('This permanently deletes finished jobs and their logs/artifacts. Re-run with --yes to confirm.');
|
|
193
197
|
process.exit(4);
|
|
194
198
|
}
|
|
195
|
-
const
|
|
196
|
-
|
|
199
|
+
const before = flags.before ? parseDateTime(flags.before) : new Date();
|
|
200
|
+
if (!before){
|
|
201
|
+
console.error(`--before "${flags.before}" isn't a date/time in the dd/mm/yyyy HH:MM:SS format`);
|
|
202
|
+
process.exit(4);
|
|
203
|
+
}
|
|
204
|
+
const r = createCleanupService(db, { jobs, config }).cleanup({ before }),
|
|
205
|
+
mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
206
|
+
console.log(
|
|
207
|
+
`Deleted ${r.jobs} finished job(s) and ${r.events} history record(s) from before ${formatDateTime(r.before)}. ` +
|
|
208
|
+
`Database: ${mb(r.bytesBefore)} -> ${mb(r.bytesAfter)}.`
|
|
209
|
+
);
|
|
197
210
|
return;
|
|
198
211
|
}
|
|
199
212
|
|
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.25",
|
|
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": {
|
package/src/api/admin.js
CHANGED
|
@@ -82,9 +82,15 @@ function createAdminRouter({ services }){
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
// Permanently deletes finished jobs and their logs/artifacts (§13.1).
|
|
85
|
+
// Database cleanup (§9): optional `before` (ISO 8601; default now) —
|
|
86
|
+
// finished jobs that ended before it, older history, then VACUUM.
|
|
85
87
|
router.post('/jobs/clean-history', (req, res) => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
+
const before = req.body?.before ? new Date(req.body.before) : new Date();
|
|
89
|
+
if (Number.isNaN(before.getTime())){
|
|
90
|
+
return res.status(400).json({ error: 'before must be an ISO 8601 date/time' });
|
|
91
|
+
}
|
|
92
|
+
const r = services.cleanup.cleanup({ before });
|
|
93
|
+
res.json({ deleted: r.jobs, events: r.events, before: r.before, bytesBefore: r.bytesBefore, bytesAfter: r.bytesAfter });
|
|
88
94
|
});
|
|
89
95
|
|
|
90
96
|
// Resource groups (§13.1) — membership itself is declared by each
|
package/src/config.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
const fs = require('node:fs'),
|
|
17
17
|
os = require('node:os'),
|
|
18
|
-
path = require('node:path')
|
|
18
|
+
path = require('node:path'),
|
|
19
|
+
{ JOB_RETENTION } = require('./services/cleanup');
|
|
19
20
|
|
|
20
21
|
// Defaults mirror README.md §13.
|
|
21
22
|
const DEFAULTS = {
|
|
@@ -47,7 +48,12 @@ const DEFAULTS = {
|
|
|
47
48
|
},
|
|
48
49
|
retention: {
|
|
49
50
|
logRetentionDays: 14,
|
|
50
|
-
artifactRetentionDays: 30
|
|
51
|
+
artifactRetentionDays: 30,
|
|
52
|
+
// How long a finished job (with its logs, artifacts and events) is kept
|
|
53
|
+
// before the hourly retention task wipes it (services/cleanup.js):
|
|
54
|
+
// 1w | 2w | 1m | 3m | 6m | forever. `forever` keeps everything until a
|
|
55
|
+
// manual cleanup (dashboard Clean up database / thub-admin jobs clean).
|
|
56
|
+
jobRetention: 'forever'
|
|
51
57
|
},
|
|
52
58
|
artifacts: {
|
|
53
59
|
maxUploadMb: 512,
|
|
@@ -116,6 +122,13 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
|
|
|
116
122
|
config.clientJoinKey = process.env.THUB_CLIENT_JOIN_KEY;
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
if (!Object.hasOwn(JOB_RETENTION, config.retention.jobRetention)){
|
|
126
|
+
throw new Error(
|
|
127
|
+
`retention.jobRetention must be one of: ${Object.keys(JOB_RETENTION).join(', ')} ` +
|
|
128
|
+
`(got ${JSON.stringify(config.retention.jobRetention)}) in ${candidate || 'the config'}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
config.configPath = candidate || null;
|
|
120
133
|
config.dataDir = path.resolve(config.dataDir);
|
|
121
134
|
|
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
|
+
{ createCleanupService } = require('./services/cleanup'),
|
|
37
38
|
{ createUpdatesService } = require('./services/updates'),
|
|
38
39
|
|
|
39
40
|
{ createAgentRouter } = require('./api/agent'),
|
|
@@ -55,10 +56,12 @@ function buildServices(config){
|
|
|
55
56
|
scheduler = createScheduler(db, { bus, events, registry, config }),
|
|
56
57
|
heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
|
|
57
58
|
retention = createRetentionService(db, { bus, logs, artifacts, config }),
|
|
59
|
+
cleanup = createCleanupService(db, { jobs, config }),
|
|
58
60
|
updates = createUpdatesService({ config });
|
|
59
61
|
|
|
60
62
|
jobs.reconcileOnStartup();
|
|
61
63
|
updates.start();
|
|
64
|
+
cleanup.start();
|
|
62
65
|
|
|
63
66
|
const sweepInterval = setInterval(() => {
|
|
64
67
|
jobs.checkAssignAcks();
|
|
@@ -81,6 +84,7 @@ function buildServices(config){
|
|
|
81
84
|
scheduler,
|
|
82
85
|
heartbeatMonitor,
|
|
83
86
|
retention,
|
|
87
|
+
cleanup,
|
|
84
88
|
updates
|
|
85
89
|
};
|
|
86
90
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/cleanup.js
|
|
3
|
+
* @description Database cleanup: wipe finished jobs (logs, artifacts, events) and old events before a cutoff, then
|
|
4
|
+
* VACUUM to shrink the file — on demand from the dashboard, and hourly per retention.jobRetention (README §9)
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
|
|
19
|
+
// retention.jobRetention values: how long a finished job is kept. `forever`
|
|
20
|
+
// means only a manual cleanup (dashboard / POST /admin/jobs/clean-history)
|
|
21
|
+
// removes jobs. Months are calendar months.
|
|
22
|
+
const JOB_RETENTION = {
|
|
23
|
+
'1w': { days: 7, label: '1 week' },
|
|
24
|
+
'2w': { days: 14, label: '2 weeks' },
|
|
25
|
+
'1m': { months: 1, label: '1 month' },
|
|
26
|
+
'3m': { months: 3, label: '3 months' },
|
|
27
|
+
'6m': { months: 6, label: '6 months' },
|
|
28
|
+
forever: { label: 'indefinitely' }
|
|
29
|
+
},
|
|
30
|
+
PRUNE_INTERVAL_MS = 3600 * 1000,
|
|
31
|
+
FIRST_PRUNE_DELAY_MS = 60 * 1000,
|
|
32
|
+
// VACUUM rewrites the whole file and blocks meanwhile — the automatic
|
|
33
|
+
// task does it at most this often; a manual cleanup always does.
|
|
34
|
+
AUTO_VACUUM_MIN_INTERVAL_MS = 24 * 3600 * 1000;
|
|
35
|
+
|
|
36
|
+
// `now` minus `amount` ({days} or {months}), in UTC so the server's own
|
|
37
|
+
// time zone and its DST changes don't shift the cutoff by an hour.
|
|
38
|
+
function subtract(now, { days, months }){
|
|
39
|
+
const d = new Date(now);
|
|
40
|
+
if (months){
|
|
41
|
+
d.setUTCMonth(d.getUTCMonth() - months);
|
|
42
|
+
}
|
|
43
|
+
if (days){
|
|
44
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
45
|
+
}
|
|
46
|
+
return d;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Cutoff for a retention setting: jobs that finished before it go. null for
|
|
50
|
+
// `forever`. Throws on an unknown setting (config.js validates it at load).
|
|
51
|
+
function retentionCutoff(setting, now = new Date()){
|
|
52
|
+
const def = JOB_RETENTION[setting];
|
|
53
|
+
if (!def){
|
|
54
|
+
throw new Error(`retention.jobRetention must be one of: ${Object.keys(JOB_RETENTION).join(', ')} (got "${setting}")`);
|
|
55
|
+
}
|
|
56
|
+
return def.days || def.months ? subtract(now, def) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createCleanupService(db, { jobs, config, now = () => new Date() }){
|
|
60
|
+
let lastVacuumAt = 0,
|
|
61
|
+
timer = null,
|
|
62
|
+
firstRun = null;
|
|
63
|
+
|
|
64
|
+
// thub.db plus its WAL — what the database takes on disk.
|
|
65
|
+
function databaseBytes(){
|
|
66
|
+
return [config.dbPath, `${config.dbPath}-wal`].reduce((sum, file) => {
|
|
67
|
+
try {
|
|
68
|
+
return sum + fs.statSync(file).size;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return sum;
|
|
72
|
+
}
|
|
73
|
+
}, 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function vacuum(){
|
|
77
|
+
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
78
|
+
db.exec('VACUUM');
|
|
79
|
+
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
80
|
+
lastVacuumAt = Date.now();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Wipe everything older than `before` (a Date; default now): finished
|
|
84
|
+
// jobs that ended before it, with their logs, artifacts and events, and
|
|
85
|
+
// the other audit events (resource/agent/group) logged before it. Events
|
|
86
|
+
// of jobs that stay (still active, or newer) are kept. `vacuum`: true,
|
|
87
|
+
// false, or 'auto' (only if something was deleted and the last VACUUM is
|
|
88
|
+
// over a day old). Returns what was done, with the size before/after.
|
|
89
|
+
function cleanup({ before = now(), vacuum: doVacuum = true } = {}){
|
|
90
|
+
const cutoff = before.toISOString(),
|
|
91
|
+
bytesBefore = config.dbPath ? databaseBytes() : 0,
|
|
92
|
+
deletedJobs = jobs.purgeFinishedBefore(cutoff),
|
|
93
|
+
deletedEvents = db.prepare(
|
|
94
|
+
`DELETE FROM events WHERE ts < ?
|
|
95
|
+
AND NOT (entity = 'job' AND entity_id IN (SELECT id FROM jobs))
|
|
96
|
+
AND NOT (entity = 'job' AND entity_id = 'bulk' AND type = 'jobs.cleaned')`
|
|
97
|
+
).run(cutoff).changes,
|
|
98
|
+
vacuumed = doVacuum === true ||
|
|
99
|
+
(doVacuum === 'auto' && deletedJobs + deletedEvents > 0 && Date.now() - lastVacuumAt >= AUTO_VACUUM_MIN_INTERVAL_MS);
|
|
100
|
+
if (vacuumed){
|
|
101
|
+
vacuum();
|
|
102
|
+
}
|
|
103
|
+
return { before: cutoff, jobs: deletedJobs, events: deletedEvents, vacuumed, bytesBefore, bytesAfter: config.dbPath ? databaseBytes() : 0 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The retention task: hourly (first run a minute after start), wipes jobs
|
|
107
|
+
// older than retention.jobRetention. Does nothing for `forever`.
|
|
108
|
+
function prune(){
|
|
109
|
+
const cutoff = retentionCutoff(config.retention.jobRetention, now());
|
|
110
|
+
if (!cutoff){
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const result = cleanup({ before: cutoff, vacuum: 'auto' });
|
|
114
|
+
if (result.jobs || result.events){
|
|
115
|
+
console.log(
|
|
116
|
+
`Retention (${JOB_RETENTION[config.retention.jobRetention].label}): removed ${result.jobs} job(s) and ${result.events} event(s) ` +
|
|
117
|
+
`from before ${result.before}${result.vacuumed ? `; database ${result.bytesBefore} -> ${result.bytesAfter} bytes` : ''}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function safePrune(){
|
|
124
|
+
try {
|
|
125
|
+
prune();
|
|
126
|
+
}
|
|
127
|
+
catch (err){
|
|
128
|
+
console.error(`Retention cleanup failed: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function start(){
|
|
133
|
+
firstRun = setTimeout(safePrune, FIRST_PRUNE_DELAY_MS);
|
|
134
|
+
firstRun.unref?.();
|
|
135
|
+
timer = setInterval(safePrune, PRUNE_INTERVAL_MS);
|
|
136
|
+
timer.unref?.();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stop(){
|
|
140
|
+
clearTimeout(firstRun);
|
|
141
|
+
clearInterval(timer);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { cleanup, prune, databaseBytes, start, stop };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { createCleanupService, retentionCutoff, JOB_RETENTION };
|
package/src/services/jobs.js
CHANGED
|
@@ -145,7 +145,27 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
145
145
|
spec.target.client = resource.id;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
// A job-supplied Docker image or command needs a Client that opted in —
|
|
149
|
+
// say which, rather than the generic "no resource can satisfy".
|
|
150
|
+
const needs = { jobImage: Boolean(spec.firmware.image), jobCommand: Boolean(spec.tests.command) },
|
|
151
|
+
{ type, labels, group, client } = spec.target;
|
|
152
|
+
if ((needs.jobImage || needs.jobCommand) && registry.everSatisfiable(type, labels, group, client) &&
|
|
153
|
+
!registry.everSatisfiable(type, labels, group, client, needs)){
|
|
154
|
+
const missing = [
|
|
155
|
+
needs.jobImage && !registry.everSatisfiable(type, labels, group, client, { jobImage: true }) &&
|
|
156
|
+
`job-supplied Docker images (${spec.firmware.image}) — "sw": { "allowJobImages": true }`,
|
|
157
|
+
needs.jobCommand && !registry.everSatisfiable(type, labels, group, client, { jobCommand: true }) &&
|
|
158
|
+
'job-supplied commands (--run) — "allowJobCommands": true'
|
|
159
|
+
].filter(Boolean);
|
|
160
|
+
throw Object.assign(
|
|
161
|
+
new Error(
|
|
162
|
+
`No matching Client runs ${missing.length ? missing.join(', nor ') : 'both a job-supplied Docker image and command together'}` +
|
|
163
|
+
' — enable it in a Client\'s config'
|
|
164
|
+
),
|
|
165
|
+
{ status: 422 }
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!registry.everSatisfiable(type, labels, group, client, needs)){
|
|
149
169
|
throw Object.assign(
|
|
150
170
|
new Error(
|
|
151
171
|
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
|
@@ -416,24 +436,38 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
416
436
|
// not just active work, needs clearing out. Unlike the nightly retention
|
|
417
437
|
// sweep (§9), this runs on demand and isn't limited to old jobs.
|
|
418
438
|
function cleanHistory(){
|
|
439
|
+
return purgeFinishedBefore(null);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Finished jobs that ended before `beforeIso` (null: all of them), with
|
|
443
|
+
// their logs, artifacts (files too) and events — manual cleanup and the
|
|
444
|
+
// job retention task (services/cleanup.js). Active jobs are never touched.
|
|
445
|
+
function purgeFinishedBefore(beforeIso){
|
|
419
446
|
const placeholders = [...TERMINAL_JOB_STATES].map(() => '?').join(','),
|
|
420
|
-
rows =
|
|
447
|
+
rows = beforeIso
|
|
448
|
+
? db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders}) AND COALESCE(finished_at, created_at) < ?`)
|
|
449
|
+
.all(...TERMINAL_JOB_STATES, beforeIso)
|
|
450
|
+
: db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders})`).all(...TERMINAL_JOB_STATES),
|
|
421
451
|
ids = rows.map((r) => r.id);
|
|
422
452
|
if (ids.length === 0){
|
|
423
453
|
return 0;
|
|
424
454
|
}
|
|
425
455
|
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
456
|
+
const deleteArtifacts = db.prepare('DELETE FROM artifacts WHERE job_id = ?'),
|
|
457
|
+
deleteLogs = db.prepare('DELETE FROM job_logs WHERE job_id = ?'),
|
|
458
|
+
deleteEvents = db.prepare('DELETE FROM events WHERE entity = \'job\' AND entity_id = ?'),
|
|
459
|
+
deleteJob = db.prepare('DELETE FROM jobs WHERE id = ?'),
|
|
460
|
+
tx = db.transaction(() => {
|
|
461
|
+
for (const id of ids){
|
|
462
|
+
artifacts.deleteJobArtifacts(id);
|
|
463
|
+
deleteArtifacts.run(id);
|
|
464
|
+
deleteLogs.run(id);
|
|
465
|
+
deleteEvents.run(id);
|
|
466
|
+
deleteJob.run(id);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
435
469
|
tx();
|
|
436
|
-
events.record('job', 'bulk', 'jobs.cleaned', { count: ids.length });
|
|
470
|
+
events.record('job', 'bulk', 'jobs.cleaned', { count: ids.length, before: beforeIso });
|
|
437
471
|
return ids.length;
|
|
438
472
|
}
|
|
439
473
|
|
|
@@ -470,7 +504,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
470
504
|
reconcileOnStartup,
|
|
471
505
|
resetQueue,
|
|
472
506
|
countActiveAndFinished,
|
|
473
|
-
cleanHistory
|
|
507
|
+
cleanHistory,
|
|
508
|
+
purgeFinishedBefore
|
|
474
509
|
};
|
|
475
510
|
}
|
|
476
511
|
|
package/src/services/registry.js
CHANGED
|
@@ -67,10 +67,16 @@ const MAX_DEVICES = 8,
|
|
|
67
67
|
present: typeof d?.present === 'boolean' ? d.present : null
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
// Plus `allowJobCommands` (either type): runs a job's own `tests.command`.
|
|
70
71
|
function sanitizeCapabilities(caps){
|
|
71
72
|
if (!caps || typeof caps !== 'object'){
|
|
72
73
|
return null;
|
|
73
74
|
}
|
|
75
|
+
const typed = sanitizeTypedCapabilities(caps);
|
|
76
|
+
return typed ? { ...typed, allowJobCommands: caps.allowJobCommands === true } : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sanitizeTypedCapabilities(caps){
|
|
74
80
|
if (caps.sw && typeof caps.sw === 'object'){
|
|
75
81
|
const sw = caps.sw;
|
|
76
82
|
return {
|
|
@@ -78,6 +84,7 @@ function sanitizeCapabilities(caps){
|
|
|
78
84
|
image: str(sw.image),
|
|
79
85
|
registry: str(sw.registry),
|
|
80
86
|
allowDockerHub: sw.allowDockerHub === true,
|
|
87
|
+
allowJobImages: sw.allowJobImages === true,
|
|
81
88
|
cpus: num(sw.cpus),
|
|
82
89
|
memory: str(sw.memory, 16)
|
|
83
90
|
}
|
|
@@ -468,10 +475,15 @@ function createRegistryService(db, { bus, events }){
|
|
|
468
475
|
);
|
|
469
476
|
}
|
|
470
477
|
|
|
471
|
-
|
|
478
|
+
// `needs`: what a job brings that a Client must have opted in to run —
|
|
479
|
+
// jobImage (firmware.image; sw.allowJobImages) and jobCommand
|
|
480
|
+
// (tests.command; allowJobCommands).
|
|
481
|
+
function matchesTarget(r, labels, groupId, resourceId, needs = {}){
|
|
472
482
|
return labels.every((l) => r.labels.includes(l)) &&
|
|
473
483
|
(!groupId || r.group_ids.includes(groupId)) &&
|
|
474
|
-
(!resourceId || r.id === resourceId)
|
|
484
|
+
(!resourceId || r.id === resourceId) &&
|
|
485
|
+
(!needs.jobImage || r.capabilities?.sw?.allowJobImages === true) &&
|
|
486
|
+
(!needs.jobCommand || r.capabilities?.allowJobCommands === true);
|
|
475
487
|
}
|
|
476
488
|
|
|
477
489
|
// Admin: remove a resource from the registry (dashboard "Remove"). Refused
|
|
@@ -509,17 +521,17 @@ function createRegistryService(db, { bus, events }){
|
|
|
509
521
|
return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
|
|
510
522
|
}
|
|
511
523
|
|
|
512
|
-
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
524
|
+
function findIdleCandidates(type, labels, groupId, resourceId, needs){
|
|
513
525
|
const rows = db
|
|
514
526
|
.prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
|
|
515
527
|
.all(RESOURCE_STATES.IDLE, type)
|
|
516
528
|
.map(rowToResource);
|
|
517
|
-
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
529
|
+
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
518
530
|
}
|
|
519
531
|
|
|
520
|
-
function everSatisfiable(type, labels, groupId, resourceId){
|
|
532
|
+
function everSatisfiable(type, labels, groupId, resourceId, needs){
|
|
521
533
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
522
|
-
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
534
|
+
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
523
535
|
}
|
|
524
536
|
|
|
525
537
|
return {
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
|
|
17
17
|
|
|
18
|
+
// What a job needs a Client to have opted in to (registry matchesTarget).
|
|
19
|
+
function jobNeeds(spec){
|
|
20
|
+
return { jobImage: Boolean(spec.firmware?.image), jobCommand: Boolean(spec.tests?.command) };
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
function rowToJob(row){
|
|
19
24
|
return { ...row, spec: JSON.parse(row.spec) };
|
|
20
25
|
}
|
|
@@ -43,7 +48,7 @@ function createScheduler(db, { bus, events, registry, config }){
|
|
|
43
48
|
for (const job of queued){
|
|
44
49
|
const { type, labels = [], group, client } = job.spec.target,
|
|
45
50
|
candidates = registry
|
|
46
|
-
.findIdleCandidates(type, labels, group, client)
|
|
51
|
+
.findIdleCandidates(type, labels, group, client, jobNeeds(job.spec))
|
|
47
52
|
.filter((r) => !claimedResourceIds.has(r.id));
|
|
48
53
|
if (candidates.length === 0){
|
|
49
54
|
continue;
|
package/src/web/routes.js
CHANGED
|
@@ -20,7 +20,10 @@ const fs = require('node:fs'),
|
|
|
20
20
|
multer = require('multer'),
|
|
21
21
|
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
22
22
|
{ attachJobStream } = require('../api/sse'),
|
|
23
|
-
{
|
|
23
|
+
{
|
|
24
|
+
RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, isNewer, compareVersions, formatDateTime, parseDateTime
|
|
25
|
+
} = require('@andrian.yablonskyy/thub-common'),
|
|
26
|
+
{ retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
|
|
24
27
|
listPrefs = require('../services/list-prefs');
|
|
25
28
|
|
|
26
29
|
// Resources page sort keys (list-prefs.js) -> comparators. Empty values
|
|
@@ -92,6 +95,31 @@ function createWebRouter({ services, config }){
|
|
|
92
95
|
return { prefs, urlFor, pageSizes: listPrefs.PAGE_SIZES };
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
// What the Clean up database modal shows: current size, the oldest
|
|
99
|
+
// finished job, the retention setting, and quick-pick cutoffs — all
|
|
100
|
+
// formatted in the user's time zone, like the cutoff field itself.
|
|
101
|
+
function cleanupInfo(req){
|
|
102
|
+
const tz = req.session.user.timezone || 'UTC',
|
|
103
|
+
fmt = (d) => formatDateTime(d, { timeZone: tz }),
|
|
104
|
+
now = new Date(),
|
|
105
|
+
oldest = services.db.prepare(
|
|
106
|
+
`SELECT MIN(COALESCE(finished_at, created_at)) AS at FROM jobs
|
|
107
|
+
WHERE state IN (${[...TERMINAL_JOB_STATES].map(() => '?').join(',')})`
|
|
108
|
+
).get(...TERMINAL_JOB_STATES).at,
|
|
109
|
+
retention = config.retention.jobRetention;
|
|
110
|
+
return {
|
|
111
|
+
size: `${(services.cleanup.databaseBytes() / 1024 / 1024).toFixed(1)} MB`,
|
|
112
|
+
oldestFinished: oldest ? fmt(oldest) : null,
|
|
113
|
+
now: fmt(now),
|
|
114
|
+
retention,
|
|
115
|
+
retentionLabel: JOB_RETENTION[retention]?.label,
|
|
116
|
+
presets: [
|
|
117
|
+
{ label: 'Everything (now)', value: fmt(now) },
|
|
118
|
+
...['1w', '1m', '3m', '6m'].map((k) => ({ label: `Older than ${JOB_RETENTION[k].label}`, value: fmt(retentionCutoff(k, now)) }))
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
95
123
|
// The job each resource is on (if any), for the resource card's Cancel.
|
|
96
124
|
function withActiveJobs(resources){
|
|
97
125
|
return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
|
|
@@ -507,8 +535,9 @@ function createWebRouter({ services, config }){
|
|
|
507
535
|
// A row's Cancel comes back to this exact view (filters, sort, page).
|
|
508
536
|
returnTo: pageUrl(),
|
|
509
537
|
canManage: req.session.user.role === 'admin',
|
|
510
|
-
// For the Reset queue / Clean
|
|
511
|
-
jobCounts: req.session.user.role === 'admin' ? services.jobs.countActiveAndFinished() : null
|
|
538
|
+
// For the Reset queue / Clean up database confirmation modals.
|
|
539
|
+
jobCounts: req.session.user.role === 'admin' ? services.jobs.countActiveAndFinished() : null,
|
|
540
|
+
cleanupInfo: req.session.user.role === 'admin' ? cleanupInfo(req) : null
|
|
512
541
|
});
|
|
513
542
|
});
|
|
514
543
|
|
|
@@ -521,9 +550,30 @@ function createWebRouter({ services, config }){
|
|
|
521
550
|
res.redirect('/jobs');
|
|
522
551
|
});
|
|
523
552
|
|
|
553
|
+
// "Clean up database" (§9): everything before the given time — finished
|
|
554
|
+
// jobs with their logs/artifacts, and older history — then VACUUM. The
|
|
555
|
+
// time is dd/mm/yyyy HH:MM:SS in the user's own time zone.
|
|
524
556
|
router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
|
|
525
|
-
const
|
|
526
|
-
|
|
557
|
+
const tz = req.session.user.timezone || 'UTC',
|
|
558
|
+
text = String(req.body.before || '').trim(),
|
|
559
|
+
before = text ? parseDateTime(text, { timeZone: tz }) : new Date();
|
|
560
|
+
if (!before){
|
|
561
|
+
flash(req, 'danger', `"${text}" isn't a date/time in the dd/mm/yyyy HH:MM:SS format — nothing was deleted.`);
|
|
562
|
+
return res.redirect('/jobs');
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
const r = services.cleanup.cleanup({ before }),
|
|
566
|
+
mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
567
|
+
flash(
|
|
568
|
+
req,
|
|
569
|
+
'warning',
|
|
570
|
+
`Deleted ${r.jobs} finished job(s) and ${r.events} history record(s) from before ` +
|
|
571
|
+
`${formatDateTime(r.before, { timeZone: tz })}. Database: ${mb(r.bytesBefore)} → ${mb(r.bytesAfter)}.`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
catch (err){
|
|
575
|
+
flash(req, 'danger', `Cleanup failed: ${err.message}`);
|
|
576
|
+
}
|
|
527
577
|
res.redirect('/jobs');
|
|
528
578
|
});
|
|
529
579
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/cleanup.test.js
|
|
3
|
+
* @description Tests: database cleanup before a cutoff, job retention settings, and the retention task
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
os = require('node:os'),
|
|
20
|
+
path = require('node:path'),
|
|
21
|
+
{ EventEmitter } = require('node:events'),
|
|
22
|
+
{ openDb } = require('../src/db'),
|
|
23
|
+
{ createEventsService } = require('../src/services/events'),
|
|
24
|
+
{ createRegistryService } = require('../src/services/registry'),
|
|
25
|
+
{ createAgentsService } = require('../src/services/agents'),
|
|
26
|
+
{ createArtifactsService } = require('../src/services/artifacts'),
|
|
27
|
+
{ createJobsService } = require('../src/services/jobs'),
|
|
28
|
+
{ createCleanupService, retentionCutoff } = require('../src/services/cleanup'),
|
|
29
|
+
{ JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
30
|
+
|
|
31
|
+
const NOW = new Date('2026-09-28T12:00:00Z');
|
|
32
|
+
|
|
33
|
+
function setup(jobRetention = 'forever'){
|
|
34
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-cleanup-')),
|
|
35
|
+
config = {
|
|
36
|
+
dataDir,
|
|
37
|
+
dbPath: path.join(dataDir, 'thub.db'),
|
|
38
|
+
artifactsDir: path.join(dataDir, 'artifacts'),
|
|
39
|
+
artifacts: { linkTtlHours: 1 },
|
|
40
|
+
sessionSecret: 's',
|
|
41
|
+
publicUrl: 'http://x',
|
|
42
|
+
scheduler: { maxQueuedPerAgent: 1000 },
|
|
43
|
+
jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 },
|
|
44
|
+
retention: { jobRetention }
|
|
45
|
+
},
|
|
46
|
+
db = openDb(config.dbPath),
|
|
47
|
+
bus = new EventEmitter(),
|
|
48
|
+
events = createEventsService(db),
|
|
49
|
+
registry = createRegistryService(db, { bus, events }),
|
|
50
|
+
{ agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
|
|
51
|
+
artifacts = createArtifactsService(db, { config }),
|
|
52
|
+
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
53
|
+
cleanup = createCleanupService(db, { jobs, config, now: () => NOW });
|
|
54
|
+
registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
|
|
55
|
+
|
|
56
|
+
// A job that finished at `finishedAt` (or is still queued, if null), with a log line.
|
|
57
|
+
function job(finishedAt){
|
|
58
|
+
const spec = { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' } },
|
|
59
|
+
j = jobs.create({ agentId: agent.id, source: 'cli', spec });
|
|
60
|
+
db.prepare('INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, 1, ?, \'runner\', ?)').run(j.id, NOW.toISOString(), 'x'.repeat(2000));
|
|
61
|
+
if (finishedAt){
|
|
62
|
+
jobs.setState(j.id, JOB_STATES.PASSED);
|
|
63
|
+
db.prepare('UPDATE jobs SET finished_at = ? WHERE id = ?').run(finishedAt, j.id);
|
|
64
|
+
}
|
|
65
|
+
return j.id;
|
|
66
|
+
}
|
|
67
|
+
const exists = (id) => !!jobs.get(id),
|
|
68
|
+
eventAt = (entity, ts) => db.prepare('INSERT INTO events (ts, entity, entity_id, type) VALUES (?, ?, \'x\', \'t\')').run(ts, entity).lastInsertRowid;
|
|
69
|
+
return { db, jobs, cleanup, job, exists, eventAt };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('retentionCutoff: days, calendar months, and forever', () => {
|
|
73
|
+
assert.equal(retentionCutoff('1w', NOW).toISOString(), '2026-09-21T12:00:00.000Z');
|
|
74
|
+
assert.equal(retentionCutoff('2w', NOW).toISOString(), '2026-09-14T12:00:00.000Z');
|
|
75
|
+
assert.equal(retentionCutoff('1m', NOW).toISOString(), '2026-08-28T12:00:00.000Z');
|
|
76
|
+
assert.equal(retentionCutoff('6m', NOW).toISOString(), '2026-03-28T12:00:00.000Z');
|
|
77
|
+
assert.equal(retentionCutoff('forever', NOW), null);
|
|
78
|
+
assert.throws(() => retentionCutoff('1y', NOW), /must be one of/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('cleanup wipes finished jobs and history before the cutoff, keeps the rest, and compacts the file', () => {
|
|
82
|
+
const { db, cleanup, job, exists, eventAt } = setup(),
|
|
83
|
+
old = Array.from({ length: 40 }, () => job('2026-01-10T00:00:00Z')),
|
|
84
|
+
recent = job('2026-09-27T00:00:00Z'),
|
|
85
|
+
active = job(null),
|
|
86
|
+
oldResourceEvent = eventAt('resource', '2026-01-01T00:00:00Z'),
|
|
87
|
+
newResourceEvent = eventAt('resource', '2026-09-27T00:00:00Z'),
|
|
88
|
+
|
|
89
|
+
r = cleanup.cleanup({ before: new Date('2026-06-01T00:00:00Z') });
|
|
90
|
+
assert.equal(r.jobs, 40);
|
|
91
|
+
assert.ok(old.every((id) => !exists(id)));
|
|
92
|
+
assert.ok(exists(recent) && exists(active));
|
|
93
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM job_logs').get().n, 2); // recent + active only
|
|
94
|
+
const eventIds = db.prepare('SELECT id FROM events').all().map((e) => e.id);
|
|
95
|
+
assert.ok(!eventIds.includes(oldResourceEvent) && eventIds.includes(newResourceEvent));
|
|
96
|
+
assert.equal(r.vacuumed, true);
|
|
97
|
+
assert.ok(r.bytesAfter < r.bytesBefore, `${r.bytesAfter} < ${r.bytesBefore}`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('an old but still active job is never wiped, nor its events', () => {
|
|
101
|
+
const { db, cleanup, job, exists } = setup(),
|
|
102
|
+
active = job(null);
|
|
103
|
+
db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run('2020-01-01T00:00:00Z', active);
|
|
104
|
+
db.prepare('UPDATE events SET ts = ? WHERE entity_id = ?').run('2020-01-01T00:00:00Z', active);
|
|
105
|
+
cleanup.cleanup({ before: NOW });
|
|
106
|
+
assert.ok(exists(active));
|
|
107
|
+
assert.ok(db.prepare('SELECT COUNT(*) AS n FROM events WHERE entity_id = ?').get(active).n > 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('the retention task wipes per jobRetention, and does nothing for forever', () => {
|
|
111
|
+
const kept = setup('forever'),
|
|
112
|
+
id = kept.job('2025-01-01T00:00:00Z');
|
|
113
|
+
assert.equal(kept.cleanup.prune(), null);
|
|
114
|
+
assert.ok(kept.exists(id));
|
|
115
|
+
|
|
116
|
+
const pruned = setup('1m'),
|
|
117
|
+
tooOld = pruned.job('2026-08-01T00:00:00Z'),
|
|
118
|
+
young = pruned.job('2026-09-01T00:00:00Z'),
|
|
119
|
+
r = pruned.cleanup.prune();
|
|
120
|
+
assert.equal(r.before, '2026-08-28T12:00:00.000Z');
|
|
121
|
+
assert.ok(!pruned.exists(tooOld) && pruned.exists(young));
|
|
122
|
+
assert.equal(r.vacuumed, true); // first automatic run: compacts
|
|
123
|
+
pruned.job('2026-08-02T00:00:00Z');
|
|
124
|
+
assert.equal(pruned.cleanup.prune().vacuumed, false); // at most once a day
|
|
125
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -525,6 +525,46 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
|
|
|
525
525
|
assert.equal(again.name, 'lab-back');
|
|
526
526
|
});
|
|
527
527
|
|
|
528
|
+
test('a job-supplied Docker image only goes to SW Clients that allow it; otherwise 422', () => {
|
|
529
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
530
|
+
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
531
|
+
imageJob = { target: { type: 'sw', labels: [] }, firmware: { image: 'alpine' }, tests: { url: 'https://x/t.tar.gz' } },
|
|
532
|
+
strict = registerResource(registry, { name: 'sw-strict', type: 'sw' });
|
|
533
|
+
registry.heartbeat(strict.id, { state: 'idle' });
|
|
534
|
+
|
|
535
|
+
assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: imageJob }), /allowJobImages/);
|
|
536
|
+
|
|
537
|
+
const { resourceId } = registry.registerAuto({
|
|
538
|
+
clientId: 'c-open', name: 'sw-open', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: true } }
|
|
539
|
+
});
|
|
540
|
+
registry.heartbeat(resourceId, { state: 'idle' });
|
|
541
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: imageJob });
|
|
542
|
+
scheduler.runPass();
|
|
543
|
+
assert.equal(jobs.get(job.id).resource_id, resourceId); // not sw-strict, though it's idle too
|
|
544
|
+
|
|
545
|
+
const fileJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
546
|
+
scheduler.runPass();
|
|
547
|
+
assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // firmware URLs still go anywhere
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('a job-supplied command only goes to Clients with allowJobCommands; otherwise 422 naming it', () => {
|
|
551
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
552
|
+
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
553
|
+
commandJob = makeSpec({ tests: { url: 'https://x/t.tar.gz', command: 'make test' } }),
|
|
554
|
+
plain = registerResource(registry, { name: 'sw-plain', type: 'sw' });
|
|
555
|
+
registry.heartbeat(plain.id, { state: 'idle' });
|
|
556
|
+
|
|
557
|
+
assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob }), /allowJobCommands/);
|
|
558
|
+
|
|
559
|
+
const { resourceId } = registry.registerAuto({
|
|
560
|
+
clientId: 'c-cmd', name: 'sw-cmd', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, allowJobCommands: true }
|
|
561
|
+
});
|
|
562
|
+
registry.heartbeat(resourceId, { state: 'idle' });
|
|
563
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob });
|
|
564
|
+
scheduler.runPass();
|
|
565
|
+
assert.equal(jobs.get(job.id).resource_id, resourceId);
|
|
566
|
+
});
|
|
567
|
+
|
|
528
568
|
test('a resource can belong to several groups at once', () => {
|
|
529
569
|
const { registry, groups } = buildTestServices(),
|
|
530
570
|
g1 = groups.create({ name: 'g1' }),
|
package/views/jobs/list.pug
CHANGED
|
@@ -9,7 +9,7 @@ block content
|
|
|
9
9
|
if canManage
|
|
10
10
|
.btn-group
|
|
11
11
|
button.btn.btn-sm.btn-outline-warning(type="button" data-bs-toggle="modal" data-bs-target="#reset-queue-modal") Reset queue
|
|
12
|
-
button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean
|
|
12
|
+
button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean up database
|
|
13
13
|
|
|
14
14
|
form.row.g-2.mb-3(method="get")
|
|
15
15
|
.col-auto
|
|
@@ -90,22 +90,57 @@ block content
|
|
|
90
90
|
|
|
91
91
|
+confirmModal({
|
|
92
92
|
id: 'clean-history-modal',
|
|
93
|
+
formId: 'cleanup-form',
|
|
93
94
|
tone: 'danger',
|
|
94
95
|
icon: 'bi-trash3-fill',
|
|
95
|
-
title: 'Clean
|
|
96
|
+
title: 'Clean up the database?',
|
|
96
97
|
action: '/jobs/clean-history',
|
|
97
|
-
confirmLabel: 'Delete
|
|
98
|
-
confirmIcon: 'bi-trash3'
|
|
99
|
-
disabled: !jobCounts.finished
|
|
98
|
+
confirmLabel: 'Delete and compact',
|
|
99
|
+
confirmIcon: 'bi-trash3'
|
|
100
100
|
})
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
p.small.text-body-secondary
|
|
102
|
+
| Database size:
|
|
103
|
+
|
|
|
104
|
+
strong= cleanupInfo.size
|
|
105
|
+
| . Finished jobs:
|
|
106
|
+
|
|
|
107
|
+
strong= jobCounts.finished
|
|
108
|
+
if cleanupInfo.oldestFinished
|
|
109
|
+
| (oldest from #{cleanupInfo.oldestFinished})
|
|
110
|
+
| .
|
|
111
|
+
label.form-label(for="cleanup-before") Delete everything before
|
|
112
|
+
input.form-control#cleanup-before(
|
|
113
|
+
type="text"
|
|
114
|
+
name="before"
|
|
115
|
+
form="cleanup-form"
|
|
116
|
+
value=cleanupInfo.now
|
|
117
|
+
required
|
|
118
|
+
pattern="\\s*\\d{1,2}/\\d{1,2}/\\d{4}( +\\d{1,2}:\\d{2}(:\\d{2})?)?\\s*"
|
|
119
|
+
placeholder="dd/mm/yyyy HH:MM:SS"
|
|
120
|
+
aria-describedby="cleanup-before-help"
|
|
121
|
+
)
|
|
122
|
+
.form-text#cleanup-before-help
|
|
123
|
+
| dd/mm/yyyy HH:MM:SS, 24-hour, in your time zone (#{userTimeZone}).
|
|
124
|
+
.d-flex.flex-wrap.gap-1.mt-2.mb-3
|
|
125
|
+
each preset in cleanupInfo.presets
|
|
126
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
127
|
+
type="button"
|
|
128
|
+
data-value=preset.value
|
|
129
|
+
onclick="document.getElementById('cleanup-before').value = this.dataset.value"
|
|
130
|
+
)= preset.label
|
|
131
|
+
p
|
|
132
|
+
| Deletes finished jobs that ended before then, with their logs and artifacts (files too), and the resource, agent and group history logged before then — then compacts the database file.
|
|
133
|
+
|
|
|
134
|
+
strong This cannot be undone.
|
|
135
|
+
| Active jobs are never touched.
|
|
136
|
+
p.small.text-body-secondary.mb-0
|
|
137
|
+
i.bi.bi-clock-history.me-1
|
|
138
|
+
if cleanupInfo.retention === 'forever'
|
|
139
|
+
| Automatic cleanup is off: finished jobs are kept indefinitely. Set
|
|
104
140
|
|
|
|
105
|
-
|
|
106
|
-
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
p.mb-0 There are no finished jobs — nothing to delete.
|
|
141
|
+
code retention.jobRetention
|
|
142
|
+
| in coordinator.json (1w, 2w, 1m, 3m, 6m) to wipe older ones every hour.
|
|
143
|
+
else
|
|
144
|
+
| Automatic cleanup: finished jobs are kept for #{cleanupInfo.retentionLabel} (
|
|
145
|
+
code retention.jobRetention
|
|
146
|
+
| ) and older ones are wiped every hour.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
//- data-bs-target="#<opts.id>" (no JS needed). The block is the body text.
|
|
4
4
|
//- opts: id, title, action, confirmLabel, and optional tone (Bootstrap
|
|
5
5
|
//- color, default danger), icon / confirmIcon (bootstrap-icons classes),
|
|
6
|
-
//- disabled (nothing to do — the confirm button is disabled)
|
|
6
|
+
//- disabled (nothing to do — the confirm button is disabled), formId (id of
|
|
7
|
+
//- the footer form, so inputs in the body can join it with form=<formId>).
|
|
7
8
|
mixin confirmModal(opts)
|
|
8
9
|
- const tone = opts.tone || 'danger'
|
|
9
10
|
.modal.fade(id=opts.id tabindex="-1" aria-labelledby=`${opts.id}-title` aria-hidden="true")
|
|
@@ -19,7 +20,7 @@ mixin confirmModal(opts)
|
|
|
19
20
|
block
|
|
20
21
|
.modal-footer
|
|
21
22
|
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Keep
|
|
22
|
-
form(method="post" action=opts.action)
|
|
23
|
+
form(method="post" action=opts.action id=opts.formId)
|
|
23
24
|
button.btn(type="submit" class=`btn-${tone}` disabled=opts.disabled)
|
|
24
25
|
if opts.confirmIcon
|
|
25
26
|
i.bi.me-1(class=opts.confirmIcon)
|