plum-e2e 2.2.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 -520
- package/backend/package.json +1 -0
- package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
- package/backend/prisma/schema.prisma +20 -10
- package/backend/routes/backup.routes.js +70 -5
- package/backend/server.js +3 -0
- package/backend/services/backupCronService.js +82 -0
- package/backend/services/backupService.js +254 -27
- package/backend/services/settingsService.js +46 -1
- package/frontend/src/lib/api/settings.js +41 -0
- package/frontend/src/routes/settings/+page.svelte +381 -8
- package/package.json +1 -1
|
@@ -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 '';
|
|
@@ -70,16 +70,26 @@ model Report {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
model Project {
|
|
73
|
-
id
|
|
74
|
-
name
|
|
75
|
-
logoUrl
|
|
76
|
-
testCasePrefix
|
|
77
|
-
testSuitePrefix
|
|
78
|
-
caseSeqNext
|
|
79
|
-
suiteSeqNext
|
|
80
|
-
discordWebhookUrl
|
|
81
|
-
slackWebhookUrl
|
|
82
|
-
notifyPublicUrl
|
|
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("")
|
|
83
93
|
}
|
|
84
94
|
|
|
85
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;
|
package/backend/server.js
CHANGED
|
@@ -41,15 +41,18 @@ 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)' : ''}`);
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
|
|
18
|
+
const cron = require('node-cron');
|
|
19
|
+
const prisma = require('./prisma');
|
|
20
|
+
const backupService = require('./backupService');
|
|
21
|
+
|
|
22
|
+
let scheduledJob = null;
|
|
23
|
+
|
|
24
|
+
const runBackup = async () => {
|
|
25
|
+
let project;
|
|
26
|
+
try {
|
|
27
|
+
project = await prisma.project.findUnique({ where: { id: 1 } });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error('❌ Backup: could not read project config:', err.message);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!project?.backupEnabled) return;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const data = await backupService.exportAll();
|
|
37
|
+
const key = await backupService.uploadToS3(data, project);
|
|
38
|
+
|
|
39
|
+
await prisma.project.update({
|
|
40
|
+
where: { id: 1 },
|
|
41
|
+
data: {
|
|
42
|
+
backupLastRunAt: new Date(),
|
|
43
|
+
backupLastStatus: `success:${key}`
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
console.log(`✅ Backup uploaded: ${key}`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('❌ Backup failed:', err.message);
|
|
49
|
+
try {
|
|
50
|
+
await prisma.project.update({
|
|
51
|
+
where: { id: 1 },
|
|
52
|
+
data: {
|
|
53
|
+
backupLastRunAt: new Date(),
|
|
54
|
+
backupLastStatus: `error:${err.message}`
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const schedule = (cronExpr, enabled) => {
|
|
62
|
+
if (scheduledJob) {
|
|
63
|
+
scheduledJob.stop();
|
|
64
|
+
scheduledJob = null;
|
|
65
|
+
}
|
|
66
|
+
if (!enabled || !cronExpr || !cron.validate(cronExpr)) return;
|
|
67
|
+
scheduledJob = cron.schedule(cronExpr, runBackup);
|
|
68
|
+
console.log(`⏰ Backup scheduled: ${cronExpr}`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const init = async () => {
|
|
72
|
+
try {
|
|
73
|
+
const project = await prisma.project.findUnique({ where: { id: 1 } });
|
|
74
|
+
schedule(project?.backupCron, project?.backupEnabled);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('Failed to initialize backup cron:', err.message);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const reload = init;
|
|
81
|
+
|
|
82
|
+
module.exports = { init, reload, runBackup };
|
|
@@ -17,45 +17,272 @@
|
|
|
17
17
|
|
|
18
18
|
const prisma = require('./prisma');
|
|
19
19
|
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Export — all data except reports (reports are too large; use pg_dump instead)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
20
24
|
const exportAll = async () => {
|
|
21
|
-
const [cronJobs, project] = await Promise.all([
|
|
25
|
+
const [cronJobs, project, testSuites, testRuns, users, runners] = await Promise.all([
|
|
22
26
|
prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
23
|
-
prisma.project.findUnique({ where: { id: 1 } })
|
|
27
|
+
prisma.project.findUnique({ where: { id: 1 } }),
|
|
28
|
+
prisma.testSuite.findMany({
|
|
29
|
+
orderBy: { createdAt: 'asc' },
|
|
30
|
+
include: {
|
|
31
|
+
cases: {
|
|
32
|
+
include: { steps: { orderBy: { order: 'asc' } } },
|
|
33
|
+
orderBy: { createdAt: 'asc' }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
prisma.testRun.findMany({
|
|
38
|
+
orderBy: { createdAt: 'asc' },
|
|
39
|
+
include: { entries: { orderBy: { order: 'asc' } } }
|
|
40
|
+
}),
|
|
41
|
+
prisma.user.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
42
|
+
prisma.runner.findMany({ orderBy: { createdAt: 'asc' } })
|
|
24
43
|
]);
|
|
25
44
|
|
|
26
45
|
return {
|
|
27
|
-
version: '
|
|
46
|
+
version: '2',
|
|
28
47
|
exportedAt: new Date().toISOString(),
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
disclaimer:
|
|
49
|
+
'Reports are not included in this backup. Use pg_dump on the PostgreSQL volume to back up report history.',
|
|
50
|
+
cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, runnerId: __, ...r }) => r),
|
|
51
|
+
project: project
|
|
52
|
+
? {
|
|
53
|
+
name: project.name,
|
|
54
|
+
logoUrl: project.logoUrl,
|
|
55
|
+
testCasePrefix: project.testCasePrefix,
|
|
56
|
+
testSuitePrefix: project.testSuitePrefix,
|
|
57
|
+
discordWebhookUrl: project.discordWebhookUrl,
|
|
58
|
+
slackWebhookUrl: project.slackWebhookUrl,
|
|
59
|
+
notifyPublicUrl: project.notifyPublicUrl
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
users: users.map(({ updatedAt: _, ...u }) => u),
|
|
63
|
+
runners: runners.map(({ createdAt: _, cronJobs: __, reports: ___, ...r }) => r),
|
|
64
|
+
testSuites: testSuites.map(({ cases, ...suite }) => ({
|
|
65
|
+
...suite,
|
|
66
|
+
cases: cases.map(({ steps, runEntries: _, history: __, ...tc }) => ({
|
|
67
|
+
...tc,
|
|
68
|
+
steps: steps.map(({ createdAt: ___, ...step }) => step)
|
|
69
|
+
}))
|
|
70
|
+
})),
|
|
71
|
+
testRuns: testRuns.map(({ entries, history: _, ...run }) => ({
|
|
72
|
+
...run,
|
|
73
|
+
entries: entries.map(({ executedAt, ...entry }) => ({ ...entry, executedAt }))
|
|
74
|
+
}))
|
|
31
75
|
};
|
|
32
76
|
};
|
|
33
77
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Import — upsert all exported data, preserve IDs for relational integrity
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const importAll = async (
|
|
83
|
+
{ cronJobs = [], project = null, users = [], runners = [], testSuites = [], testRuns = [] },
|
|
84
|
+
cronService
|
|
85
|
+
) => {
|
|
86
|
+
await prisma.$transaction(
|
|
87
|
+
async (tx) => {
|
|
88
|
+
// 1. Users (needed before suites/runs reference createdById)
|
|
89
|
+
for (const user of users) {
|
|
90
|
+
await tx.user.upsert({
|
|
91
|
+
where: { email: user.email },
|
|
92
|
+
create: user,
|
|
93
|
+
update: { name: user.name, role: user.role }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. Runners
|
|
98
|
+
for (const runner of runners) {
|
|
99
|
+
await tx.runner.upsert({
|
|
100
|
+
where: { id: runner.id },
|
|
101
|
+
create: runner,
|
|
102
|
+
update: {
|
|
103
|
+
name: runner.name,
|
|
104
|
+
url: runner.url,
|
|
105
|
+
token: runner.token,
|
|
106
|
+
browser: runner.browser
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. CronJobs
|
|
112
|
+
for (const job of cronJobs) {
|
|
113
|
+
await tx.cronJob.upsert({
|
|
114
|
+
where: { taskName: job.taskName },
|
|
115
|
+
create: {
|
|
116
|
+
taskName: job.taskName,
|
|
117
|
+
cronExpression: job.cronExpression,
|
|
118
|
+
tags: job.tags,
|
|
119
|
+
workers: job.workers ?? 1,
|
|
120
|
+
browser: job.browser ?? 'chromium',
|
|
121
|
+
enabled: job.enabled ?? true,
|
|
122
|
+
runnerIds: job.runnerIds ?? 'built-in',
|
|
123
|
+
notifyDiscord: job.notifyDiscord ?? false,
|
|
124
|
+
notifySlack: job.notifySlack ?? false
|
|
125
|
+
},
|
|
126
|
+
update: {
|
|
127
|
+
cronExpression: job.cronExpression,
|
|
128
|
+
tags: job.tags,
|
|
129
|
+
workers: job.workers ?? 1,
|
|
130
|
+
browser: job.browser ?? 'chromium',
|
|
131
|
+
runnerIds: job.runnerIds ?? 'built-in',
|
|
132
|
+
notifyDiscord: job.notifyDiscord ?? false,
|
|
133
|
+
notifySlack: job.notifySlack ?? false
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. Project settings
|
|
139
|
+
if (project) {
|
|
140
|
+
await tx.project.upsert({
|
|
141
|
+
where: { id: 1 },
|
|
142
|
+
create: { id: 1, ...project },
|
|
143
|
+
update: project
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 5. Test suites + cases + steps
|
|
148
|
+
for (const suite of testSuites) {
|
|
149
|
+
const { cases = [], ...suiteData } = suite;
|
|
150
|
+
await tx.testSuite.upsert({
|
|
151
|
+
where: { displayId: suiteData.displayId },
|
|
152
|
+
create: suiteData,
|
|
153
|
+
update: {
|
|
154
|
+
name: suiteData.name,
|
|
155
|
+
description: suiteData.description,
|
|
156
|
+
priority: suiteData.priority
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
for (const tc of cases) {
|
|
161
|
+
const { steps = [], ...caseData } = tc;
|
|
162
|
+
await tx.testCase.upsert({
|
|
163
|
+
where: { displayId: caseData.displayId },
|
|
164
|
+
create: caseData,
|
|
165
|
+
update: {
|
|
166
|
+
title: caseData.title,
|
|
167
|
+
description: caseData.description,
|
|
168
|
+
priority: caseData.priority,
|
|
169
|
+
isAutomated: caseData.isAutomated
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Replace all steps for this case (order may have changed)
|
|
174
|
+
await tx.testStep.deleteMany({ where: { caseId: caseData.id } });
|
|
175
|
+
for (const step of steps) {
|
|
176
|
+
await tx.testStep.create({ data: step });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 6. Test runs + entries
|
|
182
|
+
for (const run of testRuns) {
|
|
183
|
+
const { entries = [], ...runData } = run;
|
|
184
|
+
await tx.testRun.upsert({
|
|
185
|
+
where: { id: runData.id },
|
|
186
|
+
create: runData,
|
|
187
|
+
update: { title: runData.title, status: runData.status }
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
await tx.testRunEntry.upsert({
|
|
192
|
+
where: { id: entry.id },
|
|
193
|
+
create: entry,
|
|
194
|
+
update: { status: entry.status, notes: entry.notes, order: entry.order }
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{ timeout: 30000 }
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (cronService) await cronService.reload();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// S3 upload — S3-compatible object storage (AWS, R2, B2, MinIO)
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
const uploadToS3 = async (jsonData, config) => {
|
|
210
|
+
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
211
|
+
|
|
212
|
+
const {
|
|
213
|
+
backupS3Endpoint,
|
|
214
|
+
backupS3Region,
|
|
215
|
+
backupS3Bucket,
|
|
216
|
+
backupS3AccessKey,
|
|
217
|
+
backupS3SecretKey,
|
|
218
|
+
backupS3Prefix
|
|
219
|
+
} = config;
|
|
220
|
+
|
|
221
|
+
const clientConfig = {
|
|
222
|
+
region: backupS3Region || 'auto',
|
|
223
|
+
credentials: {
|
|
224
|
+
accessKeyId: backupS3AccessKey,
|
|
225
|
+
secretAccessKey: backupS3SecretKey
|
|
47
226
|
}
|
|
227
|
+
};
|
|
228
|
+
if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
|
|
229
|
+
|
|
230
|
+
const client = new S3Client(clientConfig);
|
|
231
|
+
|
|
232
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
233
|
+
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
234
|
+
const key = `${prefix}plum-backup-${date}.json`;
|
|
235
|
+
|
|
236
|
+
await client.send(
|
|
237
|
+
new PutObjectCommand({
|
|
238
|
+
Bucket: backupS3Bucket,
|
|
239
|
+
Key: key,
|
|
240
|
+
Body: JSON.stringify(jsonData, null, 2),
|
|
241
|
+
ContentType: 'application/json'
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return key;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const testS3Connection = async (config) => {
|
|
249
|
+
const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
|
48
250
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
251
|
+
const {
|
|
252
|
+
backupS3Endpoint,
|
|
253
|
+
backupS3Region,
|
|
254
|
+
backupS3Bucket,
|
|
255
|
+
backupS3AccessKey,
|
|
256
|
+
backupS3SecretKey,
|
|
257
|
+
backupS3Prefix
|
|
258
|
+
} = config;
|
|
259
|
+
|
|
260
|
+
const clientConfig = {
|
|
261
|
+
region: backupS3Region || 'auto',
|
|
262
|
+
credentials: {
|
|
263
|
+
accessKeyId: backupS3AccessKey,
|
|
264
|
+
secretAccessKey: backupS3SecretKey
|
|
55
265
|
}
|
|
56
|
-
}
|
|
266
|
+
};
|
|
267
|
+
if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
|
|
57
268
|
|
|
58
|
-
|
|
269
|
+
const client = new S3Client(clientConfig);
|
|
270
|
+
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
271
|
+
const key = `${prefix}.plum-connection-test`;
|
|
272
|
+
|
|
273
|
+
await client.send(
|
|
274
|
+
new PutObjectCommand({
|
|
275
|
+
Bucket: backupS3Bucket,
|
|
276
|
+
Key: key,
|
|
277
|
+
Body: 'ok',
|
|
278
|
+
ContentType: 'text/plain'
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Clean up the test file
|
|
283
|
+
try {
|
|
284
|
+
await client.send(new DeleteObjectCommand({ Bucket: backupS3Bucket, Key: key }));
|
|
285
|
+
} catch {}
|
|
59
286
|
};
|
|
60
287
|
|
|
61
|
-
module.exports = { exportAll, importAll };
|
|
288
|
+
module.exports = { exportAll, importAll, uploadToS3, testS3Connection };
|
|
@@ -70,11 +70,56 @@ const updateWebhooks = async ({ discordWebhookUrl, slackWebhookUrl, notifyPublic
|
|
|
70
70
|
});
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
const getBackupConfig = async () => {
|
|
74
|
+
const project = await getProject();
|
|
75
|
+
return {
|
|
76
|
+
backupEnabled: project.backupEnabled,
|
|
77
|
+
backupCron: project.backupCron,
|
|
78
|
+
backupS3Endpoint: project.backupS3Endpoint,
|
|
79
|
+
backupS3Region: project.backupS3Region,
|
|
80
|
+
backupS3Bucket: project.backupS3Bucket,
|
|
81
|
+
backupS3AccessKey: project.backupS3AccessKey,
|
|
82
|
+
backupS3SecretKeySet: project.backupS3SecretKey.length > 0,
|
|
83
|
+
backupS3Prefix: project.backupS3Prefix,
|
|
84
|
+
backupLastRunAt: project.backupLastRunAt,
|
|
85
|
+
backupLastStatus: project.backupLastStatus
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const updateBackupConfig = async ({
|
|
90
|
+
backupEnabled,
|
|
91
|
+
backupCron,
|
|
92
|
+
backupS3Endpoint,
|
|
93
|
+
backupS3Region,
|
|
94
|
+
backupS3Bucket,
|
|
95
|
+
backupS3AccessKey,
|
|
96
|
+
backupS3SecretKey,
|
|
97
|
+
backupS3Prefix
|
|
98
|
+
}) => {
|
|
99
|
+
const update = {
|
|
100
|
+
...(backupEnabled !== undefined && { backupEnabled }),
|
|
101
|
+
...(backupCron !== undefined && { backupCron }),
|
|
102
|
+
...(backupS3Endpoint !== undefined && { backupS3Endpoint }),
|
|
103
|
+
...(backupS3Region !== undefined && { backupS3Region }),
|
|
104
|
+
...(backupS3Bucket !== undefined && { backupS3Bucket }),
|
|
105
|
+
...(backupS3AccessKey !== undefined && { backupS3AccessKey }),
|
|
106
|
+
...(backupS3SecretKey && { backupS3SecretKey }),
|
|
107
|
+
...(backupS3Prefix !== undefined && { backupS3Prefix })
|
|
108
|
+
};
|
|
109
|
+
return prisma.project.upsert({
|
|
110
|
+
where: { id: 1 },
|
|
111
|
+
create: { id: 1, ...update },
|
|
112
|
+
update
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
73
116
|
module.exports = {
|
|
74
117
|
getProject,
|
|
75
118
|
updateProject,
|
|
76
119
|
getTestPrefixes,
|
|
77
120
|
updateTestPrefixes,
|
|
78
121
|
getWebhooks,
|
|
79
|
-
updateWebhooks
|
|
122
|
+
updateWebhooks,
|
|
123
|
+
getBackupConfig,
|
|
124
|
+
updateBackupConfig
|
|
80
125
|
};
|
|
@@ -47,6 +47,47 @@ export async function importBackup(data) {
|
|
|
47
47
|
return res.json();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export async function fetchBackupConfig() {
|
|
51
|
+
const res = await fetch(`${API_BASE}/backup/config`);
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
return {
|
|
54
|
+
backupEnabled: false,
|
|
55
|
+
backupCron: '0 2 * * *',
|
|
56
|
+
backupS3Endpoint: '',
|
|
57
|
+
backupS3Region: '',
|
|
58
|
+
backupS3Bucket: '',
|
|
59
|
+
backupS3AccessKey: '',
|
|
60
|
+
backupS3SecretKeySet: false,
|
|
61
|
+
backupS3Prefix: '',
|
|
62
|
+
backupLastRunAt: null,
|
|
63
|
+
backupLastStatus: ''
|
|
64
|
+
};
|
|
65
|
+
return res.json();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function saveBackupConfig(config) {
|
|
69
|
+
const res = await fetch(`${API_BASE}/backup/config`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify(config)
|
|
73
|
+
});
|
|
74
|
+
return res.json();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function testBackupS3(config) {
|
|
78
|
+
const res = await fetch(`${API_BASE}/backup/test-s3`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify(config)
|
|
82
|
+
});
|
|
83
|
+
return res.json();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runBackupNow() {
|
|
87
|
+
const res = await fetch(`${API_BASE}/backup/run-now`, { method: 'POST' });
|
|
88
|
+
return res.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
50
91
|
export async function fetchIntegrations() {
|
|
51
92
|
const res = await fetch(`${API_BASE}/settings/integrations`);
|
|
52
93
|
if (!res.ok) return { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|