plum-e2e 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -470
- package/backend/lib/runnerProcess.js +50 -4
- package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
- package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
- package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
- package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
- package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
- package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
- package/backend/package.json +1 -0
- package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
- package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
- package/backend/prisma/schema.prisma +22 -7
- package/backend/routes/backup.routes.js +70 -5
- package/backend/routes/node.routes.js +9 -0
- package/backend/routes/runners.routes.js +10 -0
- package/backend/routes/settings.routes.js +27 -0
- package/backend/scripts/manage-runners.mjs +49 -8
- package/backend/server.js +25 -1
- package/backend/services/backupCronService.js +82 -0
- package/backend/services/backupService.js +254 -27
- package/backend/services/cronService.js +91 -7
- package/backend/services/notificationService.js +163 -0
- package/backend/services/settingsService.js +74 -1
- package/backend/websockets/socketHandler.js +82 -6
- package/frontend/src/lib/api/schedules.js +5 -1
- package/frontend/src/lib/api/settings.js +56 -0
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +79 -3
- package/frontend/src/lib/stores/runner.js +4 -2
- package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
- package/frontend/src/routes/settings/+page.svelte +472 -9
- package/package.json +1 -1
|
@@ -49,6 +49,34 @@ function saveRegistry(registry) {
|
|
|
49
49
|
fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf8');
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Returns the PID of the process listening on the given TCP port, or null.
|
|
54
|
+
* Uses lsof on macOS/Linux and netstat on Windows.
|
|
55
|
+
*/
|
|
56
|
+
function findPidOnPort(port) {
|
|
57
|
+
const portStr = String(port);
|
|
58
|
+
try {
|
|
59
|
+
if (process.platform === 'win32') {
|
|
60
|
+
const out = execSync('netstat -ano', { encoding: 'utf8' });
|
|
61
|
+
for (const line of out.split('\n')) {
|
|
62
|
+
const upper = line.toUpperCase();
|
|
63
|
+
if (upper.includes(`:${portStr}`) && upper.includes('LISTENING')) {
|
|
64
|
+
const parts = line.trim().split(/\s+/);
|
|
65
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
66
|
+
if (!isNaN(pid) && pid > 0) return pid;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const out = execSync(`lsof -i :${portStr} -t -sTCP:LISTEN`, { encoding: 'utf8' }).trim();
|
|
71
|
+
if (out) {
|
|
72
|
+
const pid = parseInt(out.split('\n')[0].trim(), 10);
|
|
73
|
+
if (!isNaN(pid) && pid > 0) return pid;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
// Signal 0 performs the OS-level permission/existence check without actually
|
|
53
81
|
// delivering a signal — the portable way to ask "is this pid alive?".
|
|
54
82
|
function isAlive(pid) {
|
|
@@ -128,7 +156,13 @@ function startNode({ id, port, token }) {
|
|
|
128
156
|
|
|
129
157
|
const child = spawn(process.execPath, [SERVER_PATH], {
|
|
130
158
|
cwd: BACKEND_DIR,
|
|
131
|
-
env: {
|
|
159
|
+
env: {
|
|
160
|
+
...process.env,
|
|
161
|
+
NODE_TOKEN: token,
|
|
162
|
+
PLUM_MODE: 'node',
|
|
163
|
+
PORT: String(port),
|
|
164
|
+
RUNNER_ID: String(id)
|
|
165
|
+
},
|
|
132
166
|
detached: true,
|
|
133
167
|
stdio: ['ignore', out, out],
|
|
134
168
|
windowsHide: true
|
|
@@ -146,14 +180,25 @@ function startNode({ id, port, token }) {
|
|
|
146
180
|
/**
|
|
147
181
|
* Stops the managed process for a runner. Returns true if a live process was
|
|
148
182
|
* signalled, false if nothing was running.
|
|
183
|
+
*
|
|
184
|
+
* Falls back to port-based PID discovery when the registry entry is missing or
|
|
185
|
+
* its PID is stale, using the port stored in the entry or an explicit fallback.
|
|
149
186
|
*/
|
|
150
|
-
function stopNode(id) {
|
|
187
|
+
function stopNode(id, fallbackPort = null) {
|
|
151
188
|
const registry = loadRegistry();
|
|
152
189
|
const entry = registry[id];
|
|
153
190
|
let signalled = false;
|
|
154
|
-
|
|
191
|
+
|
|
192
|
+
let pid = entry?.pid && isAlive(entry.pid) ? entry.pid : null;
|
|
193
|
+
|
|
194
|
+
if (!pid) {
|
|
195
|
+
const port = fallbackPort ?? (entry?.port ? Number(entry.port) : null);
|
|
196
|
+
if (port) pid = findPidOnPort(port);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (pid) {
|
|
155
200
|
try {
|
|
156
|
-
process.kill(
|
|
201
|
+
process.kill(pid, 'SIGTERM');
|
|
157
202
|
signalled = true;
|
|
158
203
|
} catch {}
|
|
159
204
|
}
|
|
@@ -171,6 +216,7 @@ module.exports = {
|
|
|
171
216
|
isAlive,
|
|
172
217
|
isLocalUrl,
|
|
173
218
|
parsePort,
|
|
219
|
+
findPidOnPort,
|
|
174
220
|
pruneDead,
|
|
175
221
|
statusOf,
|
|
176
222
|
prepareEnv,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This file is part of Plum.
|
|
3
|
+
|
|
4
|
+
Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU General Public License as published by
|
|
6
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
(at your option) any later version.
|
|
8
|
+
|
|
9
|
+
Plum is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU General Public License
|
|
15
|
+
along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
18
|
+
Backend running on port 3002 (node/runner mode)
|
|
19
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
20
|
+
Backend running on port 3002 (node/runner mode)
|
|
21
|
+
(node:14993) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
|
|
22
|
+
(Use `node --trace-deprecation ...` to show where the warning was created)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This file is part of Plum.
|
|
3
|
+
|
|
4
|
+
Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU General Public License as published by
|
|
6
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
(at your option) any later version.
|
|
8
|
+
|
|
9
|
+
Plum is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU General Public License
|
|
15
|
+
along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
18
|
+
Backend running on port 3002 (node/runner mode)
|
|
19
|
+
(node:23570) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
|
|
20
|
+
(Use `node --trace-deprecation ...` to show where the warning was created)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This file is part of Plum.
|
|
3
|
+
|
|
4
|
+
Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU General Public License as published by
|
|
6
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
(at your option) any later version.
|
|
8
|
+
|
|
9
|
+
Plum is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU General Public License
|
|
15
|
+
along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
18
|
+
node:events:487
|
|
19
|
+
throw er; // Unhandled 'error' event
|
|
20
|
+
^
|
|
21
|
+
|
|
22
|
+
Error: listen EADDRINUSE: address already in use :::3002
|
|
23
|
+
at Server.setupListenHandle [as _listen2] (node:net:2008:16)
|
|
24
|
+
at listenInCluster (node:net:2065:12)
|
|
25
|
+
at Server.listen (node:net:2170:7)
|
|
26
|
+
at start (/Users/silverlunah/Projects/plum/backend/server.js:54:9)
|
|
27
|
+
at Object.<anonymous> (/Users/silverlunah/Projects/plum/backend/server.js:120:1)
|
|
28
|
+
at Module._compile (node:internal/modules/cjs/loader:1829:14)
|
|
29
|
+
at Module._extensions..js (node:internal/modules/cjs/loader:1969:10)
|
|
30
|
+
at Module.load (node:internal/modules/cjs/loader:1552:32)
|
|
31
|
+
at Module._load (node:internal/modules/cjs/loader:1354:12)
|
|
32
|
+
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
|
|
33
|
+
Emitted 'error' event on Server instance at:
|
|
34
|
+
at emitErrorNT (node:net:2044:8)
|
|
35
|
+
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
|
|
36
|
+
code: 'EADDRINUSE',
|
|
37
|
+
errno: -48,
|
|
38
|
+
syscall: 'listen',
|
|
39
|
+
address: '::',
|
|
40
|
+
port: 3002
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Node.js v25.9.0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This file is part of Plum.
|
|
3
|
+
|
|
4
|
+
Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU General Public License as published by
|
|
6
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
(at your option) any later version.
|
|
8
|
+
|
|
9
|
+
Plum is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU General Public License
|
|
15
|
+
along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
18
|
+
Backend running on port 3003 (node/runner mode)
|
|
19
|
+
(node:23686) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
|
|
20
|
+
(Use `node --trace-deprecation ...` to show where the warning was created)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This file is part of Plum.
|
|
3
|
+
|
|
4
|
+
Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU General Public License as published by
|
|
6
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
(at your option) any later version.
|
|
8
|
+
|
|
9
|
+
Plum is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU General Public License
|
|
15
|
+
along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
18
|
+
Backend running on port 3004 (node/runner mode)
|
|
19
|
+
(node:23733) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
|
|
20
|
+
(Use `node --trace-deprecation ...` to show where the warning was created)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This file is part of Plum.
|
|
3
|
+
|
|
4
|
+
Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU General Public License as published by
|
|
6
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
(at your option) any later version.
|
|
8
|
+
|
|
9
|
+
Plum is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU General Public License
|
|
15
|
+
along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
18
|
+
Backend running on port 3005 (node/runner mode)
|
|
19
|
+
(node:23776) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
|
|
20
|
+
(Use `node --trace-deprecation ...` to show where the warning was created)
|
package/backend/package.json
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- AlterTable Project: add webhook and public URL fields
|
|
2
|
+
ALTER TABLE "Project" ADD COLUMN "discordWebhookUrl" TEXT NOT NULL DEFAULT '';
|
|
3
|
+
ALTER TABLE "Project" ADD COLUMN "slackWebhookUrl" TEXT NOT NULL DEFAULT '';
|
|
4
|
+
ALTER TABLE "Project" ADD COLUMN "notifyPublicUrl" TEXT NOT NULL DEFAULT '';
|
|
5
|
+
|
|
6
|
+
-- AlterTable CronJob: add notification toggles
|
|
7
|
+
ALTER TABLE "CronJob" ADD COLUMN "notifyDiscord" BOOLEAN NOT NULL DEFAULT false;
|
|
8
|
+
ALTER TABLE "CronJob" ADD COLUMN "notifySlack" BOOLEAN NOT NULL DEFAULT false;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- AlterTable Project: add S3 backup configuration fields
|
|
2
|
+
ALTER TABLE "Project" ADD COLUMN "backupEnabled" BOOLEAN NOT NULL DEFAULT false;
|
|
3
|
+
ALTER TABLE "Project" ADD COLUMN "backupCron" TEXT NOT NULL DEFAULT '0 2 * * *';
|
|
4
|
+
ALTER TABLE "Project" ADD COLUMN "backupS3Endpoint" TEXT NOT NULL DEFAULT '';
|
|
5
|
+
ALTER TABLE "Project" ADD COLUMN "backupS3Region" TEXT NOT NULL DEFAULT '';
|
|
6
|
+
ALTER TABLE "Project" ADD COLUMN "backupS3Bucket" TEXT NOT NULL DEFAULT '';
|
|
7
|
+
ALTER TABLE "Project" ADD COLUMN "backupS3AccessKey" TEXT NOT NULL DEFAULT '';
|
|
8
|
+
ALTER TABLE "Project" ADD COLUMN "backupS3SecretKey" TEXT NOT NULL DEFAULT '';
|
|
9
|
+
ALTER TABLE "Project" ADD COLUMN "backupS3Prefix" TEXT NOT NULL DEFAULT '';
|
|
10
|
+
ALTER TABLE "Project" ADD COLUMN "backupLastRunAt" TIMESTAMP(3);
|
|
11
|
+
ALTER TABLE "Project" ADD COLUMN "backupLastStatus" TEXT NOT NULL DEFAULT '';
|
|
@@ -44,6 +44,8 @@ model CronJob {
|
|
|
44
44
|
enabled Boolean @default(true)
|
|
45
45
|
runnerId String?
|
|
46
46
|
runnerIds String @default("built-in")
|
|
47
|
+
notifyDiscord Boolean @default(false)
|
|
48
|
+
notifySlack Boolean @default(false)
|
|
47
49
|
runner Runner? @relation(fields: [runnerId], references: [id], onDelete: SetNull)
|
|
48
50
|
createdAt DateTime @default(now())
|
|
49
51
|
updatedAt DateTime @updatedAt
|
|
@@ -68,13 +70,26 @@ model Report {
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
model Project {
|
|
71
|
-
id
|
|
72
|
-
name
|
|
73
|
-
logoUrl
|
|
74
|
-
testCasePrefix
|
|
75
|
-
testSuitePrefix
|
|
76
|
-
caseSeqNext
|
|
77
|
-
suiteSeqNext
|
|
73
|
+
id Int @id @default(autoincrement())
|
|
74
|
+
name String @default("")
|
|
75
|
+
logoUrl String @default("")
|
|
76
|
+
testCasePrefix String @default("TC")
|
|
77
|
+
testSuitePrefix String @default("TS")
|
|
78
|
+
caseSeqNext Int @default(0)
|
|
79
|
+
suiteSeqNext Int @default(0)
|
|
80
|
+
discordWebhookUrl String @default("")
|
|
81
|
+
slackWebhookUrl String @default("")
|
|
82
|
+
notifyPublicUrl String @default("")
|
|
83
|
+
backupEnabled Boolean @default(false)
|
|
84
|
+
backupCron String @default("0 2 * * *")
|
|
85
|
+
backupS3Endpoint String @default("")
|
|
86
|
+
backupS3Region String @default("")
|
|
87
|
+
backupS3Bucket String @default("")
|
|
88
|
+
backupS3AccessKey String @default("")
|
|
89
|
+
backupS3SecretKey String @default("")
|
|
90
|
+
backupS3Prefix String @default("")
|
|
91
|
+
backupLastRunAt DateTime?
|
|
92
|
+
backupLastStatus String @default("")
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
model User {
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
const express = require('express');
|
|
19
19
|
const router = express.Router();
|
|
20
20
|
const backupService = require('../services/backupService');
|
|
21
|
+
const settingsService = require('../services/settingsService');
|
|
21
22
|
const cronService = require('../services/cronService');
|
|
23
|
+
const backupCronService = require('../services/backupCronService');
|
|
24
|
+
const prisma = require('../services/prisma');
|
|
22
25
|
|
|
23
26
|
router.get('/export', async (req, res) => {
|
|
24
27
|
try {
|
|
@@ -35,11 +38,15 @@ router.get('/export', async (req, res) => {
|
|
|
35
38
|
|
|
36
39
|
router.post('/import', async (req, res) => {
|
|
37
40
|
try {
|
|
38
|
-
const { cronJobs,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const { cronJobs, project, users, runners, testSuites, testRuns } = req.body;
|
|
42
|
+
const hasData = [cronJobs, project, users, runners, testSuites, testRuns].some(
|
|
43
|
+
(v) => v !== undefined && v !== null
|
|
44
|
+
);
|
|
45
|
+
if (!hasData) return res.status(400).json({ error: 'Invalid backup format' });
|
|
46
|
+
await backupService.importAll(
|
|
47
|
+
{ cronJobs, project, users, runners, testSuites, testRuns },
|
|
48
|
+
cronService
|
|
49
|
+
);
|
|
43
50
|
res.json({ message: 'Import successful' });
|
|
44
51
|
} catch (error) {
|
|
45
52
|
console.error('Import failed:', error);
|
|
@@ -47,4 +54,62 @@ router.post('/import', async (req, res) => {
|
|
|
47
54
|
}
|
|
48
55
|
});
|
|
49
56
|
|
|
57
|
+
router.get('/config', async (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const config = await settingsService.getBackupConfig();
|
|
60
|
+
res.json(config);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('Failed to get backup config:', error);
|
|
63
|
+
res.status(500).json({ error: 'Failed to get backup configuration' });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
router.post('/config', async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
await settingsService.updateBackupConfig(req.body);
|
|
70
|
+
await backupCronService.reload();
|
|
71
|
+
const config = await settingsService.getBackupConfig();
|
|
72
|
+
res.json(config);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Failed to save backup config:', error);
|
|
75
|
+
res.status(500).json({ error: 'Failed to save backup configuration' });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
router.post('/test-s3', async (req, res) => {
|
|
80
|
+
try {
|
|
81
|
+
// If no secret key provided in the request, fall back to the stored one
|
|
82
|
+
let config = { ...req.body };
|
|
83
|
+
if (!config.backupS3SecretKey) {
|
|
84
|
+
const stored = await prisma.project.findUnique({ where: { id: 1 } });
|
|
85
|
+
config.backupS3SecretKey = stored?.backupS3SecretKey ?? '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const required = ['backupS3Bucket', 'backupS3AccessKey', 'backupS3SecretKey'];
|
|
89
|
+
const missing = required.filter((k) => !config[k]);
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
return res.status(400).json({ error: `Missing required fields: ${missing.join(', ')}` });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await backupService.testS3Connection(config);
|
|
95
|
+
res.json({ ok: true });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
res.status(400).json({ error: error.message || 'Connection failed' });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.post('/run-now', async (req, res) => {
|
|
102
|
+
try {
|
|
103
|
+
await backupCronService.runBackup();
|
|
104
|
+
const config = await settingsService.getBackupConfig();
|
|
105
|
+
if (config.backupLastStatus?.startsWith('error:')) {
|
|
106
|
+
return res.status(500).json({ error: config.backupLastStatus.replace('error:', '') });
|
|
107
|
+
}
|
|
108
|
+
res.json({ ok: true, lastRunAt: config.backupLastRunAt, lastStatus: config.backupLastStatus });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('Backup run-now failed:', error);
|
|
111
|
+
res.status(500).json({ error: error.message || 'Backup failed' });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
50
115
|
module.exports = router;
|
|
@@ -34,6 +34,15 @@ router.get('/ping', authGuard, (req, res) => {
|
|
|
34
34
|
res.json({ ok: true, mode: process.env.PLUM_MODE || 'server' });
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
// Graceful shutdown for node-mode processes (no-op on the primary server)
|
|
38
|
+
router.post('/shutdown', authGuard, (req, res) => {
|
|
39
|
+
if (process.env.PLUM_MODE !== 'node') {
|
|
40
|
+
return res.status(403).json({ error: 'Not a node runner' });
|
|
41
|
+
}
|
|
42
|
+
res.json({ ok: true });
|
|
43
|
+
setTimeout(() => process.exit(0), 200);
|
|
44
|
+
});
|
|
45
|
+
|
|
37
46
|
// Start a remote test job
|
|
38
47
|
router.post('/execute', authGuard, (req, res) => {
|
|
39
48
|
const { tags, browser = 'chromium', workers = 1, tests = null } = req.body;
|
|
@@ -64,6 +64,16 @@ router.put('/:id', async (req, res) => {
|
|
|
64
64
|
|
|
65
65
|
router.delete('/:id', async (req, res) => {
|
|
66
66
|
try {
|
|
67
|
+
const runner = await runnerService.getById(req.params.id);
|
|
68
|
+
if (runner) {
|
|
69
|
+
try {
|
|
70
|
+
await fetch(`${runner.url}/api/shutdown`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { Authorization: `Bearer ${runner.token}` },
|
|
73
|
+
signal: AbortSignal.timeout(3000)
|
|
74
|
+
});
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
67
77
|
await runnerService.remove(req.params.id);
|
|
68
78
|
res.json({ message: 'Runner deleted' });
|
|
69
79
|
} catch (e) {
|
|
@@ -76,4 +76,31 @@ router.post('/test-prefixes/migrate', jwtAuth, async (req, res, next) => {
|
|
|
76
76
|
}
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
router.get('/integrations', async (req, res, next) => {
|
|
80
|
+
try {
|
|
81
|
+
const webhooks = await settingsService.getWebhooks();
|
|
82
|
+
res.json(webhooks);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
next(e);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
router.post('/integrations', async (req, res, next) => {
|
|
89
|
+
try {
|
|
90
|
+
const { discordWebhookUrl, slackWebhookUrl, notifyPublicUrl } = req.body;
|
|
91
|
+
const project = await settingsService.updateWebhooks({
|
|
92
|
+
discordWebhookUrl,
|
|
93
|
+
slackWebhookUrl,
|
|
94
|
+
notifyPublicUrl
|
|
95
|
+
});
|
|
96
|
+
res.json({
|
|
97
|
+
discordWebhookUrl: project.discordWebhookUrl,
|
|
98
|
+
slackWebhookUrl: project.slackWebhookUrl,
|
|
99
|
+
notifyPublicUrl: project.notifyPublicUrl
|
|
100
|
+
});
|
|
101
|
+
} catch (e) {
|
|
102
|
+
next(e);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
79
106
|
module.exports = router;
|
|
@@ -32,8 +32,16 @@ import pc from 'picocolors';
|
|
|
32
32
|
import runnerProcess from '../lib/runnerProcess.js';
|
|
33
33
|
import nodeRegister from '../lib/nodeRegister.js';
|
|
34
34
|
|
|
35
|
-
const {
|
|
36
|
-
|
|
35
|
+
const {
|
|
36
|
+
isLocalUrl,
|
|
37
|
+
parsePort,
|
|
38
|
+
pruneDead,
|
|
39
|
+
statusOf,
|
|
40
|
+
prepareEnv,
|
|
41
|
+
startNode,
|
|
42
|
+
stopNode,
|
|
43
|
+
findPidOnPort
|
|
44
|
+
} = runnerProcess;
|
|
37
45
|
const { generateToken, registerWithPrimary } = nodeRegister;
|
|
38
46
|
|
|
39
47
|
const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
|
|
@@ -68,6 +76,9 @@ async function deleteRunner(id) {
|
|
|
68
76
|
/**
|
|
69
77
|
* Resolves the display + control state for every runner: reachability (ping),
|
|
70
78
|
* whether we own a live process for it, and whether we can control it at all.
|
|
79
|
+
*
|
|
80
|
+
* Local runners that are online but absent from the registry are automatically
|
|
81
|
+
* reclaimed by scanning their port for a running process.
|
|
71
82
|
*/
|
|
72
83
|
async function describeRunners() {
|
|
73
84
|
const runners = await fetchRunners();
|
|
@@ -77,7 +88,19 @@ async function describeRunners() {
|
|
|
77
88
|
runners.map(async (r) => {
|
|
78
89
|
const online = await pingRunner(r.id);
|
|
79
90
|
const local = isLocalUrl(r.url);
|
|
80
|
-
|
|
91
|
+
let managed = statusOf(r.id) === 'running';
|
|
92
|
+
|
|
93
|
+
if (local && online && !managed) {
|
|
94
|
+
const port = Number(parsePort(r.url));
|
|
95
|
+
const pid = findPidOnPort(port);
|
|
96
|
+
if (pid) {
|
|
97
|
+
const registry = runnerProcess.loadRegistry();
|
|
98
|
+
registry[r.id] = { pid, port: String(port), startedAt: Date.now() };
|
|
99
|
+
runnerProcess.saveRegistry(registry);
|
|
100
|
+
managed = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
let state;
|
|
82
105
|
if (managed) state = 'managed';
|
|
83
106
|
else if (online) state = 'unmanaged';
|
|
@@ -181,7 +204,7 @@ async function runAction(r) {
|
|
|
181
204
|
if (cancelled(confirmed) || !confirmed) return;
|
|
182
205
|
const s = clack.spinner();
|
|
183
206
|
s.start(`Deleting "${r.name}"...`);
|
|
184
|
-
if (r.
|
|
207
|
+
if (r.local) stopNode(r.id, Number(parsePort(r.url)));
|
|
185
208
|
try {
|
|
186
209
|
await deleteRunner(r.id);
|
|
187
210
|
s.stop(pc.green(`Deleted "${r.name}"`));
|
|
@@ -194,7 +217,11 @@ async function runAction(r) {
|
|
|
194
217
|
async function addRunner() {
|
|
195
218
|
const suggested = `node-${generateToken().slice(0, 6)}`;
|
|
196
219
|
|
|
197
|
-
const name = await clack.text({
|
|
220
|
+
const name = await clack.text({
|
|
221
|
+
message: 'Runner name',
|
|
222
|
+
placeholder: suggested,
|
|
223
|
+
defaultValue: suggested
|
|
224
|
+
});
|
|
198
225
|
if (cancelled(name)) return;
|
|
199
226
|
|
|
200
227
|
const port = await clack.text({
|
|
@@ -205,7 +232,11 @@ async function addRunner() {
|
|
|
205
232
|
if (cancelled(port)) return;
|
|
206
233
|
|
|
207
234
|
const defToken = process.env.NODE_TOKEN || generateToken();
|
|
208
|
-
const token = await clack.text({
|
|
235
|
+
const token = await clack.text({
|
|
236
|
+
message: 'Auth token',
|
|
237
|
+
placeholder: defToken,
|
|
238
|
+
defaultValue: defToken
|
|
239
|
+
});
|
|
209
240
|
if (cancelled(token)) return;
|
|
210
241
|
|
|
211
242
|
// Dev nodes run as a bare process on the host; the dockerized primary reaches
|
|
@@ -216,9 +247,19 @@ async function addRunner() {
|
|
|
216
247
|
s.start(`Registering "${name}" with the primary...`);
|
|
217
248
|
let id;
|
|
218
249
|
try {
|
|
219
|
-
const res = await registerWithPrimary({
|
|
250
|
+
const res = await registerWithPrimary({
|
|
251
|
+
primary: API_URL,
|
|
252
|
+
name,
|
|
253
|
+
url,
|
|
254
|
+
token,
|
|
255
|
+
browser: 'chromium'
|
|
256
|
+
});
|
|
220
257
|
id = res.id;
|
|
221
|
-
s.stop(
|
|
258
|
+
s.stop(
|
|
259
|
+
res.reused
|
|
260
|
+
? pc.green(`Reusing existing runner "${name}"`)
|
|
261
|
+
: pc.green(`Registered "${name}" (id ${id})`)
|
|
262
|
+
);
|
|
222
263
|
} catch (e) {
|
|
223
264
|
s.stop(pc.red(`Could not register "${name}": ${e.message}`));
|
|
224
265
|
return;
|
package/backend/server.js
CHANGED
|
@@ -41,19 +41,43 @@ const isNodeMode = process.env.PLUM_MODE === 'node';
|
|
|
41
41
|
const port = parseInt(process.env.PORT || '3001', 10);
|
|
42
42
|
|
|
43
43
|
let cronService = null;
|
|
44
|
+
let backupCronService = null;
|
|
44
45
|
if (!isNodeMode) {
|
|
45
46
|
const socketHandler = require('./websockets/socketHandler.js');
|
|
46
47
|
cronService = require('./services/cronService');
|
|
48
|
+
backupCronService = require('./services/backupCronService');
|
|
47
49
|
socketHandler(io);
|
|
48
50
|
cronService.setSocketIO(io);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
async function start() {
|
|
52
54
|
if (cronService) await cronService.init();
|
|
55
|
+
if (backupCronService) await backupCronService.init();
|
|
53
56
|
|
|
54
57
|
server.listen(port, async () => {
|
|
55
58
|
console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
|
|
56
|
-
if (isNodeMode)
|
|
59
|
+
if (isNodeMode) {
|
|
60
|
+
// Self-register PID so manage-runners can track and stop this process.
|
|
61
|
+
const runnerId = process.env.RUNNER_ID;
|
|
62
|
+
if (runnerId) {
|
|
63
|
+
const { loadRegistry, saveRegistry } = require('./lib/runnerProcess');
|
|
64
|
+
const registry = loadRegistry();
|
|
65
|
+
registry[runnerId] = { pid: process.pid, port: String(port), startedAt: Date.now() };
|
|
66
|
+
saveRegistry(registry);
|
|
67
|
+
|
|
68
|
+
const cleanup = () => {
|
|
69
|
+
try {
|
|
70
|
+
const reg = loadRegistry();
|
|
71
|
+
delete reg[runnerId];
|
|
72
|
+
saveRegistry(reg);
|
|
73
|
+
} catch {}
|
|
74
|
+
};
|
|
75
|
+
process.once('SIGTERM', cleanup);
|
|
76
|
+
process.once('SIGINT', cleanup);
|
|
77
|
+
process.once('exit', cleanup);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
57
81
|
|
|
58
82
|
// chokidar v5+ is ESM-only — use dynamic import to stay compatible with CJS
|
|
59
83
|
let chokidar;
|