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.
@@ -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 all scheduled tests, report history, and project settings to a JSON file. Import
1112
- to restore after a data loss or migration.
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 containing all cron jobs, report metadata, and project
1122
- settings. Report detail files are stored on disk and not included.
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 Backup'}
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 cron jobs, report metadata, and project settings from a previously exported
1135
- backup. Existing records with the same identifier are overwritten.
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;