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
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
exportBackup,
|
|
26
26
|
importBackup,
|
|
27
27
|
fetchIntegrations,
|
|
28
|
-
saveIntegrations
|
|
28
|
+
saveIntegrations,
|
|
29
|
+
fetchBackupConfig,
|
|
30
|
+
saveBackupConfig,
|
|
31
|
+
testBackupS3,
|
|
32
|
+
runBackupNow
|
|
29
33
|
} from '$lib/api/settings';
|
|
30
34
|
import {
|
|
31
35
|
fetchRunners,
|
|
@@ -94,6 +98,25 @@
|
|
|
94
98
|
let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
95
99
|
let integrationsSaving = false;
|
|
96
100
|
|
|
101
|
+
let backupConfig = {
|
|
102
|
+
backupEnabled: false,
|
|
103
|
+
backupCron: '0 2 * * *',
|
|
104
|
+
backupS3Endpoint: '',
|
|
105
|
+
backupS3Region: '',
|
|
106
|
+
backupS3Bucket: '',
|
|
107
|
+
backupS3AccessKey: '',
|
|
108
|
+
backupS3SecretKey: '',
|
|
109
|
+
backupS3Prefix: ''
|
|
110
|
+
};
|
|
111
|
+
let backupConfigSaving = false;
|
|
112
|
+
let backupS3SecretKeySet = false;
|
|
113
|
+
let backupTestingS3 = false;
|
|
114
|
+
let backupRunningNow = false;
|
|
115
|
+
let backupS3TestResult = null;
|
|
116
|
+
let backupS3TestMessage = '';
|
|
117
|
+
let backupLastRunAt = null;
|
|
118
|
+
let backupLastStatus = '';
|
|
119
|
+
|
|
97
120
|
let runners = [];
|
|
98
121
|
let runnerForm = { name: '', url: '', token: '', browser: 'chromium' };
|
|
99
122
|
let runnerFormError = '';
|
|
@@ -134,6 +157,22 @@
|
|
|
134
157
|
try {
|
|
135
158
|
integrations = await fetchIntegrations();
|
|
136
159
|
} catch {}
|
|
160
|
+
try {
|
|
161
|
+
const bc = await fetchBackupConfig();
|
|
162
|
+
backupS3SecretKeySet = bc.backupS3SecretKeySet;
|
|
163
|
+
backupLastRunAt = bc.backupLastRunAt;
|
|
164
|
+
backupLastStatus = bc.backupLastStatus;
|
|
165
|
+
backupConfig = {
|
|
166
|
+
backupEnabled: bc.backupEnabled,
|
|
167
|
+
backupCron: bc.backupCron,
|
|
168
|
+
backupS3Endpoint: bc.backupS3Endpoint,
|
|
169
|
+
backupS3Region: bc.backupS3Region,
|
|
170
|
+
backupS3Bucket: bc.backupS3Bucket,
|
|
171
|
+
backupS3AccessKey: bc.backupS3AccessKey,
|
|
172
|
+
backupS3SecretKey: '',
|
|
173
|
+
backupS3Prefix: bc.backupS3Prefix
|
|
174
|
+
};
|
|
175
|
+
} catch {}
|
|
137
176
|
if ($auth.user) {
|
|
138
177
|
profileForm = { name: $auth.user.name, email: $auth.user.email };
|
|
139
178
|
}
|
|
@@ -419,6 +458,60 @@
|
|
|
419
458
|
}
|
|
420
459
|
}
|
|
421
460
|
|
|
461
|
+
async function handleSaveBackupConfig() {
|
|
462
|
+
backupConfigSaving = true;
|
|
463
|
+
try {
|
|
464
|
+
const payload = { ...backupConfig };
|
|
465
|
+
if (!payload.backupS3SecretKey) delete payload.backupS3SecretKey;
|
|
466
|
+
const result = await saveBackupConfig(payload);
|
|
467
|
+
if (result.error) throw new Error(result.error);
|
|
468
|
+
if (backupConfig.backupS3SecretKey) backupS3SecretKeySet = true;
|
|
469
|
+
backupConfig = { ...backupConfig, backupS3SecretKey: '' };
|
|
470
|
+
showToast('success', 'Backup configuration saved.');
|
|
471
|
+
} catch (e) {
|
|
472
|
+
showToast('error', e.message || 'Failed to save backup configuration.');
|
|
473
|
+
} finally {
|
|
474
|
+
backupConfigSaving = false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function handleTestS3() {
|
|
479
|
+
backupTestingS3 = true;
|
|
480
|
+
backupS3TestResult = null;
|
|
481
|
+
backupS3TestMessage = '';
|
|
482
|
+
try {
|
|
483
|
+
const result = await testBackupS3(backupConfig);
|
|
484
|
+
if (result.error) throw new Error(result.error);
|
|
485
|
+
backupS3TestResult = 'success';
|
|
486
|
+
backupS3TestMessage = 'Connection successful.';
|
|
487
|
+
} catch (e) {
|
|
488
|
+
backupS3TestResult = 'error';
|
|
489
|
+
backupS3TestMessage = e.message || 'Connection failed.';
|
|
490
|
+
} finally {
|
|
491
|
+
backupTestingS3 = false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function handleRunBackupNow() {
|
|
496
|
+
backupRunningNow = true;
|
|
497
|
+
try {
|
|
498
|
+
const result = await runBackupNow();
|
|
499
|
+
if (result.error) throw new Error(result.error);
|
|
500
|
+
backupLastRunAt = result.lastRunAt;
|
|
501
|
+
backupLastStatus = result.lastStatus ?? '';
|
|
502
|
+
showToast('success', 'Backup uploaded to S3 successfully.');
|
|
503
|
+
} catch (e) {
|
|
504
|
+
showToast('error', e.message || 'Backup failed. Check your S3 configuration.');
|
|
505
|
+
const bc = await fetchBackupConfig().catch(() => null);
|
|
506
|
+
if (bc) {
|
|
507
|
+
backupLastRunAt = bc.backupLastRunAt;
|
|
508
|
+
backupLastStatus = bc.backupLastStatus;
|
|
509
|
+
}
|
|
510
|
+
} finally {
|
|
511
|
+
backupRunningNow = false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
422
515
|
$: navItems = [
|
|
423
516
|
{ id: 'project', label: 'Project' },
|
|
424
517
|
{ id: 'runners', label: 'Runners' },
|
|
@@ -1108,21 +1201,23 @@
|
|
|
1108
1201
|
<div class="content-header">
|
|
1109
1202
|
<h2>Backup</h2>
|
|
1110
1203
|
<p class="content-desc">
|
|
1111
|
-
Export
|
|
1112
|
-
|
|
1204
|
+
Export your test cases, schedules, users, and project settings. Automate uploads to any
|
|
1205
|
+
S3-compatible storage — Cloudflare R2, Backblaze B2, AWS S3, or MinIO.
|
|
1113
1206
|
</p>
|
|
1114
1207
|
</div>
|
|
1115
1208
|
|
|
1209
|
+
<!-- Manual export / import -->
|
|
1116
1210
|
<div class="card settings-card">
|
|
1211
|
+
<p class="card-title">Manual Backup</p>
|
|
1117
1212
|
<div class="backup-row">
|
|
1118
1213
|
<div class="backup-block">
|
|
1119
1214
|
<p class="backup-block-title">Export</p>
|
|
1120
1215
|
<p class="backup-block-desc">
|
|
1121
|
-
Downloads a <code>.json</code> file
|
|
1122
|
-
|
|
1216
|
+
Downloads a <code>.json</code> file with all cron jobs, test cases, test runs, users,
|
|
1217
|
+
runners, and project settings.
|
|
1123
1218
|
</p>
|
|
1124
1219
|
<Button on:click={handleExport} disabled={exporting}>
|
|
1125
|
-
{exporting ? 'Exporting…' : 'Export
|
|
1220
|
+
{exporting ? 'Exporting…' : 'Export JSON'}
|
|
1126
1221
|
</Button>
|
|
1127
1222
|
</div>
|
|
1128
1223
|
|
|
@@ -1131,8 +1226,8 @@
|
|
|
1131
1226
|
<div class="backup-block">
|
|
1132
1227
|
<p class="backup-block-title">Import</p>
|
|
1133
1228
|
<p class="backup-block-desc">
|
|
1134
|
-
Restores
|
|
1135
|
-
|
|
1229
|
+
Restores all data from a previously exported backup. Existing records are
|
|
1230
|
+
overwritten. Cron jobs are re-scheduled after import.
|
|
1136
1231
|
</p>
|
|
1137
1232
|
<div class="import-row">
|
|
1138
1233
|
<label class="file-label">
|
|
@@ -1151,6 +1246,194 @@
|
|
|
1151
1246
|
</div>
|
|
1152
1247
|
</div>
|
|
1153
1248
|
</div>
|
|
1249
|
+
<p class="backup-disclaimer">
|
|
1250
|
+
Reports are not included in backups. To back up report history, run
|
|
1251
|
+
<code>pg_dump</code> directly on the PostgreSQL volume.
|
|
1252
|
+
</p>
|
|
1253
|
+
</div>
|
|
1254
|
+
|
|
1255
|
+
<!-- S3 cloud backup -->
|
|
1256
|
+
<div class="card settings-card">
|
|
1257
|
+
<p class="card-title">S3 Storage</p>
|
|
1258
|
+
<p class="backup-block-desc" style="margin-bottom: 1.25rem;">
|
|
1259
|
+
Works with any S3-compatible provider — Cloudflare R2, Backblaze B2, AWS S3, or MinIO.
|
|
1260
|
+
Leave <strong>Endpoint URL</strong> empty for AWS S3.
|
|
1261
|
+
</p>
|
|
1262
|
+
|
|
1263
|
+
<div class="field-row">
|
|
1264
|
+
<div class="field">
|
|
1265
|
+
<label class="field-label" for="s3-endpoint">
|
|
1266
|
+
<span>Endpoint URL</span>
|
|
1267
|
+
<span class="field-hint">Leave blank for AWS S3</span>
|
|
1268
|
+
</label>
|
|
1269
|
+
<input
|
|
1270
|
+
id="s3-endpoint"
|
|
1271
|
+
type="url"
|
|
1272
|
+
class="field-input"
|
|
1273
|
+
bind:value={backupConfig.backupS3Endpoint}
|
|
1274
|
+
placeholder="https://account.r2.cloudflarestorage.com"
|
|
1275
|
+
/>
|
|
1276
|
+
</div>
|
|
1277
|
+
<div class="field">
|
|
1278
|
+
<label class="field-label" for="s3-region">
|
|
1279
|
+
<span>Region</span>
|
|
1280
|
+
<span class="field-hint">Use <code>auto</code> for Cloudflare R2</span>
|
|
1281
|
+
</label>
|
|
1282
|
+
<input
|
|
1283
|
+
id="s3-region"
|
|
1284
|
+
type="text"
|
|
1285
|
+
class="field-input"
|
|
1286
|
+
bind:value={backupConfig.backupS3Region}
|
|
1287
|
+
placeholder="us-east-1"
|
|
1288
|
+
/>
|
|
1289
|
+
</div>
|
|
1290
|
+
</div>
|
|
1291
|
+
|
|
1292
|
+
<div class="field-row">
|
|
1293
|
+
<div class="field">
|
|
1294
|
+
<label class="field-label" for="s3-bucket">
|
|
1295
|
+
<span>Bucket</span>
|
|
1296
|
+
</label>
|
|
1297
|
+
<input
|
|
1298
|
+
id="s3-bucket"
|
|
1299
|
+
type="text"
|
|
1300
|
+
class="field-input"
|
|
1301
|
+
bind:value={backupConfig.backupS3Bucket}
|
|
1302
|
+
placeholder="my-plum-backups"
|
|
1303
|
+
/>
|
|
1304
|
+
</div>
|
|
1305
|
+
<div class="field">
|
|
1306
|
+
<label class="field-label" for="s3-prefix">
|
|
1307
|
+
<span>Path Prefix</span>
|
|
1308
|
+
<span class="field-hint">Optional folder inside the bucket</span>
|
|
1309
|
+
</label>
|
|
1310
|
+
<input
|
|
1311
|
+
id="s3-prefix"
|
|
1312
|
+
type="text"
|
|
1313
|
+
class="field-input"
|
|
1314
|
+
bind:value={backupConfig.backupS3Prefix}
|
|
1315
|
+
placeholder="plum/"
|
|
1316
|
+
/>
|
|
1317
|
+
</div>
|
|
1318
|
+
</div>
|
|
1319
|
+
|
|
1320
|
+
<div class="field-row">
|
|
1321
|
+
<div class="field">
|
|
1322
|
+
<label class="field-label" for="s3-access-key">
|
|
1323
|
+
<span>Access Key ID</span>
|
|
1324
|
+
</label>
|
|
1325
|
+
<input
|
|
1326
|
+
id="s3-access-key"
|
|
1327
|
+
type="text"
|
|
1328
|
+
class="field-input"
|
|
1329
|
+
bind:value={backupConfig.backupS3AccessKey}
|
|
1330
|
+
placeholder="AKIAIOSFODNN7EXAMPLE"
|
|
1331
|
+
autocomplete="off"
|
|
1332
|
+
/>
|
|
1333
|
+
</div>
|
|
1334
|
+
<div class="field">
|
|
1335
|
+
<label class="field-label" for="s3-secret-key">
|
|
1336
|
+
<span>Secret Access Key</span>
|
|
1337
|
+
<span class="field-hint"
|
|
1338
|
+
>{backupS3SecretKeySet
|
|
1339
|
+
? 'A key is already saved — leave blank to keep it'
|
|
1340
|
+
: 'Required'}</span
|
|
1341
|
+
>
|
|
1342
|
+
</label>
|
|
1343
|
+
<input
|
|
1344
|
+
id="s3-secret-key"
|
|
1345
|
+
type="password"
|
|
1346
|
+
class="field-input"
|
|
1347
|
+
bind:value={backupConfig.backupS3SecretKey}
|
|
1348
|
+
placeholder={backupS3SecretKeySet ? '••••••••' : 'Enter secret key'}
|
|
1349
|
+
autocomplete="new-password"
|
|
1350
|
+
/>
|
|
1351
|
+
</div>
|
|
1352
|
+
</div>
|
|
1353
|
+
|
|
1354
|
+
<div class="backup-actions">
|
|
1355
|
+
<Button variant="ghost" on:click={handleTestS3} disabled={backupTestingS3}>
|
|
1356
|
+
{backupTestingS3 ? 'Testing…' : 'Test Connection'}
|
|
1357
|
+
</Button>
|
|
1358
|
+
<Button on:click={handleSaveBackupConfig} disabled={backupConfigSaving}>
|
|
1359
|
+
{backupConfigSaving ? 'Saving…' : 'Save S3 Config'}
|
|
1360
|
+
</Button>
|
|
1361
|
+
</div>
|
|
1362
|
+
|
|
1363
|
+
{#if backupS3TestResult}
|
|
1364
|
+
<p
|
|
1365
|
+
class="s3-test-result"
|
|
1366
|
+
class:s3-test-success={backupS3TestResult === 'success'}
|
|
1367
|
+
class:s3-test-error={backupS3TestResult === 'error'}
|
|
1368
|
+
>
|
|
1369
|
+
{backupS3TestResult === 'success' ? '✓' : '✗'}
|
|
1370
|
+
{backupS3TestMessage}
|
|
1371
|
+
</p>
|
|
1372
|
+
{/if}
|
|
1373
|
+
</div>
|
|
1374
|
+
|
|
1375
|
+
<!-- Scheduled backup -->
|
|
1376
|
+
<div class="card settings-card">
|
|
1377
|
+
<p class="card-title">Scheduled Backup</p>
|
|
1378
|
+
|
|
1379
|
+
<div class="field">
|
|
1380
|
+
<label class="field-label backup-toggle-label" for="backup-enabled">
|
|
1381
|
+
<span>Enable scheduled backup</span>
|
|
1382
|
+
<button
|
|
1383
|
+
id="backup-enabled"
|
|
1384
|
+
class="toggle-btn"
|
|
1385
|
+
class:active={backupConfig.backupEnabled}
|
|
1386
|
+
on:click={() => (backupConfig.backupEnabled = !backupConfig.backupEnabled)}
|
|
1387
|
+
role="switch"
|
|
1388
|
+
aria-checked={backupConfig.backupEnabled}
|
|
1389
|
+
>
|
|
1390
|
+
<span class="toggle-thumb"></span>
|
|
1391
|
+
</button>
|
|
1392
|
+
</label>
|
|
1393
|
+
</div>
|
|
1394
|
+
|
|
1395
|
+
<div class="field">
|
|
1396
|
+
<label class="field-label" for="backup-cron">
|
|
1397
|
+
<span>Cron Expression</span>
|
|
1398
|
+
<span class="field-hint">
|
|
1399
|
+
5-field cron — e.g. <code>0 2 * * *</code> = daily at 2 AM.
|
|
1400
|
+
<a href="https://crontab.guru" target="_blank" rel="noopener noreferrer"
|
|
1401
|
+
>Test at crontab.guru ↗</a
|
|
1402
|
+
>
|
|
1403
|
+
</span>
|
|
1404
|
+
</label>
|
|
1405
|
+
<input
|
|
1406
|
+
id="backup-cron"
|
|
1407
|
+
type="text"
|
|
1408
|
+
class="field-input field-input-mono"
|
|
1409
|
+
bind:value={backupConfig.backupCron}
|
|
1410
|
+
placeholder="0 2 * * *"
|
|
1411
|
+
/>
|
|
1412
|
+
</div>
|
|
1413
|
+
|
|
1414
|
+
{#if backupLastRunAt}
|
|
1415
|
+
<p class="backup-last-run">
|
|
1416
|
+
Last backup: {new Date(backupLastRunAt).toLocaleString()} —
|
|
1417
|
+
{#if backupLastStatus?.startsWith('success:')}
|
|
1418
|
+
<span class="status-success"
|
|
1419
|
+
>uploaded to {backupLastStatus.replace('success:', '')}</span
|
|
1420
|
+
>
|
|
1421
|
+
{:else if backupLastStatus?.startsWith('error:')}
|
|
1422
|
+
<span class="status-error">{backupLastStatus.replace('error:', '')}</span>
|
|
1423
|
+
{:else}
|
|
1424
|
+
<span>{backupLastStatus}</span>
|
|
1425
|
+
{/if}
|
|
1426
|
+
</p>
|
|
1427
|
+
{/if}
|
|
1428
|
+
|
|
1429
|
+
<div class="backup-actions">
|
|
1430
|
+
<Button variant="ghost" on:click={handleRunBackupNow} disabled={backupRunningNow}>
|
|
1431
|
+
{backupRunningNow ? 'Uploading…' : 'Upload to S3 Now'}
|
|
1432
|
+
</Button>
|
|
1433
|
+
<Button on:click={handleSaveBackupConfig} disabled={backupConfigSaving}>
|
|
1434
|
+
{backupConfigSaving ? 'Saving…' : 'Save Schedule'}
|
|
1435
|
+
</Button>
|
|
1436
|
+
</div>
|
|
1154
1437
|
</div>
|
|
1155
1438
|
</div>
|
|
1156
1439
|
{/if}
|
|
@@ -1520,6 +1803,96 @@
|
|
|
1520
1803
|
border-radius: 3px;
|
|
1521
1804
|
}
|
|
1522
1805
|
|
|
1806
|
+
.backup-disclaimer {
|
|
1807
|
+
margin-top: 1rem;
|
|
1808
|
+
padding: 0.625rem 0.875rem;
|
|
1809
|
+
background: var(--bg-subtle);
|
|
1810
|
+
border: 1px solid var(--border);
|
|
1811
|
+
border-radius: var(--radius-sm);
|
|
1812
|
+
font-size: 0.8125rem;
|
|
1813
|
+
color: var(--text-muted);
|
|
1814
|
+
line-height: 1.5;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
.backup-actions {
|
|
1818
|
+
display: flex;
|
|
1819
|
+
gap: 0.625rem;
|
|
1820
|
+
margin-top: 0.25rem;
|
|
1821
|
+
flex-wrap: wrap;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
.s3-test-result {
|
|
1825
|
+
margin-top: 0.625rem;
|
|
1826
|
+
font-size: 0.8125rem;
|
|
1827
|
+
font-weight: 500;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
.s3-test-success {
|
|
1831
|
+
color: var(--pass);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
.s3-test-error {
|
|
1835
|
+
color: var(--fail);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
.backup-last-run {
|
|
1839
|
+
font-size: 0.8125rem;
|
|
1840
|
+
color: var(--text-muted);
|
|
1841
|
+
margin-top: 0.25rem;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
.status-success {
|
|
1845
|
+
color: var(--pass);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
.status-error {
|
|
1849
|
+
color: var(--fail);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
.backup-toggle-label {
|
|
1853
|
+
display: flex;
|
|
1854
|
+
align-items: center;
|
|
1855
|
+
justify-content: space-between;
|
|
1856
|
+
cursor: default;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
.toggle-btn {
|
|
1860
|
+
flex-shrink: 0;
|
|
1861
|
+
width: 40px;
|
|
1862
|
+
height: 22px;
|
|
1863
|
+
border-radius: 100px;
|
|
1864
|
+
border: none;
|
|
1865
|
+
background: var(--border);
|
|
1866
|
+
cursor: pointer;
|
|
1867
|
+
position: relative;
|
|
1868
|
+
transition: background 0.2s var(--ease-out);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
.toggle-btn.active {
|
|
1872
|
+
background: var(--accent);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
.toggle-btn .toggle-thumb {
|
|
1876
|
+
position: absolute;
|
|
1877
|
+
top: 3px;
|
|
1878
|
+
left: 3px;
|
|
1879
|
+
width: 16px;
|
|
1880
|
+
height: 16px;
|
|
1881
|
+
border-radius: 50%;
|
|
1882
|
+
background: white;
|
|
1883
|
+
transition: transform 0.2s var(--ease-out);
|
|
1884
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
.toggle-btn.active .toggle-thumb {
|
|
1888
|
+
transform: translateX(18px);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
.field-input-mono {
|
|
1892
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1893
|
+
font-size: 0.8125rem;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1523
1896
|
.import-row {
|
|
1524
1897
|
display: flex;
|
|
1525
1898
|
align-items: center;
|