plum-e2e 2.2.0 → 2.4.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/app.js +1 -0
- package/backend/constants/triggers.js +3 -1
- package/backend/lib/serverConfig.js +23 -14
- package/backend/mcp/server.js +385 -0
- package/backend/middleware/jwtAuth.js +18 -0
- package/backend/package-lock.json +1432 -28
- package/backend/package.json +4 -1
- package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
- package/backend/prisma/migrations/20260621000002_add_mcp_key/migration.sql +2 -0
- package/backend/prisma/schema.prisma +21 -10
- package/backend/routes/backup.routes.js +70 -5
- package/backend/routes/settings.routes.js +18 -0
- package/backend/routes/trigger.routes.js +94 -0
- package/backend/scripts/manage-runners.mjs +25 -4
- package/backend/server.js +11 -0
- package/backend/services/backupCronService.js +82 -0
- package/backend/services/backupService.js +254 -27
- package/backend/services/settingsService.js +65 -1
- package/bin/plum.js +18 -51
- package/frontend/src/lib/api/settings.js +61 -0
- package/frontend/src/lib/components/layout/Nav.svelte +0 -1
- package/frontend/src/routes/+layout.js +20 -0
- package/frontend/src/routes/+layout.svelte +24 -16
- package/frontend/src/routes/settings/+page.svelte +622 -9
- package/package.json +2 -2
|
@@ -25,7 +25,13 @@
|
|
|
25
25
|
exportBackup,
|
|
26
26
|
importBackup,
|
|
27
27
|
fetchIntegrations,
|
|
28
|
-
saveIntegrations
|
|
28
|
+
saveIntegrations,
|
|
29
|
+
fetchBackupConfig,
|
|
30
|
+
saveBackupConfig,
|
|
31
|
+
testBackupS3,
|
|
32
|
+
runBackupNow,
|
|
33
|
+
fetchMcpConfig,
|
|
34
|
+
generateMcpKey as generateMcpKeyApi
|
|
29
35
|
} from '$lib/api/settings';
|
|
30
36
|
import {
|
|
31
37
|
fetchRunners,
|
|
@@ -50,7 +56,7 @@
|
|
|
50
56
|
import Toast from '$lib/components/ui/Toast.svelte';
|
|
51
57
|
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
52
58
|
|
|
53
|
-
/** @type {'project' | 'runners' | 'repository' | 'integrations' | 'account' | 'users' | 'backup'} */
|
|
59
|
+
/** @type {'project' | 'runners' | 'repository' | 'integrations' | 'mcp' | 'account' | 'users' | 'backup'} */
|
|
54
60
|
let section =
|
|
55
61
|
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('plum:settings:section')) ||
|
|
56
62
|
'project';
|
|
@@ -94,6 +100,32 @@
|
|
|
94
100
|
let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
95
101
|
let integrationsSaving = false;
|
|
96
102
|
|
|
103
|
+
let mcpKey = '';
|
|
104
|
+
let mcpKeySet = false;
|
|
105
|
+
let mcpShowKey = false;
|
|
106
|
+
let mcpGenerating = false;
|
|
107
|
+
let mcpKeyCopied = false;
|
|
108
|
+
let mcpSnippetCopied = false;
|
|
109
|
+
|
|
110
|
+
let backupConfig = {
|
|
111
|
+
backupEnabled: false,
|
|
112
|
+
backupCron: '0 2 * * *',
|
|
113
|
+
backupS3Endpoint: '',
|
|
114
|
+
backupS3Region: '',
|
|
115
|
+
backupS3Bucket: '',
|
|
116
|
+
backupS3AccessKey: '',
|
|
117
|
+
backupS3SecretKey: '',
|
|
118
|
+
backupS3Prefix: ''
|
|
119
|
+
};
|
|
120
|
+
let backupConfigSaving = false;
|
|
121
|
+
let backupS3SecretKeySet = false;
|
|
122
|
+
let backupTestingS3 = false;
|
|
123
|
+
let backupRunningNow = false;
|
|
124
|
+
let backupS3TestResult = null;
|
|
125
|
+
let backupS3TestMessage = '';
|
|
126
|
+
let backupLastRunAt = null;
|
|
127
|
+
let backupLastStatus = '';
|
|
128
|
+
|
|
97
129
|
let runners = [];
|
|
98
130
|
let runnerForm = { name: '', url: '', token: '', browser: 'chromium' };
|
|
99
131
|
let runnerFormError = '';
|
|
@@ -134,6 +166,27 @@
|
|
|
134
166
|
try {
|
|
135
167
|
integrations = await fetchIntegrations();
|
|
136
168
|
} catch {}
|
|
169
|
+
try {
|
|
170
|
+
const mcp = await fetchMcpConfig();
|
|
171
|
+
mcpKeySet = mcp.mcpKeySet;
|
|
172
|
+
mcpKey = mcp.mcpKey;
|
|
173
|
+
} catch {}
|
|
174
|
+
try {
|
|
175
|
+
const bc = await fetchBackupConfig();
|
|
176
|
+
backupS3SecretKeySet = bc.backupS3SecretKeySet;
|
|
177
|
+
backupLastRunAt = bc.backupLastRunAt;
|
|
178
|
+
backupLastStatus = bc.backupLastStatus;
|
|
179
|
+
backupConfig = {
|
|
180
|
+
backupEnabled: bc.backupEnabled,
|
|
181
|
+
backupCron: bc.backupCron,
|
|
182
|
+
backupS3Endpoint: bc.backupS3Endpoint,
|
|
183
|
+
backupS3Region: bc.backupS3Region,
|
|
184
|
+
backupS3Bucket: bc.backupS3Bucket,
|
|
185
|
+
backupS3AccessKey: bc.backupS3AccessKey,
|
|
186
|
+
backupS3SecretKey: '',
|
|
187
|
+
backupS3Prefix: bc.backupS3Prefix
|
|
188
|
+
};
|
|
189
|
+
} catch {}
|
|
137
190
|
if ($auth.user) {
|
|
138
191
|
profileForm = { name: $auth.user.name, email: $auth.user.email };
|
|
139
192
|
}
|
|
@@ -407,6 +460,35 @@
|
|
|
407
460
|
goto('/login');
|
|
408
461
|
}
|
|
409
462
|
|
|
463
|
+
async function handleGenerateMcpKey() {
|
|
464
|
+
mcpGenerating = true;
|
|
465
|
+
try {
|
|
466
|
+
const result = await generateMcpKeyApi();
|
|
467
|
+
mcpKey = result.mcpKey;
|
|
468
|
+
mcpKeySet = true;
|
|
469
|
+
mcpShowKey = true;
|
|
470
|
+
showToast('success', 'MCP key generated.');
|
|
471
|
+
} catch {
|
|
472
|
+
showToast('error', 'Failed to generate MCP key.');
|
|
473
|
+
} finally {
|
|
474
|
+
mcpGenerating = false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function handleCopyMcpKey() {
|
|
479
|
+
navigator.clipboard.writeText(mcpKey).then(() => {
|
|
480
|
+
mcpKeyCopied = true;
|
|
481
|
+
setTimeout(() => (mcpKeyCopied = false), 1500);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function handleCopyMcpSnippet() {
|
|
486
|
+
navigator.clipboard.writeText(mcpConfigSnippet).then(() => {
|
|
487
|
+
mcpSnippetCopied = true;
|
|
488
|
+
setTimeout(() => (mcpSnippetCopied = false), 1500);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
410
492
|
async function handleSaveIntegrations() {
|
|
411
493
|
integrationsSaving = true;
|
|
412
494
|
try {
|
|
@@ -419,11 +501,89 @@
|
|
|
419
501
|
}
|
|
420
502
|
}
|
|
421
503
|
|
|
504
|
+
async function handleSaveBackupConfig() {
|
|
505
|
+
backupConfigSaving = true;
|
|
506
|
+
try {
|
|
507
|
+
const payload = { ...backupConfig };
|
|
508
|
+
if (!payload.backupS3SecretKey) delete payload.backupS3SecretKey;
|
|
509
|
+
const result = await saveBackupConfig(payload);
|
|
510
|
+
if (result.error) throw new Error(result.error);
|
|
511
|
+
if (backupConfig.backupS3SecretKey) backupS3SecretKeySet = true;
|
|
512
|
+
backupConfig = { ...backupConfig, backupS3SecretKey: '' };
|
|
513
|
+
showToast('success', 'Backup configuration saved.');
|
|
514
|
+
} catch (e) {
|
|
515
|
+
showToast('error', e.message || 'Failed to save backup configuration.');
|
|
516
|
+
} finally {
|
|
517
|
+
backupConfigSaving = false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function handleTestS3() {
|
|
522
|
+
backupTestingS3 = true;
|
|
523
|
+
backupS3TestResult = null;
|
|
524
|
+
backupS3TestMessage = '';
|
|
525
|
+
try {
|
|
526
|
+
const result = await testBackupS3(backupConfig);
|
|
527
|
+
if (result.error) throw new Error(result.error);
|
|
528
|
+
backupS3TestResult = 'success';
|
|
529
|
+
backupS3TestMessage = 'Connection successful.';
|
|
530
|
+
} catch (e) {
|
|
531
|
+
backupS3TestResult = 'error';
|
|
532
|
+
backupS3TestMessage = e.message || 'Connection failed.';
|
|
533
|
+
} finally {
|
|
534
|
+
backupTestingS3 = false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function handleRunBackupNow() {
|
|
539
|
+
backupRunningNow = true;
|
|
540
|
+
try {
|
|
541
|
+
const result = await runBackupNow();
|
|
542
|
+
if (result.error) throw new Error(result.error);
|
|
543
|
+
backupLastRunAt = result.lastRunAt;
|
|
544
|
+
backupLastStatus = result.lastStatus ?? '';
|
|
545
|
+
showToast('success', 'Backup uploaded to S3 successfully.');
|
|
546
|
+
} catch (e) {
|
|
547
|
+
showToast('error', e.message || 'Backup failed. Check your S3 configuration.');
|
|
548
|
+
const bc = await fetchBackupConfig().catch(() => null);
|
|
549
|
+
if (bc) {
|
|
550
|
+
backupLastRunAt = bc.backupLastRunAt;
|
|
551
|
+
backupLastStatus = bc.backupLastStatus;
|
|
552
|
+
}
|
|
553
|
+
} finally {
|
|
554
|
+
backupRunningNow = false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
$: s3Configured = !!(
|
|
559
|
+
backupConfig.backupS3Bucket &&
|
|
560
|
+
backupConfig.backupS3AccessKey &&
|
|
561
|
+
backupS3SecretKeySet
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
$: mcpConfigSnippet = JSON.stringify(
|
|
565
|
+
{
|
|
566
|
+
mcpServers: {
|
|
567
|
+
plum: {
|
|
568
|
+
command: 'plum',
|
|
569
|
+
args: ['mcp'],
|
|
570
|
+
env: {
|
|
571
|
+
PLUM_API_URL: 'http://localhost:3001',
|
|
572
|
+
PLUM_API_KEY: mcpKey
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
null,
|
|
578
|
+
2
|
|
579
|
+
);
|
|
580
|
+
|
|
422
581
|
$: navItems = [
|
|
423
582
|
{ id: 'project', label: 'Project' },
|
|
424
583
|
{ id: 'runners', label: 'Runners' },
|
|
425
584
|
{ id: 'repository', label: 'Repository' },
|
|
426
585
|
{ id: 'integrations', label: 'Integrations' },
|
|
586
|
+
{ id: 'mcp', label: 'MCP' },
|
|
427
587
|
{ id: 'account', label: 'Account' },
|
|
428
588
|
...($auth.user?.role === 'admin' ? [{ id: 'users', label: 'Users' }] : []),
|
|
429
589
|
{ id: 'backup', label: 'Backup' }
|
|
@@ -901,6 +1061,137 @@
|
|
|
901
1061
|
</div>
|
|
902
1062
|
</div>
|
|
903
1063
|
|
|
1064
|
+
<!-- MCP -->
|
|
1065
|
+
{:else if section === 'mcp'}
|
|
1066
|
+
<div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
|
|
1067
|
+
<div class="content-header">
|
|
1068
|
+
<h2>MCP Integration</h2>
|
|
1069
|
+
<p class="content-desc">
|
|
1070
|
+
Generate an API key for any MCP-compatible AI client — Claude, Cursor, Windsurf, and
|
|
1071
|
+
others.
|
|
1072
|
+
</p>
|
|
1073
|
+
</div>
|
|
1074
|
+
|
|
1075
|
+
<div class="card settings-card">
|
|
1076
|
+
<p class="card-title">API Key</p>
|
|
1077
|
+
|
|
1078
|
+
{#if !mcpKeySet}
|
|
1079
|
+
<p class="content-desc">No key generated yet.</p>
|
|
1080
|
+
<div class="card-footer">
|
|
1081
|
+
<Button on:click={handleGenerateMcpKey} disabled={mcpGenerating}>
|
|
1082
|
+
{mcpGenerating ? 'Generating…' : 'Generate Key'}
|
|
1083
|
+
</Button>
|
|
1084
|
+
</div>
|
|
1085
|
+
{:else}
|
|
1086
|
+
<div class="mcp-key-row">
|
|
1087
|
+
<input
|
|
1088
|
+
type={mcpShowKey ? 'text' : 'password'}
|
|
1089
|
+
class="field-input mcp-key-input"
|
|
1090
|
+
value={mcpKey}
|
|
1091
|
+
readonly
|
|
1092
|
+
spellcheck="false"
|
|
1093
|
+
autocomplete="off"
|
|
1094
|
+
/>
|
|
1095
|
+
<button
|
|
1096
|
+
class="icon-btn"
|
|
1097
|
+
title={mcpShowKey ? 'Hide key' : 'Show key'}
|
|
1098
|
+
on:click={() => (mcpShowKey = !mcpShowKey)}
|
|
1099
|
+
>
|
|
1100
|
+
{#if mcpShowKey}
|
|
1101
|
+
<svg
|
|
1102
|
+
width="14"
|
|
1103
|
+
height="14"
|
|
1104
|
+
viewBox="0 0 24 24"
|
|
1105
|
+
fill="none"
|
|
1106
|
+
stroke="currentColor"
|
|
1107
|
+
stroke-width="2"
|
|
1108
|
+
stroke-linecap="round"
|
|
1109
|
+
stroke-linejoin="round"
|
|
1110
|
+
>
|
|
1111
|
+
<path
|
|
1112
|
+
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"
|
|
1113
|
+
/><path
|
|
1114
|
+
d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"
|
|
1115
|
+
/><line x1="1" y1="1" x2="23" y2="23" />
|
|
1116
|
+
</svg>
|
|
1117
|
+
{:else}
|
|
1118
|
+
<svg
|
|
1119
|
+
width="14"
|
|
1120
|
+
height="14"
|
|
1121
|
+
viewBox="0 0 24 24"
|
|
1122
|
+
fill="none"
|
|
1123
|
+
stroke="currentColor"
|
|
1124
|
+
stroke-width="2"
|
|
1125
|
+
stroke-linecap="round"
|
|
1126
|
+
stroke-linejoin="round"
|
|
1127
|
+
>
|
|
1128
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
|
|
1129
|
+
cx="12"
|
|
1130
|
+
cy="12"
|
|
1131
|
+
r="3"
|
|
1132
|
+
/>
|
|
1133
|
+
</svg>
|
|
1134
|
+
{/if}
|
|
1135
|
+
</button>
|
|
1136
|
+
<button class="icon-btn" title="Copy key" on:click={handleCopyMcpKey}>
|
|
1137
|
+
{#if mcpKeyCopied}
|
|
1138
|
+
<svg
|
|
1139
|
+
width="14"
|
|
1140
|
+
height="14"
|
|
1141
|
+
viewBox="0 0 24 24"
|
|
1142
|
+
fill="none"
|
|
1143
|
+
stroke="currentColor"
|
|
1144
|
+
stroke-width="2"
|
|
1145
|
+
stroke-linecap="round"
|
|
1146
|
+
stroke-linejoin="round"
|
|
1147
|
+
>
|
|
1148
|
+
<polyline points="20 6 9 17 4 12" />
|
|
1149
|
+
</svg>
|
|
1150
|
+
{:else}
|
|
1151
|
+
<svg
|
|
1152
|
+
width="14"
|
|
1153
|
+
height="14"
|
|
1154
|
+
viewBox="0 0 24 24"
|
|
1155
|
+
fill="none"
|
|
1156
|
+
stroke="currentColor"
|
|
1157
|
+
stroke-width="2"
|
|
1158
|
+
stroke-linecap="round"
|
|
1159
|
+
stroke-linejoin="round"
|
|
1160
|
+
>
|
|
1161
|
+
<rect x="9" y="9" width="13" height="13" rx="2" /><path
|
|
1162
|
+
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
|
1163
|
+
/>
|
|
1164
|
+
</svg>
|
|
1165
|
+
{/if}
|
|
1166
|
+
</button>
|
|
1167
|
+
</div>
|
|
1168
|
+
<p class="mcp-regen-note">Regenerating invalidates the existing key immediately.</p>
|
|
1169
|
+
<div class="card-footer">
|
|
1170
|
+
<Button variant="ghost" on:click={handleGenerateMcpKey} disabled={mcpGenerating}>
|
|
1171
|
+
{mcpGenerating ? 'Generating…' : 'Regenerate Key'}
|
|
1172
|
+
</Button>
|
|
1173
|
+
</div>
|
|
1174
|
+
{/if}
|
|
1175
|
+
</div>
|
|
1176
|
+
|
|
1177
|
+
{#if mcpKeySet}
|
|
1178
|
+
<div class="card settings-card">
|
|
1179
|
+
<p class="card-title">Config Snippet</p>
|
|
1180
|
+
<p class="content-desc">
|
|
1181
|
+
Add this to your MCP client's config file (e.g.
|
|
1182
|
+
<code class="code-sample">claude_desktop_config.json</code>, Cursor MCP settings,
|
|
1183
|
+
etc.).
|
|
1184
|
+
</p>
|
|
1185
|
+
<pre class="mcp-snippet">{mcpConfigSnippet}</pre>
|
|
1186
|
+
<div class="card-footer">
|
|
1187
|
+
<Button variant="ghost" on:click={handleCopyMcpSnippet}>
|
|
1188
|
+
{mcpSnippetCopied ? 'Copied!' : 'Copy Config'}
|
|
1189
|
+
</Button>
|
|
1190
|
+
</div>
|
|
1191
|
+
</div>
|
|
1192
|
+
{/if}
|
|
1193
|
+
</div>
|
|
1194
|
+
|
|
904
1195
|
<!-- ACCOUNT -->
|
|
905
1196
|
{:else if section === 'account'}
|
|
906
1197
|
<div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
|
|
@@ -1108,21 +1399,23 @@
|
|
|
1108
1399
|
<div class="content-header">
|
|
1109
1400
|
<h2>Backup</h2>
|
|
1110
1401
|
<p class="content-desc">
|
|
1111
|
-
Export
|
|
1112
|
-
|
|
1402
|
+
Export your test cases, schedules, users, and project settings. Automate uploads to any
|
|
1403
|
+
S3-compatible storage — Cloudflare R2, Backblaze B2, AWS S3, or MinIO.
|
|
1113
1404
|
</p>
|
|
1114
1405
|
</div>
|
|
1115
1406
|
|
|
1407
|
+
<!-- Manual export / import -->
|
|
1116
1408
|
<div class="card settings-card">
|
|
1409
|
+
<p class="card-title">Manual Backup</p>
|
|
1117
1410
|
<div class="backup-row">
|
|
1118
1411
|
<div class="backup-block">
|
|
1119
1412
|
<p class="backup-block-title">Export</p>
|
|
1120
1413
|
<p class="backup-block-desc">
|
|
1121
|
-
Downloads a <code>.json</code> file
|
|
1122
|
-
|
|
1414
|
+
Downloads a <code>.json</code> file with all cron jobs, test cases, test runs, users,
|
|
1415
|
+
runners, and project settings.
|
|
1123
1416
|
</p>
|
|
1124
1417
|
<Button on:click={handleExport} disabled={exporting}>
|
|
1125
|
-
{exporting ? 'Exporting…' : 'Export
|
|
1418
|
+
{exporting ? 'Exporting…' : 'Export JSON'}
|
|
1126
1419
|
</Button>
|
|
1127
1420
|
</div>
|
|
1128
1421
|
|
|
@@ -1131,8 +1424,8 @@
|
|
|
1131
1424
|
<div class="backup-block">
|
|
1132
1425
|
<p class="backup-block-title">Import</p>
|
|
1133
1426
|
<p class="backup-block-desc">
|
|
1134
|
-
Restores
|
|
1135
|
-
|
|
1427
|
+
Restores all data from a previously exported backup. Existing records are
|
|
1428
|
+
overwritten. Cron jobs are re-scheduled after import.
|
|
1136
1429
|
</p>
|
|
1137
1430
|
<div class="import-row">
|
|
1138
1431
|
<label class="file-label">
|
|
@@ -1151,6 +1444,198 @@
|
|
|
1151
1444
|
</div>
|
|
1152
1445
|
</div>
|
|
1153
1446
|
</div>
|
|
1447
|
+
<p class="backup-disclaimer">
|
|
1448
|
+
Reports are not included in backups. To back up report history, run
|
|
1449
|
+
<code>pg_dump</code> directly on the PostgreSQL volume.
|
|
1450
|
+
</p>
|
|
1451
|
+
</div>
|
|
1452
|
+
|
|
1453
|
+
<!-- S3 cloud backup -->
|
|
1454
|
+
<div class="card settings-card">
|
|
1455
|
+
<p class="card-title">S3 Storage</p>
|
|
1456
|
+
<p class="backup-block-desc" style="margin-bottom: 1.25rem;">
|
|
1457
|
+
Works with any S3-compatible provider — Cloudflare R2, Backblaze B2, AWS S3, or MinIO.
|
|
1458
|
+
Leave <strong>Endpoint URL</strong> empty for AWS S3.
|
|
1459
|
+
</p>
|
|
1460
|
+
|
|
1461
|
+
<div class="field-row">
|
|
1462
|
+
<div class="field">
|
|
1463
|
+
<label class="field-label" for="s3-endpoint">
|
|
1464
|
+
<span>Endpoint URL</span>
|
|
1465
|
+
<span class="field-hint">Leave blank for AWS S3</span>
|
|
1466
|
+
</label>
|
|
1467
|
+
<input
|
|
1468
|
+
id="s3-endpoint"
|
|
1469
|
+
type="url"
|
|
1470
|
+
class="field-input"
|
|
1471
|
+
bind:value={backupConfig.backupS3Endpoint}
|
|
1472
|
+
placeholder="https://account.r2.cloudflarestorage.com"
|
|
1473
|
+
/>
|
|
1474
|
+
</div>
|
|
1475
|
+
<div class="field">
|
|
1476
|
+
<label class="field-label" for="s3-region">
|
|
1477
|
+
<span>Region</span>
|
|
1478
|
+
<span class="field-hint">Use <code>auto</code> for Cloudflare R2</span>
|
|
1479
|
+
</label>
|
|
1480
|
+
<input
|
|
1481
|
+
id="s3-region"
|
|
1482
|
+
type="text"
|
|
1483
|
+
class="field-input"
|
|
1484
|
+
bind:value={backupConfig.backupS3Region}
|
|
1485
|
+
placeholder="us-east-1"
|
|
1486
|
+
/>
|
|
1487
|
+
</div>
|
|
1488
|
+
</div>
|
|
1489
|
+
|
|
1490
|
+
<div class="field-row">
|
|
1491
|
+
<div class="field">
|
|
1492
|
+
<label class="field-label" for="s3-bucket">
|
|
1493
|
+
<span>Bucket</span>
|
|
1494
|
+
</label>
|
|
1495
|
+
<input
|
|
1496
|
+
id="s3-bucket"
|
|
1497
|
+
type="text"
|
|
1498
|
+
class="field-input"
|
|
1499
|
+
bind:value={backupConfig.backupS3Bucket}
|
|
1500
|
+
placeholder="my-plum-backups"
|
|
1501
|
+
/>
|
|
1502
|
+
</div>
|
|
1503
|
+
<div class="field">
|
|
1504
|
+
<label class="field-label" for="s3-prefix">
|
|
1505
|
+
<span>Path Prefix</span>
|
|
1506
|
+
<span class="field-hint">Optional folder inside the bucket</span>
|
|
1507
|
+
</label>
|
|
1508
|
+
<input
|
|
1509
|
+
id="s3-prefix"
|
|
1510
|
+
type="text"
|
|
1511
|
+
class="field-input"
|
|
1512
|
+
bind:value={backupConfig.backupS3Prefix}
|
|
1513
|
+
placeholder="plum/"
|
|
1514
|
+
/>
|
|
1515
|
+
</div>
|
|
1516
|
+
</div>
|
|
1517
|
+
|
|
1518
|
+
<div class="field-row">
|
|
1519
|
+
<div class="field">
|
|
1520
|
+
<label class="field-label" for="s3-access-key">
|
|
1521
|
+
<span>Access Key ID</span>
|
|
1522
|
+
</label>
|
|
1523
|
+
<input
|
|
1524
|
+
id="s3-access-key"
|
|
1525
|
+
type="text"
|
|
1526
|
+
class="field-input"
|
|
1527
|
+
bind:value={backupConfig.backupS3AccessKey}
|
|
1528
|
+
placeholder="AKIAIOSFODNN7EXAMPLE"
|
|
1529
|
+
autocomplete="off"
|
|
1530
|
+
/>
|
|
1531
|
+
</div>
|
|
1532
|
+
<div class="field">
|
|
1533
|
+
<label class="field-label" for="s3-secret-key">
|
|
1534
|
+
<span>Secret Access Key</span>
|
|
1535
|
+
<span class="field-hint"
|
|
1536
|
+
>{backupS3SecretKeySet
|
|
1537
|
+
? 'A key is already saved — leave blank to keep it'
|
|
1538
|
+
: 'Required'}</span
|
|
1539
|
+
>
|
|
1540
|
+
</label>
|
|
1541
|
+
<input
|
|
1542
|
+
id="s3-secret-key"
|
|
1543
|
+
type="password"
|
|
1544
|
+
class="field-input"
|
|
1545
|
+
bind:value={backupConfig.backupS3SecretKey}
|
|
1546
|
+
placeholder={backupS3SecretKeySet ? '••••••••' : 'Enter secret key'}
|
|
1547
|
+
autocomplete="new-password"
|
|
1548
|
+
/>
|
|
1549
|
+
</div>
|
|
1550
|
+
</div>
|
|
1551
|
+
|
|
1552
|
+
<div class="backup-actions">
|
|
1553
|
+
<Button variant="ghost" on:click={handleTestS3} disabled={backupTestingS3}>
|
|
1554
|
+
{backupTestingS3 ? 'Testing…' : 'Test Connection'}
|
|
1555
|
+
</Button>
|
|
1556
|
+
<Button on:click={handleSaveBackupConfig} disabled={backupConfigSaving}>
|
|
1557
|
+
{backupConfigSaving ? 'Saving…' : 'Save S3 Config'}
|
|
1558
|
+
</Button>
|
|
1559
|
+
</div>
|
|
1560
|
+
|
|
1561
|
+
{#if backupS3TestResult}
|
|
1562
|
+
<p
|
|
1563
|
+
class="s3-test-result"
|
|
1564
|
+
class:s3-test-success={backupS3TestResult === 'success'}
|
|
1565
|
+
class:s3-test-error={backupS3TestResult === 'error'}
|
|
1566
|
+
>
|
|
1567
|
+
{backupS3TestResult === 'success' ? '✓' : '✗'}
|
|
1568
|
+
{backupS3TestMessage}
|
|
1569
|
+
</p>
|
|
1570
|
+
{/if}
|
|
1571
|
+
</div>
|
|
1572
|
+
|
|
1573
|
+
<!-- Scheduled backup -->
|
|
1574
|
+
<div class="card settings-card" class:card-disabled={!s3Configured}>
|
|
1575
|
+
<p class="card-title">Scheduled Backup</p>
|
|
1576
|
+
|
|
1577
|
+
{#if !s3Configured}
|
|
1578
|
+
<p class="backup-block-desc">Configure S3 storage above to enable scheduled backups.</p>
|
|
1579
|
+
{:else}
|
|
1580
|
+
<div class="field">
|
|
1581
|
+
<label class="field-label backup-toggle-label" for="backup-enabled">
|
|
1582
|
+
<span>Enable scheduled backup</span>
|
|
1583
|
+
<button
|
|
1584
|
+
id="backup-enabled"
|
|
1585
|
+
class="toggle-btn"
|
|
1586
|
+
class:active={backupConfig.backupEnabled}
|
|
1587
|
+
on:click={() => (backupConfig.backupEnabled = !backupConfig.backupEnabled)}
|
|
1588
|
+
role="switch"
|
|
1589
|
+
aria-checked={backupConfig.backupEnabled}
|
|
1590
|
+
>
|
|
1591
|
+
<span class="toggle-thumb"></span>
|
|
1592
|
+
</button>
|
|
1593
|
+
</label>
|
|
1594
|
+
</div>
|
|
1595
|
+
|
|
1596
|
+
<div class="field">
|
|
1597
|
+
<label class="field-label" for="backup-cron">
|
|
1598
|
+
<span>Cron Expression</span>
|
|
1599
|
+
<span class="field-hint">
|
|
1600
|
+
5-field cron — e.g. <code>0 2 * * *</code> = daily at 2 AM.
|
|
1601
|
+
<a href="https://crontab.guru" target="_blank" rel="noopener noreferrer"
|
|
1602
|
+
>Test at crontab.guru ↗</a
|
|
1603
|
+
>
|
|
1604
|
+
</span>
|
|
1605
|
+
</label>
|
|
1606
|
+
<input
|
|
1607
|
+
id="backup-cron"
|
|
1608
|
+
type="text"
|
|
1609
|
+
class="field-input field-input-mono"
|
|
1610
|
+
bind:value={backupConfig.backupCron}
|
|
1611
|
+
placeholder="0 2 * * *"
|
|
1612
|
+
/>
|
|
1613
|
+
</div>
|
|
1614
|
+
|
|
1615
|
+
{#if backupLastRunAt}
|
|
1616
|
+
<p class="backup-last-run">
|
|
1617
|
+
Last backup: {new Date(backupLastRunAt).toLocaleString()} —
|
|
1618
|
+
{#if backupLastStatus?.startsWith('success:')}
|
|
1619
|
+
<span class="status-success"
|
|
1620
|
+
>uploaded to {backupLastStatus.replace('success:', '')}</span
|
|
1621
|
+
>
|
|
1622
|
+
{:else if backupLastStatus?.startsWith('error:')}
|
|
1623
|
+
<span class="status-error">{backupLastStatus.replace('error:', '')}</span>
|
|
1624
|
+
{:else}
|
|
1625
|
+
<span>{backupLastStatus}</span>
|
|
1626
|
+
{/if}
|
|
1627
|
+
</p>
|
|
1628
|
+
{/if}
|
|
1629
|
+
|
|
1630
|
+
<div class="backup-actions">
|
|
1631
|
+
<Button variant="ghost" on:click={handleRunBackupNow} disabled={backupRunningNow}>
|
|
1632
|
+
{backupRunningNow ? 'Uploading…' : 'Upload to S3 Now'}
|
|
1633
|
+
</Button>
|
|
1634
|
+
<Button on:click={handleSaveBackupConfig} disabled={backupConfigSaving}>
|
|
1635
|
+
{backupConfigSaving ? 'Saving…' : 'Save Schedule'}
|
|
1636
|
+
</Button>
|
|
1637
|
+
</div>
|
|
1638
|
+
{/if}
|
|
1154
1639
|
</div>
|
|
1155
1640
|
</div>
|
|
1156
1641
|
{/if}
|
|
@@ -1481,6 +1966,11 @@
|
|
|
1481
1966
|
gap: 0.5rem;
|
|
1482
1967
|
}
|
|
1483
1968
|
|
|
1969
|
+
.card-disabled {
|
|
1970
|
+
opacity: 0.5;
|
|
1971
|
+
pointer-events: none;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1484
1974
|
/* ── Backup ── */
|
|
1485
1975
|
.backup-row {
|
|
1486
1976
|
display: flex;
|
|
@@ -1520,6 +2010,96 @@
|
|
|
1520
2010
|
border-radius: 3px;
|
|
1521
2011
|
}
|
|
1522
2012
|
|
|
2013
|
+
.backup-disclaimer {
|
|
2014
|
+
margin-top: 1rem;
|
|
2015
|
+
padding: 0.625rem 0.875rem;
|
|
2016
|
+
background: var(--bg-subtle);
|
|
2017
|
+
border: 1px solid var(--border);
|
|
2018
|
+
border-radius: var(--radius-sm);
|
|
2019
|
+
font-size: 0.8125rem;
|
|
2020
|
+
color: var(--text-muted);
|
|
2021
|
+
line-height: 1.5;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
.backup-actions {
|
|
2025
|
+
display: flex;
|
|
2026
|
+
gap: 0.625rem;
|
|
2027
|
+
margin-top: 0.25rem;
|
|
2028
|
+
flex-wrap: wrap;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
.s3-test-result {
|
|
2032
|
+
margin-top: 0.625rem;
|
|
2033
|
+
font-size: 0.8125rem;
|
|
2034
|
+
font-weight: 500;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
.s3-test-success {
|
|
2038
|
+
color: var(--pass);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
.s3-test-error {
|
|
2042
|
+
color: var(--fail);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
.backup-last-run {
|
|
2046
|
+
font-size: 0.8125rem;
|
|
2047
|
+
color: var(--text-muted);
|
|
2048
|
+
margin-top: 0.25rem;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
.status-success {
|
|
2052
|
+
color: var(--pass);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
.status-error {
|
|
2056
|
+
color: var(--fail);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
.backup-toggle-label {
|
|
2060
|
+
display: flex;
|
|
2061
|
+
align-items: center;
|
|
2062
|
+
justify-content: space-between;
|
|
2063
|
+
cursor: default;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
.toggle-btn {
|
|
2067
|
+
flex-shrink: 0;
|
|
2068
|
+
width: 40px;
|
|
2069
|
+
height: 22px;
|
|
2070
|
+
border-radius: 100px;
|
|
2071
|
+
border: none;
|
|
2072
|
+
background: var(--border);
|
|
2073
|
+
cursor: pointer;
|
|
2074
|
+
position: relative;
|
|
2075
|
+
transition: background 0.2s var(--ease-out);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
.toggle-btn.active {
|
|
2079
|
+
background: var(--accent);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
.toggle-btn .toggle-thumb {
|
|
2083
|
+
position: absolute;
|
|
2084
|
+
top: 3px;
|
|
2085
|
+
left: 3px;
|
|
2086
|
+
width: 16px;
|
|
2087
|
+
height: 16px;
|
|
2088
|
+
border-radius: 50%;
|
|
2089
|
+
background: white;
|
|
2090
|
+
transition: transform 0.2s var(--ease-out);
|
|
2091
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
.toggle-btn.active .toggle-thumb {
|
|
2095
|
+
transform: translateX(18px);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
.field-input-mono {
|
|
2099
|
+
font-family: 'JetBrains Mono', monospace;
|
|
2100
|
+
font-size: 0.8125rem;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
1523
2103
|
.import-row {
|
|
1524
2104
|
display: flex;
|
|
1525
2105
|
align-items: center;
|
|
@@ -1703,6 +2283,39 @@
|
|
|
1703
2283
|
flex-shrink: 0;
|
|
1704
2284
|
}
|
|
1705
2285
|
|
|
2286
|
+
/* ── MCP ── */
|
|
2287
|
+
.mcp-key-row {
|
|
2288
|
+
display: flex;
|
|
2289
|
+
align-items: center;
|
|
2290
|
+
gap: 0.375rem;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
.mcp-key-input {
|
|
2294
|
+
flex: 1;
|
|
2295
|
+
font-family: 'JetBrains Mono', monospace;
|
|
2296
|
+
font-size: 0.8125rem;
|
|
2297
|
+
min-width: 0;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
.mcp-regen-note {
|
|
2301
|
+
font-size: 0.78rem;
|
|
2302
|
+
color: var(--text-muted);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
.mcp-snippet {
|
|
2306
|
+
font-family: 'JetBrains Mono', monospace;
|
|
2307
|
+
font-size: 0.78rem;
|
|
2308
|
+
background: var(--bg-subtle);
|
|
2309
|
+
border: 1px solid var(--border);
|
|
2310
|
+
border-radius: var(--radius-sm);
|
|
2311
|
+
padding: 0.875rem 1rem;
|
|
2312
|
+
white-space: pre;
|
|
2313
|
+
overflow-x: auto;
|
|
2314
|
+
color: var(--text);
|
|
2315
|
+
line-height: 1.6;
|
|
2316
|
+
margin: 0;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
1706
2319
|
/* reuse icon-btn from other pages */
|
|
1707
2320
|
.icon-btn {
|
|
1708
2321
|
display: flex;
|