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
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
toggleCronJob
|
|
26
26
|
} from '$lib/api/schedules';
|
|
27
27
|
import { fetchRunners } from '$lib/api/runners';
|
|
28
|
+
import { fetchIntegrations } from '$lib/api/settings';
|
|
28
29
|
import { activeCronJobs } from '$lib/stores/runner';
|
|
29
30
|
import { BROWSERS, TOAST_TIMEOUT_MS } from '$lib/constants';
|
|
30
31
|
import { stagger } from '$lib/utils/format';
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
|
|
51
52
|
let cronJobs = [];
|
|
52
53
|
let availableRunners = [];
|
|
54
|
+
let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
53
55
|
let toast = null;
|
|
54
56
|
|
|
55
57
|
let modalOpen = false;
|
|
@@ -64,7 +66,9 @@
|
|
|
64
66
|
tags: '',
|
|
65
67
|
workers: 1,
|
|
66
68
|
browser: 'chromium',
|
|
67
|
-
runnerIds: ['built-in']
|
|
69
|
+
runnerIds: ['built-in'],
|
|
70
|
+
notifyDiscord: false,
|
|
71
|
+
notifySlack: false
|
|
68
72
|
};
|
|
69
73
|
let selectedSchedule = '';
|
|
70
74
|
let useCustomCron = false;
|
|
@@ -137,7 +141,9 @@
|
|
|
137
141
|
tags: '',
|
|
138
142
|
workers: 1,
|
|
139
143
|
browser: 'chromium',
|
|
140
|
-
runnerIds: ['built-in']
|
|
144
|
+
runnerIds: ['built-in'],
|
|
145
|
+
notifyDiscord: false,
|
|
146
|
+
notifySlack: false
|
|
141
147
|
};
|
|
142
148
|
selectedSchedule = '';
|
|
143
149
|
useCustomCron = false;
|
|
@@ -157,7 +163,9 @@
|
|
|
157
163
|
tags: job.tags,
|
|
158
164
|
workers: job.workers ?? 1,
|
|
159
165
|
browser: job.browser ?? 'chromium',
|
|
160
|
-
runnerIds: prunedIds.length > 0 ? prunedIds : ['built-in']
|
|
166
|
+
runnerIds: prunedIds.length > 0 ? prunedIds : ['built-in'],
|
|
167
|
+
notifyDiscord: job.notifyDiscord ?? false,
|
|
168
|
+
notifySlack: job.notifySlack ?? false
|
|
161
169
|
};
|
|
162
170
|
const isPreset = scheduleOptions.some((o) => o.value === job.cronExpression);
|
|
163
171
|
useCustomCron = !isPreset;
|
|
@@ -231,10 +239,15 @@
|
|
|
231
239
|
}
|
|
232
240
|
|
|
233
241
|
onMount(async () => {
|
|
234
|
-
cronJobs = await
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
242
|
+
[cronJobs, availableRunners, integrations] = await Promise.all([
|
|
243
|
+
fetchCronJobs(),
|
|
244
|
+
fetchRunners().catch(() => []),
|
|
245
|
+
fetchIntegrations().catch(() => ({
|
|
246
|
+
discordWebhookUrl: '',
|
|
247
|
+
slackWebhookUrl: '',
|
|
248
|
+
notifyPublicUrl: ''
|
|
249
|
+
}))
|
|
250
|
+
]);
|
|
238
251
|
});
|
|
239
252
|
</script>
|
|
240
253
|
|
|
@@ -373,6 +386,26 @@
|
|
|
373
386
|
</div>
|
|
374
387
|
</div>
|
|
375
388
|
|
|
389
|
+
{#if integrations.discordWebhookUrl || integrations.slackWebhookUrl}
|
|
390
|
+
<div class="field">
|
|
391
|
+
<div class="field-label"><span>Notifications</span></div>
|
|
392
|
+
<div class="notify-checks">
|
|
393
|
+
{#if integrations.discordWebhookUrl}
|
|
394
|
+
<label class="notify-check-option">
|
|
395
|
+
<input type="checkbox" bind:checked={form.notifyDiscord} />
|
|
396
|
+
<span>Discord</span>
|
|
397
|
+
</label>
|
|
398
|
+
{/if}
|
|
399
|
+
{#if integrations.slackWebhookUrl}
|
|
400
|
+
<label class="notify-check-option">
|
|
401
|
+
<input type="checkbox" bind:checked={form.notifySlack} />
|
|
402
|
+
<span>Slack</span>
|
|
403
|
+
</label>
|
|
404
|
+
{/if}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
{/if}
|
|
408
|
+
|
|
376
409
|
{#if formError}
|
|
377
410
|
<p class="form-error">{formError}</p>
|
|
378
411
|
{/if}
|
|
@@ -859,4 +892,29 @@
|
|
|
859
892
|
text-overflow: ellipsis;
|
|
860
893
|
max-width: 180px;
|
|
861
894
|
}
|
|
895
|
+
|
|
896
|
+
/* Notification checkboxes in modal */
|
|
897
|
+
.notify-checks {
|
|
898
|
+
display: flex;
|
|
899
|
+
flex-direction: row;
|
|
900
|
+
gap: 1rem;
|
|
901
|
+
padding: 0.375rem 0;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.notify-check-option {
|
|
905
|
+
display: flex;
|
|
906
|
+
align-items: center;
|
|
907
|
+
gap: 0.5rem;
|
|
908
|
+
font-size: 0.8125rem;
|
|
909
|
+
color: var(--text);
|
|
910
|
+
cursor: pointer;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
.notify-check-option input[type='checkbox'] {
|
|
914
|
+
accent-color: var(--accent);
|
|
915
|
+
width: 13px;
|
|
916
|
+
height: 13px;
|
|
917
|
+
flex-shrink: 0;
|
|
918
|
+
cursor: pointer;
|
|
919
|
+
}
|
|
862
920
|
</style>
|
|
@@ -19,7 +19,18 @@
|
|
|
19
19
|
import { onMount } from 'svelte';
|
|
20
20
|
import { fly } from 'svelte/transition';
|
|
21
21
|
import { goto } from '$app/navigation';
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
fetchProject,
|
|
24
|
+
saveProject,
|
|
25
|
+
exportBackup,
|
|
26
|
+
importBackup,
|
|
27
|
+
fetchIntegrations,
|
|
28
|
+
saveIntegrations,
|
|
29
|
+
fetchBackupConfig,
|
|
30
|
+
saveBackupConfig,
|
|
31
|
+
testBackupS3,
|
|
32
|
+
runBackupNow
|
|
33
|
+
} from '$lib/api/settings';
|
|
23
34
|
import {
|
|
24
35
|
fetchRunners,
|
|
25
36
|
createRunner,
|
|
@@ -43,7 +54,7 @@
|
|
|
43
54
|
import Toast from '$lib/components/ui/Toast.svelte';
|
|
44
55
|
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
45
56
|
|
|
46
|
-
/** @type {'project' | 'runners' | 'repository' | 'account' | 'users' | 'backup'} */
|
|
57
|
+
/** @type {'project' | 'runners' | 'repository' | 'integrations' | 'account' | 'users' | 'backup'} */
|
|
47
58
|
let section =
|
|
48
59
|
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('plum:settings:section')) ||
|
|
49
60
|
'project';
|
|
@@ -84,6 +95,28 @@
|
|
|
84
95
|
let exporting = false;
|
|
85
96
|
let fileInput;
|
|
86
97
|
|
|
98
|
+
let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
99
|
+
let integrationsSaving = false;
|
|
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
|
+
|
|
87
120
|
let runners = [];
|
|
88
121
|
let runnerForm = { name: '', url: '', token: '', browser: 'chromium' };
|
|
89
122
|
let runnerFormError = '';
|
|
@@ -121,6 +154,25 @@
|
|
|
121
154
|
testSuitePrefix: prefixes.testSuitePrefix
|
|
122
155
|
};
|
|
123
156
|
} catch {}
|
|
157
|
+
try {
|
|
158
|
+
integrations = await fetchIntegrations();
|
|
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 {}
|
|
124
176
|
if ($auth.user) {
|
|
125
177
|
profileForm = { name: $auth.user.name, email: $auth.user.email };
|
|
126
178
|
}
|
|
@@ -394,10 +446,77 @@
|
|
|
394
446
|
goto('/login');
|
|
395
447
|
}
|
|
396
448
|
|
|
449
|
+
async function handleSaveIntegrations() {
|
|
450
|
+
integrationsSaving = true;
|
|
451
|
+
try {
|
|
452
|
+
integrations = await saveIntegrations(integrations);
|
|
453
|
+
showToast('success', 'Integration settings saved.');
|
|
454
|
+
} catch {
|
|
455
|
+
showToast('error', 'Failed to save integration settings.');
|
|
456
|
+
} finally {
|
|
457
|
+
integrationsSaving = false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
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
|
+
|
|
397
515
|
$: navItems = [
|
|
398
516
|
{ id: 'project', label: 'Project' },
|
|
399
517
|
{ id: 'runners', label: 'Runners' },
|
|
400
518
|
{ id: 'repository', label: 'Repository' },
|
|
519
|
+
{ id: 'integrations', label: 'Integrations' },
|
|
401
520
|
{ id: 'account', label: 'Account' },
|
|
402
521
|
...($auth.user?.role === 'admin' ? [{ id: 'users', label: 'Users' }] : []),
|
|
403
522
|
{ id: 'backup', label: 'Backup' }
|
|
@@ -811,6 +930,70 @@
|
|
|
811
930
|
</div>
|
|
812
931
|
</div>
|
|
813
932
|
|
|
933
|
+
<!-- INTEGRATIONS -->
|
|
934
|
+
{:else if section === 'integrations'}
|
|
935
|
+
<div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
|
|
936
|
+
<div class="content-header">
|
|
937
|
+
<h2>Integrations</h2>
|
|
938
|
+
<p class="content-desc">
|
|
939
|
+
Connect Discord and Slack to receive run notifications with pass/fail results and report
|
|
940
|
+
links.
|
|
941
|
+
</p>
|
|
942
|
+
</div>
|
|
943
|
+
|
|
944
|
+
<div class="card settings-card">
|
|
945
|
+
<p class="card-title">Webhooks</p>
|
|
946
|
+
|
|
947
|
+
<div class="field">
|
|
948
|
+
<label class="field-label" for="discord-url">
|
|
949
|
+
<span>Discord Webhook URL</span>
|
|
950
|
+
<span class="field-hint">Leave blank to disable Discord notifications</span>
|
|
951
|
+
</label>
|
|
952
|
+
<input
|
|
953
|
+
id="discord-url"
|
|
954
|
+
type="url"
|
|
955
|
+
class="field-input"
|
|
956
|
+
bind:value={integrations.discordWebhookUrl}
|
|
957
|
+
placeholder="https://discord.com/api/webhooks/…"
|
|
958
|
+
/>
|
|
959
|
+
</div>
|
|
960
|
+
|
|
961
|
+
<div class="field">
|
|
962
|
+
<label class="field-label" for="slack-url">
|
|
963
|
+
<span>Slack Webhook URL</span>
|
|
964
|
+
<span class="field-hint">Leave blank to disable Slack notifications</span>
|
|
965
|
+
</label>
|
|
966
|
+
<input
|
|
967
|
+
id="slack-url"
|
|
968
|
+
type="url"
|
|
969
|
+
class="field-input"
|
|
970
|
+
bind:value={integrations.slackWebhookUrl}
|
|
971
|
+
placeholder="https://hooks.slack.com/services/…"
|
|
972
|
+
/>
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
<div class="field">
|
|
976
|
+
<label class="field-label" for="public-url">
|
|
977
|
+
<span>Public URL</span>
|
|
978
|
+
<span class="field-hint"
|
|
979
|
+
>Base URL of this Plum instance, used to link reports in notifications</span
|
|
980
|
+
>
|
|
981
|
+
</label>
|
|
982
|
+
<input
|
|
983
|
+
id="public-url"
|
|
984
|
+
type="url"
|
|
985
|
+
class="field-input"
|
|
986
|
+
bind:value={integrations.notifyPublicUrl}
|
|
987
|
+
placeholder="https://plum.yourcompany.com"
|
|
988
|
+
/>
|
|
989
|
+
</div>
|
|
990
|
+
|
|
991
|
+
<Button on:click={handleSaveIntegrations} disabled={integrationsSaving}>
|
|
992
|
+
{integrationsSaving ? 'Saving…' : 'Save Integrations'}
|
|
993
|
+
</Button>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
|
|
814
997
|
<!-- ACCOUNT -->
|
|
815
998
|
{:else if section === 'account'}
|
|
816
999
|
<div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
|
|
@@ -1018,21 +1201,23 @@
|
|
|
1018
1201
|
<div class="content-header">
|
|
1019
1202
|
<h2>Backup</h2>
|
|
1020
1203
|
<p class="content-desc">
|
|
1021
|
-
Export
|
|
1022
|
-
|
|
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.
|
|
1023
1206
|
</p>
|
|
1024
1207
|
</div>
|
|
1025
1208
|
|
|
1209
|
+
<!-- Manual export / import -->
|
|
1026
1210
|
<div class="card settings-card">
|
|
1211
|
+
<p class="card-title">Manual Backup</p>
|
|
1027
1212
|
<div class="backup-row">
|
|
1028
1213
|
<div class="backup-block">
|
|
1029
1214
|
<p class="backup-block-title">Export</p>
|
|
1030
1215
|
<p class="backup-block-desc">
|
|
1031
|
-
Downloads a <code>.json</code> file
|
|
1032
|
-
|
|
1216
|
+
Downloads a <code>.json</code> file with all cron jobs, test cases, test runs, users,
|
|
1217
|
+
runners, and project settings.
|
|
1033
1218
|
</p>
|
|
1034
1219
|
<Button on:click={handleExport} disabled={exporting}>
|
|
1035
|
-
{exporting ? 'Exporting…' : 'Export
|
|
1220
|
+
{exporting ? 'Exporting…' : 'Export JSON'}
|
|
1036
1221
|
</Button>
|
|
1037
1222
|
</div>
|
|
1038
1223
|
|
|
@@ -1041,8 +1226,8 @@
|
|
|
1041
1226
|
<div class="backup-block">
|
|
1042
1227
|
<p class="backup-block-title">Import</p>
|
|
1043
1228
|
<p class="backup-block-desc">
|
|
1044
|
-
Restores
|
|
1045
|
-
|
|
1229
|
+
Restores all data from a previously exported backup. Existing records are
|
|
1230
|
+
overwritten. Cron jobs are re-scheduled after import.
|
|
1046
1231
|
</p>
|
|
1047
1232
|
<div class="import-row">
|
|
1048
1233
|
<label class="file-label">
|
|
@@ -1061,6 +1246,194 @@
|
|
|
1061
1246
|
</div>
|
|
1062
1247
|
</div>
|
|
1063
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>
|
|
1064
1437
|
</div>
|
|
1065
1438
|
</div>
|
|
1066
1439
|
{/if}
|
|
@@ -1430,6 +1803,96 @@
|
|
|
1430
1803
|
border-radius: 3px;
|
|
1431
1804
|
}
|
|
1432
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
|
+
|
|
1433
1896
|
.import-row {
|
|
1434
1897
|
display: flex;
|
|
1435
1898
|
align-items: center;
|