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.
Files changed (31) hide show
  1. package/README.md +61 -470
  2. package/backend/lib/runnerProcess.js +50 -4
  3. package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
  4. package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
  5. package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
  6. package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
  7. package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
  8. package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
  9. package/backend/package.json +1 -0
  10. package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
  11. package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
  12. package/backend/prisma/schema.prisma +22 -7
  13. package/backend/routes/backup.routes.js +70 -5
  14. package/backend/routes/node.routes.js +9 -0
  15. package/backend/routes/runners.routes.js +10 -0
  16. package/backend/routes/settings.routes.js +27 -0
  17. package/backend/scripts/manage-runners.mjs +49 -8
  18. package/backend/server.js +25 -1
  19. package/backend/services/backupCronService.js +82 -0
  20. package/backend/services/backupService.js +254 -27
  21. package/backend/services/cronService.js +91 -7
  22. package/backend/services/notificationService.js +163 -0
  23. package/backend/services/settingsService.js +74 -1
  24. package/backend/websockets/socketHandler.js +82 -6
  25. package/frontend/src/lib/api/schedules.js +5 -1
  26. package/frontend/src/lib/api/settings.js +56 -0
  27. package/frontend/src/lib/components/layout/RunnerPanel.svelte +79 -3
  28. package/frontend/src/lib/stores/runner.js +4 -2
  29. package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
  30. package/frontend/src/routes/settings/+page.svelte +472 -9
  31. 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 fetchCronJobs();
235
- try {
236
- availableRunners = await fetchRunners();
237
- } catch {}
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 { fetchProject, saveProject, exportBackup, importBackup } from '$lib/api/settings';
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 all scheduled tests, report history, and project settings to a JSON file. Import
1022
- to restore after a data loss or migration.
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 containing all cron jobs, report metadata, and project
1032
- settings. Report detail files are stored on disk and not included.
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 Backup'}
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 cron jobs, report metadata, and project settings from a previously exported
1045
- backup. Existing records with the same identifier are overwritten.
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"