@andrian.yablonskyy/thub-coordinator 1.0.23 → 1.0.24
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 +41 -13
- package/src/services/registry.js +10 -6
- package/src/services/scheduler.js +1 -1
- package/src/web/routes.js +55 -5
- package/test/cleanup.test.js +125 -0
- package/test/scheduler.test.js +22 -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.24",
|
|
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,20 @@ 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 needs an SW Client that opted in — say so
|
|
149
|
+
// specifically rather than the generic "no resource can satisfy".
|
|
150
|
+
const needs = { jobImage: Boolean(spec.firmware.image) },
|
|
151
|
+
{ type, labels, group, client } = spec.target;
|
|
152
|
+
if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
|
|
153
|
+
throw Object.assign(
|
|
154
|
+
new Error(
|
|
155
|
+
`No matching SW Client runs job-supplied Docker images (${spec.firmware.image}) — enable it on a Client ` +
|
|
156
|
+
'with "sw": { "allowJobImages": true } in its config, or pass a firmware URL instead'
|
|
157
|
+
),
|
|
158
|
+
{ status: 422 }
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!registry.everSatisfiable(type, labels, group, client, needs)){
|
|
149
162
|
throw Object.assign(
|
|
150
163
|
new Error(
|
|
151
164
|
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
|
@@ -416,24 +429,38 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
416
429
|
// not just active work, needs clearing out. Unlike the nightly retention
|
|
417
430
|
// sweep (§9), this runs on demand and isn't limited to old jobs.
|
|
418
431
|
function cleanHistory(){
|
|
432
|
+
return purgeFinishedBefore(null);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Finished jobs that ended before `beforeIso` (null: all of them), with
|
|
436
|
+
// their logs, artifacts (files too) and events — manual cleanup and the
|
|
437
|
+
// job retention task (services/cleanup.js). Active jobs are never touched.
|
|
438
|
+
function purgeFinishedBefore(beforeIso){
|
|
419
439
|
const placeholders = [...TERMINAL_JOB_STATES].map(() => '?').join(','),
|
|
420
|
-
rows =
|
|
440
|
+
rows = beforeIso
|
|
441
|
+
? db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders}) AND COALESCE(finished_at, created_at) < ?`)
|
|
442
|
+
.all(...TERMINAL_JOB_STATES, beforeIso)
|
|
443
|
+
: db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders})`).all(...TERMINAL_JOB_STATES),
|
|
421
444
|
ids = rows.map((r) => r.id);
|
|
422
445
|
if (ids.length === 0){
|
|
423
446
|
return 0;
|
|
424
447
|
}
|
|
425
448
|
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
449
|
+
const deleteArtifacts = db.prepare('DELETE FROM artifacts WHERE job_id = ?'),
|
|
450
|
+
deleteLogs = db.prepare('DELETE FROM job_logs WHERE job_id = ?'),
|
|
451
|
+
deleteEvents = db.prepare('DELETE FROM events WHERE entity = \'job\' AND entity_id = ?'),
|
|
452
|
+
deleteJob = db.prepare('DELETE FROM jobs WHERE id = ?'),
|
|
453
|
+
tx = db.transaction(() => {
|
|
454
|
+
for (const id of ids){
|
|
455
|
+
artifacts.deleteJobArtifacts(id);
|
|
456
|
+
deleteArtifacts.run(id);
|
|
457
|
+
deleteLogs.run(id);
|
|
458
|
+
deleteEvents.run(id);
|
|
459
|
+
deleteJob.run(id);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
435
462
|
tx();
|
|
436
|
-
events.record('job', 'bulk', 'jobs.cleaned', { count: ids.length });
|
|
463
|
+
events.record('job', 'bulk', 'jobs.cleaned', { count: ids.length, before: beforeIso });
|
|
437
464
|
return ids.length;
|
|
438
465
|
}
|
|
439
466
|
|
|
@@ -470,7 +497,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
470
497
|
reconcileOnStartup,
|
|
471
498
|
resetQueue,
|
|
472
499
|
countActiveAndFinished,
|
|
473
|
-
cleanHistory
|
|
500
|
+
cleanHistory,
|
|
501
|
+
purgeFinishedBefore
|
|
474
502
|
};
|
|
475
503
|
}
|
|
476
504
|
|
package/src/services/registry.js
CHANGED
|
@@ -78,6 +78,7 @@ function sanitizeCapabilities(caps){
|
|
|
78
78
|
image: str(sw.image),
|
|
79
79
|
registry: str(sw.registry),
|
|
80
80
|
allowDockerHub: sw.allowDockerHub === true,
|
|
81
|
+
allowJobImages: sw.allowJobImages === true,
|
|
81
82
|
cpus: num(sw.cpus),
|
|
82
83
|
memory: str(sw.memory, 16)
|
|
83
84
|
}
|
|
@@ -468,10 +469,13 @@ function createRegistryService(db, { bus, events }){
|
|
|
468
469
|
);
|
|
469
470
|
}
|
|
470
471
|
|
|
471
|
-
|
|
472
|
+
// `needs.jobImage`: the job brings its own Docker image (firmware.image),
|
|
473
|
+
// which only SW Clients that opted in (sw.allowJobImages) will run.
|
|
474
|
+
function matchesTarget(r, labels, groupId, resourceId, needs = {}){
|
|
472
475
|
return labels.every((l) => r.labels.includes(l)) &&
|
|
473
476
|
(!groupId || r.group_ids.includes(groupId)) &&
|
|
474
|
-
(!resourceId || r.id === resourceId)
|
|
477
|
+
(!resourceId || r.id === resourceId) &&
|
|
478
|
+
(!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
|
|
475
479
|
}
|
|
476
480
|
|
|
477
481
|
// Admin: remove a resource from the registry (dashboard "Remove"). Refused
|
|
@@ -509,17 +513,17 @@ function createRegistryService(db, { bus, events }){
|
|
|
509
513
|
return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
|
|
510
514
|
}
|
|
511
515
|
|
|
512
|
-
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
516
|
+
function findIdleCandidates(type, labels, groupId, resourceId, needs){
|
|
513
517
|
const rows = db
|
|
514
518
|
.prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
|
|
515
519
|
.all(RESOURCE_STATES.IDLE, type)
|
|
516
520
|
.map(rowToResource);
|
|
517
|
-
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
521
|
+
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
518
522
|
}
|
|
519
523
|
|
|
520
|
-
function everSatisfiable(type, labels, groupId, resourceId){
|
|
524
|
+
function everSatisfiable(type, labels, groupId, resourceId, needs){
|
|
521
525
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
522
|
-
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
526
|
+
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
523
527
|
}
|
|
524
528
|
|
|
525
529
|
return {
|
|
@@ -43,7 +43,7 @@ function createScheduler(db, { bus, events, registry, config }){
|
|
|
43
43
|
for (const job of queued){
|
|
44
44
|
const { type, labels = [], group, client } = job.spec.target,
|
|
45
45
|
candidates = registry
|
|
46
|
-
.findIdleCandidates(type, labels, group, client)
|
|
46
|
+
.findIdleCandidates(type, labels, group, client, { jobImage: Boolean(job.spec.firmware?.image) })
|
|
47
47
|
.filter((r) => !claimedResourceIds.has(r.id));
|
|
48
48
|
if (candidates.length === 0){
|
|
49
49
|
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,28 @@ 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
|
+
|
|
528
550
|
test('a resource can belong to several groups at once', () => {
|
|
529
551
|
const { registry, groups } = buildTestServices(),
|
|
530
552
|
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)
|