plum-e2e 1.2.3 → 1.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 (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +237 -90
  3. package/backend/_scaffold/utils/browser.ts +5 -2
  4. package/backend/app.js +9 -1
  5. package/backend/config/scripts/generate-report.js +34 -73
  6. package/backend/config/scripts/run-tests.js +7 -3
  7. package/backend/constants/triggers.js +67 -0
  8. package/backend/lib/reportFilename.js +37 -0
  9. package/backend/lib/testChunker.js +73 -0
  10. package/backend/middleware/auth.js +32 -0
  11. package/backend/package.json +4 -2
  12. package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
  13. package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
  14. package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
  15. package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
  16. package/backend/prisma/schema.prisma +21 -1
  17. package/backend/routes/cron.routes.js +28 -0
  18. package/backend/routes/node.routes.js +121 -0
  19. package/backend/routes/reports.routes.js +23 -20
  20. package/backend/routes/runners.routes.js +83 -0
  21. package/backend/scripts/add-local-runner.js +120 -0
  22. package/backend/scripts/create-test.js +148 -0
  23. package/backend/server.js +16 -7
  24. package/backend/services/backupService.js +3 -30
  25. package/backend/services/cronService.js +220 -36
  26. package/backend/services/reportService.js +227 -55
  27. package/backend/services/runnerService.js +179 -0
  28. package/backend/websockets/socketHandler.js +162 -21
  29. package/bin/plum.js +191 -47
  30. package/docker-compose.node.yml +59 -0
  31. package/docker-compose.yml +2 -0
  32. package/frontend/package.json +1 -4
  33. package/frontend/src/app.css +20 -254
  34. package/frontend/src/app.html +1 -1
  35. package/frontend/src/lib/api/reports.js +17 -36
  36. package/frontend/src/lib/api/runners.js +61 -0
  37. package/frontend/src/lib/api/schedules.js +34 -5
  38. package/frontend/src/lib/api/settings.js +5 -5
  39. package/frontend/src/lib/api/tests.js +2 -19
  40. package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
  41. package/frontend/src/lib/components/layout/Nav.svelte +42 -47
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
  43. package/frontend/src/lib/components/ui/Badge.svelte +6 -1
  44. package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
  45. package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
  46. package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
  47. package/frontend/src/lib/constants.js +36 -0
  48. package/frontend/src/lib/stores/runner.js +23 -12
  49. package/frontend/src/lib/styles/global.css +176 -0
  50. package/frontend/src/lib/styles/reset.css +86 -0
  51. package/frontend/src/lib/styles/tokens.css +90 -0
  52. package/frontend/src/lib/utils/format.js +46 -0
  53. package/frontend/src/routes/+page.svelte +16 -35
  54. package/frontend/src/routes/reports/+page.svelte +84 -167
  55. package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
  56. package/frontend/src/routes/reports/live/+page.svelte +704 -0
  57. package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
  58. package/frontend/src/routes/settings/+page.svelte +774 -127
  59. package/frontend/static/favicon-32x32.png +0 -0
  60. package/frontend/static/favicon.ico +0 -0
  61. package/package.json +2 -2
  62. package/frontend/static/favicon.png +0 -0
@@ -19,7 +19,22 @@
19
19
  import { onMount } from 'svelte';
20
20
  import { fly } from 'svelte/transition';
21
21
  import { fetchProject, saveProject, exportBackup, importBackup } from '$lib/api/settings';
22
+ import {
23
+ fetchRunners,
24
+ createRunner,
25
+ updateRunner,
26
+ deleteRunner,
27
+ pingRunner,
28
+ probeRunner
29
+ } from '$lib/api/runners';
30
+ import { builtInEnabled } from '$lib/stores/runner';
31
+ import { theme } from '$lib/stores/theme';
32
+ import { BROWSERS, TOAST_TIMEOUT_MS } from '$lib/constants';
22
33
  import Button from '$lib/components/ui/Button.svelte';
34
+ import Toast from '$lib/components/ui/Toast.svelte';
35
+
36
+ /** @type {'project' | 'runners' | 'backup'} */
37
+ let section = 'project';
23
38
 
24
39
  let project = { name: '', logoUrl: '' };
25
40
  let projectSaving = false;
@@ -30,17 +45,142 @@
30
45
  let exporting = false;
31
46
  let fileInput;
32
47
 
48
+ let runners = [];
49
+ let runnerForm = { name: '', url: '', token: '', browser: 'chromium' };
50
+ let runnerFormError = '';
51
+ let runnerFormSaving = false;
52
+ let runnerFormOpen = false;
53
+ let pingResults = {};
54
+ let editingId = null;
55
+ let editForm = { name: '', url: '', token: '', browser: 'chromium' };
56
+ let editFormError = '';
57
+ let editFormSaving = false;
58
+
33
59
  function showToast(type, message) {
34
60
  toast = { type, message };
35
- setTimeout(() => (toast = null), 4000);
61
+ setTimeout(() => (toast = null), TOAST_TIMEOUT_MS);
36
62
  }
37
63
 
64
+ let runnersLoaded = false;
65
+
38
66
  onMount(async () => {
67
+ try {
68
+ const bi = localStorage.getItem('plum:builtInEnabled');
69
+ if (bi !== null) builtInEnabled.set(bi !== 'false');
70
+ } catch {}
39
71
  try {
40
72
  project = await fetchProject();
41
73
  } catch {}
74
+ try {
75
+ runners = await fetchRunners();
76
+ runnersLoaded = true;
77
+ } catch {}
42
78
  });
43
79
 
80
+ $: if (section === 'runners' && runnersLoaded) pingAll();
81
+
82
+ async function pingAll() {
83
+ if (runners.length === 0) return;
84
+ pingResults = Object.fromEntries(runners.map((r) => [r.id, { loading: true }]));
85
+ await Promise.all(
86
+ runners.map(async (r) => {
87
+ try {
88
+ const result = await pingRunner(r.id);
89
+ pingResults = { ...pingResults, [r.id]: { ...result, loading: false } };
90
+ } catch {
91
+ pingResults = {
92
+ ...pingResults,
93
+ [r.id]: { ok: false, error: 'Network error', loading: false }
94
+ };
95
+ }
96
+ })
97
+ );
98
+ }
99
+
100
+ function handleBuiltInToggle() {
101
+ builtInEnabled.update((v) => {
102
+ const next = !v;
103
+ try {
104
+ localStorage.setItem('plum:builtInEnabled', String(next));
105
+ } catch {}
106
+ return next;
107
+ });
108
+ }
109
+
110
+ function toggleTheme() {
111
+ theme.update((t) => (t === 'light' ? 'dark' : 'light'));
112
+ }
113
+
114
+ async function handleAddRunner() {
115
+ if (!runnerForm.name || !runnerForm.url || !runnerForm.token) {
116
+ runnerFormError = 'Name, URL and token are required.';
117
+ return;
118
+ }
119
+ runnerFormError = '';
120
+ runnerFormSaving = true;
121
+ try {
122
+ const probe = await probeRunner(runnerForm.url, runnerForm.token);
123
+ if (!probe.ok) {
124
+ runnerFormError = `Cannot reach this runner — ${probe.error ?? 'check the URL and token'}.`;
125
+ return;
126
+ }
127
+ const { runner } = await createRunner(runnerForm);
128
+ runners = [...runners, runner];
129
+ pingResults = {
130
+ ...pingResults,
131
+ [runner.id]: { ok: true, latency: probe.latency, loading: false }
132
+ };
133
+ runnerForm = { name: '', url: '', token: '', browser: 'chromium' };
134
+ runnerFormOpen = false;
135
+ showToast('success', `Runner "${runner.name}" added.`);
136
+ } catch {
137
+ runnerFormError = 'Failed to add runner.';
138
+ } finally {
139
+ runnerFormSaving = false;
140
+ }
141
+ }
142
+
143
+ async function handleDeleteRunner(id, name) {
144
+ try {
145
+ await deleteRunner(id);
146
+ runners = runners.filter((r) => r.id !== id);
147
+ showToast('success', `Runner "${name}" removed.`);
148
+ } catch {
149
+ showToast('error', 'Failed to remove runner.');
150
+ }
151
+ }
152
+
153
+ function startEdit(r) {
154
+ editingId = r.id;
155
+ editForm = { name: r.name, url: r.url, token: r.token, browser: r.browser };
156
+ editFormError = '';
157
+ }
158
+
159
+ async function handleUpdateRunner(id) {
160
+ if (!editForm.name || !editForm.url || !editForm.token) {
161
+ editFormError = 'Name, URL and token are required.';
162
+ return;
163
+ }
164
+ editFormError = '';
165
+ editFormSaving = true;
166
+ try {
167
+ const probe = await probeRunner(editForm.url, editForm.token);
168
+ if (!probe.ok) {
169
+ editFormError = `Cannot reach this runner — ${probe.error ?? 'check the URL and token'}.`;
170
+ return;
171
+ }
172
+ const { runner } = await updateRunner(id, editForm);
173
+ runners = runners.map((r) => (r.id === id ? runner : r));
174
+ pingResults = { ...pingResults, [id]: { ok: true, latency: probe.latency, loading: false } };
175
+ editingId = null;
176
+ showToast('success', `Runner "${runner.name}" updated.`);
177
+ } catch {
178
+ editFormError = 'Failed to update runner.';
179
+ } finally {
180
+ editFormSaving = false;
181
+ }
182
+ }
183
+
44
184
  async function handleSaveProject() {
45
185
  projectSaving = true;
46
186
  try {
@@ -81,7 +221,6 @@
81
221
  const result = await importBackup(data);
82
222
  if (result.error) throw new Error(result.error);
83
223
  showToast('success', 'Import successful. Cron jobs have been re-scheduled.');
84
- // Refresh project settings in case they were part of the backup
85
224
  project = await fetchProject();
86
225
  importFile = null;
87
226
  if (fileInput) fileInput.value = '';
@@ -95,124 +234,389 @@
95
234
  function handleFileChange(e) {
96
235
  importFile = e.target.files[0] ?? null;
97
236
  }
237
+
238
+ const navItems = [
239
+ { id: 'project', label: 'Project' },
240
+ { id: 'runners', label: 'Runners' },
241
+ { id: 'backup', label: 'Backup' }
242
+ ];
98
243
  </script>
99
244
 
100
245
  <svelte:head><title>Settings — Plum</title></svelte:head>
101
246
 
102
- {#if toast}
103
- <div class="toast alert alert-{toast.type}" transition:fly={{ y: -8, duration: 240 }}>
104
- {toast.message}
105
- </div>
106
- {/if}
247
+ <Toast {toast} />
107
248
 
108
249
  <div class="page-header">
109
250
  <h1>Settings</h1>
110
- <p class="subtitle">Configure your project and manage data backups</p>
111
251
  </div>
112
252
 
113
- <!-- Project section -->
114
- <section class="settings-section">
115
- <div class="section-header">
116
- <h2 class="section-title">Project</h2>
117
- <p class="section-desc">Identity information shown across the UI</p>
118
- </div>
253
+ <div class="settings-layout">
254
+ <!-- Left sidebar -->
255
+ <aside class="settings-sidebar">
256
+ <nav>
257
+ {#each navItems as item}
258
+ <button
259
+ class="sidebar-item"
260
+ class:active={section === item.id}
261
+ on:click={() => (section = item.id)}
262
+ >
263
+ {item.label}
264
+ </button>
265
+ {/each}
266
+ </nav>
267
+ </aside>
119
268
 
120
- <div class="card settings-card">
121
- <div class="field">
122
- <label class="field-label" for="project-name">Project Name</label>
123
- <input
124
- id="project-name"
125
- type="text"
126
- class="field-input"
127
- bind:value={project.name}
128
- placeholder="My Test Suite"
129
- />
130
- </div>
131
-
132
- <div class="field">
133
- <label class="field-label" for="project-logo">
134
- <span>Logo URL</span>
135
- <span class="field-hint">Direct link to an image (PNG, SVG, JPG)</span>
136
- </label>
137
- <input
138
- id="project-logo"
139
- type="url"
140
- class="field-input"
141
- bind:value={project.logoUrl}
142
- placeholder="https://example.com/logo.png"
143
- />
144
- </div>
145
-
146
- {#if project.logoUrl}
147
- <div class="logo-preview">
148
- <span class="preview-label">Preview</span>
149
- <img
150
- src={project.logoUrl}
151
- alt="Project logo preview"
152
- class="logo-img"
153
- on:error={(e) => (e.target.style.display = 'none')}
154
- />
269
+ <!-- Right content -->
270
+ <div class="settings-content">
271
+ <!-- PROJECT -->
272
+ {#if section === 'project'}
273
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
274
+ <div class="content-header">
275
+ <h2>Project</h2>
276
+ <p class="content-desc">Identity information shown across the UI</p>
277
+ </div>
278
+
279
+ <div class="card settings-card">
280
+ <div class="field">
281
+ <label class="field-label" for="project-name">Project Name</label>
282
+ <input
283
+ id="project-name"
284
+ type="text"
285
+ class="field-input"
286
+ bind:value={project.name}
287
+ placeholder="My Test Suite"
288
+ />
289
+ </div>
290
+
291
+ <div class="field">
292
+ <label class="field-label" for="project-logo">
293
+ <span>Logo URL</span>
294
+ <span class="field-hint">Direct link to an image (PNG, SVG, JPG)</span>
295
+ </label>
296
+ <input
297
+ id="project-logo"
298
+ type="url"
299
+ class="field-input"
300
+ bind:value={project.logoUrl}
301
+ placeholder="https://example.com/logo.png"
302
+ />
303
+ </div>
304
+
305
+ {#if project.logoUrl}
306
+ <div class="logo-preview">
307
+ <span class="preview-label">Preview</span>
308
+ <img
309
+ src={project.logoUrl}
310
+ alt="Project logo preview"
311
+ class="logo-img"
312
+ on:error={(e) => (e.target.style.display = 'none')}
313
+ />
314
+ </div>
315
+ {/if}
316
+
317
+ <!-- Dark mode toggle -->
318
+ <div class="toggle-row">
319
+ <div class="toggle-info">
320
+ <span class="toggle-label">Dark mode</span>
321
+ <span class="toggle-desc">Switch between light and dark appearance</span>
322
+ </div>
323
+ <button
324
+ class="toggle-switch"
325
+ class:on={$theme === 'dark'}
326
+ role="switch"
327
+ aria-checked={$theme === 'dark'}
328
+ on:click={toggleTheme}
329
+ >
330
+ <span class="toggle-thumb"></span>
331
+ </button>
332
+ </div>
333
+
334
+ <div class="card-footer">
335
+ <Button on:click={handleSaveProject} disabled={projectSaving}>
336
+ {projectSaving ? 'Saving…' : 'Save Project'}
337
+ </Button>
338
+ </div>
339
+ </div>
155
340
  </div>
156
- {/if}
157
341
 
158
- <div class="card-footer">
159
- <Button on:click={handleSaveProject} disabled={projectSaving}>
160
- {projectSaving ? 'Saving…' : 'Save Project'}
161
- </Button>
162
- </div>
163
- </div>
164
- </section>
165
-
166
- <!-- Backup section -->
167
- <section class="settings-section">
168
- <div class="section-header">
169
- <h2 class="section-title">Backup</h2>
170
- <p class="section-desc">
171
- Export all scheduled tests, report history, and project settings to a JSON file. Import to
172
- restore after a data loss or migration.
173
- </p>
174
- </div>
342
+ <!-- RUNNERS -->
343
+ {:else if section === 'runners'}
344
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
345
+ <div class="content-header">
346
+ <h2>Runners</h2>
347
+ <p class="content-desc">
348
+ Register self-hosted runner nodes to distribute tests across machines.
349
+ </p>
350
+ </div>
351
+
352
+ <div class="card settings-card">
353
+ <!-- Built-in runner toggle -->
354
+ <div class="toggle-row">
355
+ <div class="toggle-info">
356
+ <span class="toggle-label">Built-in runner</span>
357
+ <span class="toggle-desc">
358
+ Use this server to run tests locally. Disable to route all runs to external nodes.
359
+ </span>
360
+ </div>
361
+ <button
362
+ class="toggle-switch"
363
+ class:on={$builtInEnabled}
364
+ role="switch"
365
+ aria-checked={$builtInEnabled}
366
+ on:click={handleBuiltInToggle}
367
+ >
368
+ <span class="toggle-thumb"></span>
369
+ </button>
370
+ </div>
175
371
 
176
- <div class="card settings-card">
177
- <div class="backup-row">
178
- <div class="backup-block">
179
- <p class="backup-block-title">Export</p>
180
- <p class="backup-block-desc">
181
- Downloads a <code>.json</code> file containing all cron jobs, report metadata, and project
182
- settings. Report detail files (the actual test output) are stored on disk and not included.
183
- </p>
184
- <Button on:click={handleExport} disabled={exporting}>
185
- {exporting ? 'Exporting…' : 'Export Backup'}
186
- </Button>
372
+ <!-- External runner cards -->
373
+ {#if runners.length > 0}
374
+ <div class="runner-cards">
375
+ {#each runners as r (r.id)}
376
+ {@const ping = pingResults[r.id]}
377
+ {#if editingId === r.id}
378
+ <div class="runner-card editing" transition:fly={{ y: -4, duration: 180 }}>
379
+ <div class="runner-form-fields">
380
+ <div class="field">
381
+ <label class="field-label" for="edit-name-{r.id}">Name</label>
382
+ <input
383
+ id="edit-name-{r.id}"
384
+ type="text"
385
+ class="field-input"
386
+ bind:value={editForm.name}
387
+ placeholder="staging-node"
388
+ />
389
+ </div>
390
+ <div class="field">
391
+ <label class="field-label" for="edit-url-{r.id}">
392
+ URL
393
+ <span class="field-hint"
394
+ >Use <code>host.docker.internal</code> for local nodes</span
395
+ >
396
+ </label>
397
+ <input
398
+ id="edit-url-{r.id}"
399
+ type="url"
400
+ class="field-input"
401
+ bind:value={editForm.url}
402
+ placeholder="http://host.docker.internal:3002"
403
+ />
404
+ </div>
405
+ <div class="field">
406
+ <label class="field-label" for="edit-token-{r.id}">Token</label>
407
+ <input
408
+ id="edit-token-{r.id}"
409
+ type="text"
410
+ class="field-input"
411
+ bind:value={editForm.token}
412
+ placeholder="secret-token"
413
+ spellcheck="false"
414
+ autocomplete="off"
415
+ />
416
+ </div>
417
+ <div class="field">
418
+ <label class="field-label" for="edit-browser-{r.id}">Browser</label>
419
+ <select
420
+ id="edit-browser-{r.id}"
421
+ class="field-input"
422
+ bind:value={editForm.browser}
423
+ >
424
+ {#each BROWSERS as b}
425
+ <option value={b.id}>{b.label}</option>
426
+ {/each}
427
+ </select>
428
+ </div>
429
+ </div>
430
+ {#if editFormError}
431
+ <p class="form-error">{editFormError}</p>
432
+ {/if}
433
+ <div class="runner-form-actions">
434
+ <Button on:click={() => handleUpdateRunner(r.id)} disabled={editFormSaving}>
435
+ {editFormSaving ? 'Checking…' : 'Save'}
436
+ </Button>
437
+ <Button
438
+ variant="ghost"
439
+ on:click={() => {
440
+ editingId = null;
441
+ editFormError = '';
442
+ }}
443
+ disabled={editFormSaving}>Cancel</Button
444
+ >
445
+ </div>
446
+ </div>
447
+ {:else}
448
+ <div class="runner-card" transition:fly={{ y: -4, duration: 180 }}>
449
+ <div class="runner-card-header">
450
+ <svg
451
+ class="runner-card-icon"
452
+ width="13"
453
+ height="13"
454
+ viewBox="0 0 24 24"
455
+ fill="none"
456
+ stroke="currentColor"
457
+ stroke-width="2"
458
+ stroke-linecap="round"
459
+ >
460
+ <rect x="2" y="3" width="20" height="14" rx="2" />
461
+ <path d="M8 21h8M12 17v4" />
462
+ </svg>
463
+ <span class="runner-card-name">{r.name}</span>
464
+ <span class="runner-browser-pill">{r.browser}</span>
465
+ {#if ping && !ping.loading}
466
+ {#if ping.ok}
467
+ <span class="ping-badge ok">{ping.latency}ms</span>
468
+ {:else}
469
+ <span class="ping-badge fail" title={ping.error}>unreachable</span>
470
+ {/if}
471
+ {:else if ping?.loading}
472
+ <span class="ping-badge pinging">pinging…</span>
473
+ {/if}
474
+ </div>
475
+ <p class="runner-card-url">{r.url}</p>
476
+ <div class="runner-card-actions">
477
+ <Button variant="ghost" size="sm" on:click={() => startEdit(r)}>Edit</Button>
478
+ <Button
479
+ variant="danger"
480
+ size="sm"
481
+ on:click={() => handleDeleteRunner(r.id, r.name)}>Remove</Button
482
+ >
483
+ </div>
484
+ </div>
485
+ {/if}
486
+ {/each}
487
+ </div>
488
+ {/if}
489
+
490
+ <!-- Add runner form / button -->
491
+ {#if runnerFormOpen}
492
+ <div class="runner-form" transition:fly={{ y: -6, duration: 200 }}>
493
+ <p class="runner-form-title">Add runner</p>
494
+ <div class="runner-form-fields">
495
+ <div class="field">
496
+ <label class="field-label" for="rn-name">Name</label>
497
+ <input
498
+ id="rn-name"
499
+ type="text"
500
+ class="field-input"
501
+ bind:value={runnerForm.name}
502
+ placeholder="staging-node"
503
+ />
504
+ </div>
505
+ <div class="field">
506
+ <label class="field-label" for="rn-url">
507
+ URL
508
+ <span class="field-hint"
509
+ >Use <code>host.docker.internal</code> for local nodes</span
510
+ >
511
+ </label>
512
+ <input
513
+ id="rn-url"
514
+ type="url"
515
+ class="field-input"
516
+ bind:value={runnerForm.url}
517
+ placeholder="http://host.docker.internal:3002"
518
+ />
519
+ </div>
520
+ <div class="field">
521
+ <label class="field-label" for="rn-token">Token</label>
522
+ <input
523
+ id="rn-token"
524
+ type="text"
525
+ class="field-input"
526
+ bind:value={runnerForm.token}
527
+ placeholder="secret-token"
528
+ spellcheck="false"
529
+ autocomplete="off"
530
+ />
531
+ </div>
532
+ <div class="field">
533
+ <label class="field-label" for="rn-browser">Browser</label>
534
+ <select id="rn-browser" class="field-input" bind:value={runnerForm.browser}>
535
+ {#each BROWSERS as b}
536
+ <option value={b.id}>{b.label}</option>
537
+ {/each}
538
+ </select>
539
+ </div>
540
+ </div>
541
+ {#if runnerFormError}
542
+ <p class="form-error">{runnerFormError}</p>
543
+ {/if}
544
+ <div class="runner-form-actions">
545
+ <Button on:click={handleAddRunner} disabled={runnerFormSaving}>
546
+ {runnerFormSaving ? 'Checking…' : 'Add Runner'}
547
+ </Button>
548
+ <Button
549
+ variant="ghost"
550
+ on:click={() => {
551
+ runnerFormOpen = false;
552
+ runnerFormError = '';
553
+ }}
554
+ disabled={runnerFormSaving}>Cancel</Button
555
+ >
556
+ </div>
557
+ </div>
558
+ {:else}
559
+ <div class="card-footer">
560
+ <Button variant="ghost" on:click={() => (runnerFormOpen = true)}>+ Add Runner</Button>
561
+ </div>
562
+ {/if}
563
+ </div>
187
564
  </div>
188
565
 
189
- <div class="backup-divider"></div>
566
+ <!-- BACKUP -->
567
+ {:else if section === 'backup'}
568
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
569
+ <div class="content-header">
570
+ <h2>Backup</h2>
571
+ <p class="content-desc">
572
+ Export all scheduled tests, report history, and project settings to a JSON file. Import
573
+ to restore after a data loss or migration.
574
+ </p>
575
+ </div>
190
576
 
191
- <div class="backup-block">
192
- <p class="backup-block-title">Import</p>
193
- <p class="backup-block-desc">
194
- Restores cron jobs, report metadata, and project settings from a previously exported
195
- backup. Existing records with the same identifier are overwritten.
196
- </p>
197
- <div class="import-row">
198
- <label class="file-label">
199
- <input
200
- bind:this={fileInput}
201
- type="file"
202
- accept=".json"
203
- class="file-input-hidden"
204
- on:change={handleFileChange}
205
- />
206
- <span class="file-btn">{importFile ? importFile.name : 'Choose file…'}</span>
207
- </label>
208
- <Button on:click={handleImport} disabled={!importFile || importing}>
209
- {importing ? 'Importing…' : 'Import'}
210
- </Button>
577
+ <div class="card settings-card">
578
+ <div class="backup-row">
579
+ <div class="backup-block">
580
+ <p class="backup-block-title">Export</p>
581
+ <p class="backup-block-desc">
582
+ Downloads a <code>.json</code> file containing all cron jobs, report metadata, and project
583
+ settings. Report detail files are stored on disk and not included.
584
+ </p>
585
+ <Button on:click={handleExport} disabled={exporting}>
586
+ {exporting ? 'Exporting…' : 'Export Backup'}
587
+ </Button>
588
+ </div>
589
+
590
+ <div class="backup-divider"></div>
591
+
592
+ <div class="backup-block">
593
+ <p class="backup-block-title">Import</p>
594
+ <p class="backup-block-desc">
595
+ Restores cron jobs, report metadata, and project settings from a previously exported
596
+ backup. Existing records with the same identifier are overwritten.
597
+ </p>
598
+ <div class="import-row">
599
+ <label class="file-label">
600
+ <input
601
+ bind:this={fileInput}
602
+ type="file"
603
+ accept=".json"
604
+ class="file-input-hidden"
605
+ on:change={handleFileChange}
606
+ />
607
+ <span class="file-btn">{importFile ? importFile.name : 'Choose file…'}</span>
608
+ </label>
609
+ <Button on:click={handleImport} disabled={!importFile || importing}>
610
+ {importing ? 'Importing…' : 'Import'}
611
+ </Button>
612
+ </div>
613
+ </div>
614
+ </div>
211
615
  </div>
212
616
  </div>
213
- </div>
617
+ {/if}
214
618
  </div>
215
- </section>
619
+ </div>
216
620
 
217
621
  <style>
218
622
  .page-header {
@@ -223,54 +627,92 @@
223
627
 
224
628
  .page-header h1 {
225
629
  font-size: 2.5rem;
226
- margin-bottom: 0.375rem;
227
630
  }
228
631
 
229
- .subtitle {
632
+ /* ── GitHub-style layout ── */
633
+ .settings-layout {
634
+ display: grid;
635
+ grid-template-columns: 200px 1fr;
636
+ gap: 3rem;
637
+ align-items: start;
638
+ }
639
+
640
+ .settings-sidebar {
641
+ position: sticky;
642
+ top: 72px;
643
+ }
644
+
645
+ .settings-sidebar nav {
646
+ display: flex;
647
+ flex-direction: column;
648
+ gap: 0.125rem;
649
+ }
650
+
651
+ .sidebar-item {
652
+ display: block;
653
+ width: 100%;
654
+ text-align: left;
655
+ padding: 0.45rem 0.75rem;
656
+ font-family: var(--font-body);
657
+ font-size: 0.875rem;
658
+ font-weight: 400;
230
659
  color: var(--text-muted);
231
- font-size: 0.9375rem;
660
+ background: transparent;
661
+ border: none;
662
+ border-radius: var(--radius-sm);
663
+ cursor: pointer;
664
+ transition:
665
+ background var(--duration-fast),
666
+ color var(--duration-fast);
667
+ }
668
+
669
+ .sidebar-item:hover {
670
+ background: var(--bg-subtle);
671
+ color: var(--text);
232
672
  }
233
673
 
234
- .toast {
235
- margin-bottom: 1.25rem;
236
- border-radius: var(--radius-md);
674
+ .sidebar-item.active {
675
+ background: var(--accent-soft);
676
+ color: var(--accent);
677
+ font-weight: 500;
237
678
  }
238
679
 
239
- /* Sections */
240
- .settings-section {
241
- margin-bottom: 2.5rem;
680
+ /* ── Content area ── */
681
+ .settings-content {
682
+ min-width: 0;
242
683
  }
243
684
 
244
- .section-header {
245
- margin-bottom: 1rem;
685
+ .content-section {
686
+ display: flex;
687
+ flex-direction: column;
688
+ gap: 1.25rem;
689
+ }
690
+
691
+ .content-header {
692
+ margin-bottom: 0.25rem;
246
693
  }
247
694
 
248
- .section-title {
249
- font-size: 1rem;
695
+ .content-header h2 {
696
+ font-size: 1.1rem;
250
697
  font-weight: 500;
698
+ font-family: var(--font-body);
251
699
  color: var(--text);
252
700
  margin-bottom: 0.25rem;
253
701
  }
254
702
 
255
- .section-desc {
703
+ .content-desc {
256
704
  font-size: 0.875rem;
257
705
  color: var(--text-muted);
258
706
  line-height: 1.5;
259
707
  }
260
708
 
709
+ /* ── Card ── */
261
710
  .settings-card {
262
711
  display: flex;
263
712
  flex-direction: column;
264
713
  gap: 1.25rem;
265
714
  }
266
715
 
267
- /* Fields */
268
- .field {
269
- display: flex;
270
- flex-direction: column;
271
- gap: 0.375rem;
272
- }
273
-
274
716
  .field-label {
275
717
  display: flex;
276
718
  align-items: baseline;
@@ -286,7 +728,6 @@
286
728
  color: var(--text-muted);
287
729
  }
288
730
 
289
- /* Logo preview */
290
731
  .logo-preview {
291
732
  display: flex;
292
733
  flex-direction: column;
@@ -309,11 +750,199 @@
309
750
  }
310
751
 
311
752
  .card-footer {
312
- padding-top: 0.25rem;
313
- border-top: 1px solid var(--border);
753
+ padding-top: 0.125rem;
314
754
  }
315
755
 
316
- /* Backup layout */
756
+ /* ── Toggle switch ── */
757
+ .toggle-row {
758
+ display: flex;
759
+ align-items: center;
760
+ justify-content: space-between;
761
+ gap: 1.5rem;
762
+ padding: 0.875rem 1rem;
763
+ background: var(--bg-subtle);
764
+ border: 1px solid var(--border);
765
+ border-radius: var(--radius-sm);
766
+ }
767
+
768
+ .toggle-info {
769
+ display: flex;
770
+ flex-direction: column;
771
+ gap: 0.2rem;
772
+ }
773
+
774
+ .toggle-label {
775
+ font-size: 0.875rem;
776
+ font-weight: 500;
777
+ color: var(--text);
778
+ }
779
+
780
+ .toggle-desc {
781
+ font-size: 0.78rem;
782
+ color: var(--text-muted);
783
+ line-height: 1.4;
784
+ }
785
+
786
+ .toggle-switch {
787
+ flex-shrink: 0;
788
+ width: 40px;
789
+ height: 22px;
790
+ border-radius: 100px;
791
+ border: none;
792
+ background: var(--border);
793
+ cursor: pointer;
794
+ position: relative;
795
+ transition: background 0.2s var(--ease-out);
796
+ }
797
+
798
+ .toggle-switch.on {
799
+ background: var(--accent);
800
+ }
801
+
802
+ .toggle-thumb {
803
+ position: absolute;
804
+ top: 3px;
805
+ left: 3px;
806
+ width: 16px;
807
+ height: 16px;
808
+ border-radius: 50%;
809
+ background: white;
810
+ transition: transform 0.2s var(--ease-out);
811
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
812
+ }
813
+
814
+ .toggle-switch.on .toggle-thumb {
815
+ transform: translateX(18px);
816
+ }
817
+
818
+ /* ── Runner cards ── */
819
+ .runner-cards {
820
+ display: flex;
821
+ flex-direction: column;
822
+ gap: 0.625rem;
823
+ }
824
+
825
+ .runner-card {
826
+ border: 1px solid var(--border);
827
+ border-radius: var(--radius-sm);
828
+ padding: 0.75rem 1rem;
829
+ display: flex;
830
+ flex-direction: column;
831
+ gap: 0.375rem;
832
+ transition: border-color var(--duration-fast);
833
+ }
834
+
835
+ .runner-card:hover {
836
+ border-color: color-mix(in srgb, var(--text-muted) 40%, var(--border));
837
+ }
838
+
839
+ .runner-card.editing {
840
+ border-color: var(--accent);
841
+ background: var(--accent-soft);
842
+ gap: 0.875rem;
843
+ padding: 1rem;
844
+ }
845
+
846
+ .runner-card-header {
847
+ display: flex;
848
+ align-items: center;
849
+ gap: 0.5rem;
850
+ }
851
+
852
+ .runner-card-icon {
853
+ color: var(--node);
854
+ flex-shrink: 0;
855
+ }
856
+
857
+ .runner-card-name {
858
+ font-size: 0.875rem;
859
+ font-weight: 500;
860
+ color: var(--text);
861
+ flex: 1;
862
+ }
863
+
864
+ .runner-browser-pill {
865
+ font-size: 0.65rem;
866
+ font-family: 'JetBrains Mono', monospace;
867
+ color: var(--text-muted);
868
+ background: var(--bg-subtle);
869
+ border: 1px solid var(--border);
870
+ border-radius: 100px;
871
+ padding: 0.1rem 0.45rem;
872
+ flex-shrink: 0;
873
+ }
874
+
875
+ .runner-card-url {
876
+ font-size: 0.75rem;
877
+ font-family: 'JetBrains Mono', monospace;
878
+ color: var(--text-muted);
879
+ white-space: nowrap;
880
+ overflow: hidden;
881
+ text-overflow: ellipsis;
882
+ padding-left: calc(13px + 0.5rem);
883
+ }
884
+
885
+ .runner-card-actions {
886
+ display: flex;
887
+ align-items: center;
888
+ gap: 0.375rem;
889
+ padding-left: calc(13px + 0.5rem);
890
+ margin-top: 0.125rem;
891
+ }
892
+
893
+ .ping-badge {
894
+ font-size: 0.7rem;
895
+ font-weight: 500;
896
+ padding: 0.15rem 0.5rem;
897
+ border-radius: 100px;
898
+ flex-shrink: 0;
899
+ }
900
+
901
+ .ping-badge.ok {
902
+ background: var(--pass-soft);
903
+ color: var(--pass);
904
+ }
905
+ .ping-badge.fail {
906
+ background: var(--fail-soft);
907
+ color: var(--fail);
908
+ }
909
+ .ping-badge.pinging {
910
+ color: var(--text-muted);
911
+ }
912
+
913
+ .form-error {
914
+ font-size: 0.8125rem;
915
+ color: var(--fail);
916
+ }
917
+
918
+ .runner-form {
919
+ border: 1px solid var(--border);
920
+ border-radius: var(--radius-sm);
921
+ padding: 1rem;
922
+ display: flex;
923
+ flex-direction: column;
924
+ gap: 0.875rem;
925
+ background: var(--bg-subtle);
926
+ }
927
+
928
+ .runner-form-title {
929
+ font-size: 0.8125rem;
930
+ font-weight: 500;
931
+ color: var(--text);
932
+ }
933
+
934
+ .runner-form-fields {
935
+ display: grid;
936
+ grid-template-columns: 1fr 1fr;
937
+ gap: 0.75rem;
938
+ }
939
+
940
+ .runner-form-actions {
941
+ display: flex;
942
+ gap: 0.5rem;
943
+ }
944
+
945
+ /* ── Backup ── */
317
946
  .backup-row {
318
947
  display: flex;
319
948
  gap: 2rem;
@@ -352,7 +981,6 @@
352
981
  border-radius: 3px;
353
982
  }
354
983
 
355
- /* File input */
356
984
  .import-row {
357
985
  display: flex;
358
986
  align-items: center;
@@ -397,7 +1025,22 @@
397
1025
  background: var(--bg-subtle);
398
1026
  }
399
1027
 
1028
+ /* ── Responsive ── */
400
1029
  @media (max-width: 640px) {
1030
+ .settings-layout {
1031
+ grid-template-columns: 1fr;
1032
+ gap: 1.5rem;
1033
+ }
1034
+
1035
+ .settings-sidebar {
1036
+ position: static;
1037
+ }
1038
+
1039
+ .settings-sidebar nav {
1040
+ flex-direction: row;
1041
+ flex-wrap: wrap;
1042
+ }
1043
+
401
1044
  .backup-row {
402
1045
  flex-direction: column;
403
1046
  }
@@ -406,5 +1049,9 @@
406
1049
  width: 100%;
407
1050
  height: 1px;
408
1051
  }
1052
+
1053
+ .runner-form-fields {
1054
+ grid-template-columns: 1fr;
1055
+ }
409
1056
  }
410
1057
  </style>