plum-e2e 2.4.13 → 2.5.1

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 (55) hide show
  1. package/CLAUDE.md +1 -1
  2. package/backend/_scaffold/utils/browser.ts +47 -2
  3. package/backend/_scaffold/utils/hooks.ts +25 -2
  4. package/backend/config/scripts/run-tests.js +35 -13
  5. package/backend/logs/runner-cmr8o2x3n0000mg01410tpuck.log +20 -0
  6. package/backend/logs/runner-cmr90hvcx0000n2016edtmrtv.log +43 -0
  7. package/backend/logs/runner-cmr90i7h20001n201g2wg6v3n.log +43 -0
  8. package/backend/logs/runner-cmr90iilm0002n201mmisva95.log +43 -0
  9. package/backend/logs/runner-cmr90j70r0003n201xz8525dz.log +20 -0
  10. package/backend/logs/runner-cmr90yup70000qp017ianc2gp.log +43 -0
  11. package/backend/logs/runner-cmr90zbtm0001qp01u9tfdkh1.log +20 -0
  12. package/backend/logs/runner-cmr91a3mw0000tb01414rd1df.log +20 -0
  13. package/backend/logs/runner-cmr91dhla0001tb01jp7thjnl.log +20 -0
  14. package/backend/prisma/migrations/20260706000000_add_report_logs/migration.sql +2 -0
  15. package/backend/prisma/migrations/20260707032554_add_perf_indexes/migration.sql +44 -0
  16. package/backend/prisma/schema.prisma +20 -0
  17. package/backend/routes/node.routes.js +42 -4
  18. package/backend/routes/reports.routes.js +4 -2
  19. package/backend/services/reportService.js +58 -20
  20. package/backend/services/runnerService.js +11 -1
  21. package/backend/websockets/socketHandler.js +162 -16
  22. package/bin/plum.js +96 -68
  23. package/frontend/package.json +1 -1
  24. package/frontend/src/lib/api/reports.js +16 -5
  25. package/frontend/src/lib/components/layout/RunnerPanel.svelte +53 -55
  26. package/frontend/src/lib/components/ui/AutomatedBadge.svelte +56 -0
  27. package/frontend/src/lib/components/ui/BackLink.svelte +77 -0
  28. package/frontend/src/lib/components/ui/Badge.svelte +1 -1
  29. package/frontend/src/lib/components/ui/Button.svelte +1 -1
  30. package/frontend/src/lib/components/ui/CaseIdChip.svelte +59 -0
  31. package/frontend/src/lib/components/ui/ConfirmModal.svelte +1 -1
  32. package/frontend/src/lib/components/ui/Pagination.svelte +1 -1
  33. package/frontend/src/lib/components/ui/PriorityBadge.svelte +83 -0
  34. package/frontend/src/lib/components/ui/ResultChip.svelte +92 -0
  35. package/frontend/src/lib/components/ui/StatusDot.svelte +64 -0
  36. package/frontend/src/lib/components/ui/StepKeyword.svelte +79 -0
  37. package/frontend/src/lib/components/ui/StepStatusIcon.svelte +66 -0
  38. package/frontend/src/lib/components/ui/TagChip.svelte +51 -0
  39. package/frontend/src/lib/constants.js +14 -0
  40. package/frontend/src/lib/stores/runner.js +5 -3
  41. package/frontend/src/lib/styles/tokens.css +26 -0
  42. package/frontend/src/lib/utils/format.js +74 -0
  43. package/frontend/src/routes/+page.svelte +3 -3
  44. package/frontend/src/routes/login/+page.svelte +55 -40
  45. package/frontend/src/routes/reports/+page.svelte +35 -27
  46. package/frontend/src/routes/reports/[id]/+page.svelte +703 -215
  47. package/frontend/src/routes/reports/live/+page.svelte +281 -283
  48. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -3
  49. package/frontend/src/routes/settings/+page.svelte +6 -6
  50. package/frontend/src/routes/setup/+page.svelte +2 -1
  51. package/frontend/src/routes/test-repository/+page.svelte +5 -5
  52. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +33 -152
  53. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +4 -4
  54. package/frontend/vite.config.js +7 -1
  55. package/package.json +1 -1
package/bin/plum.js CHANGED
@@ -247,20 +247,20 @@ function applyServerConfig(cfg) {
247
247
  clack.log.success('docker-compose.override.yml written');
248
248
  }
249
249
 
250
- async function serverStart() {
251
- clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum Server ')));
252
- const cfg = await configureServer({ force: false });
253
- applyServerConfig(cfg);
254
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
255
-
256
- execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
257
-
258
- const apiBase = `http://localhost:${cfg.backendPort}`;
250
+ // Node's fetch resolves "localhost" to ::1 first on many Linux distros (Debian
251
+ // included). If Docker only published the port on IPv4, that first attempt hangs
252
+ // until it times out on every poll, eating the whole budget even though the port
253
+ // is reachable — and reachable fine from a browser, which races both families.
254
+ // 127.0.0.1 sidesteps the DNS/happy-eyeballs mismatch entirely.
255
+ const READY_POLL_INTERVAL_MS = 2000;
256
+ const READY_POLL_MAX_ATTEMPTS = 90; // ~3 minutes
257
+
258
+ async function waitForServerReady(apiBase) {
259
259
  const s = clack.spinner();
260
260
  s.start('Waiting for server to be ready…');
261
261
  let ready = false;
262
- for (let i = 0; i < 40; i++) {
263
- await new Promise((r) => setTimeout(r, 1500));
262
+ for (let i = 0; i < READY_POLL_MAX_ATTEMPTS; i++) {
263
+ await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL_MS));
264
264
  try {
265
265
  const res = await fetch(`${apiBase}/auth/needs-setup`);
266
266
  if (res.ok) {
@@ -268,54 +268,90 @@ async function serverStart() {
268
268
  break;
269
269
  }
270
270
  } catch {}
271
+ if (i > 0 && i % 15 === 0) {
272
+ s.message(
273
+ `Still waiting for server to be ready… (${Math.round((i * READY_POLL_INTERVAL_MS) / 1000)}s — check "docker compose logs -f backend" if this feels stuck)`
274
+ );
275
+ }
271
276
  }
272
- s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
277
+ s.stop(
278
+ ready
279
+ ? pc.green('✓ Server is ready')
280
+ : pc.yellow('Server did not respond in time — it may still be starting')
281
+ );
282
+ return ready;
283
+ }
273
284
 
274
- if (ready) {
275
- let needsSetup = false;
276
- try {
277
- const res = await fetch(`${apiBase}/auth/needs-setup`);
278
- const data = await res.json();
279
- needsSetup = data.needsSetup;
280
- } catch {}
285
+ async function runFirstUserSetup(apiBase, frontendPort) {
286
+ let needsSetup = false;
287
+ try {
288
+ const res = await fetch(`${apiBase}/auth/needs-setup`);
289
+ const data = await res.json();
290
+ needsSetup = data.needsSetup;
291
+ } catch {}
281
292
 
282
- if (needsSetup) {
283
- clack.log.info('No users found — create your first account to get started.');
293
+ if (!needsSetup) return;
284
294
 
285
- const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
286
- if (clack.isCancel(name)) {
287
- clack.log.warn('Skipped. Create a user at /setup in the UI.');
288
- } else {
289
- const email = await clack.text({
290
- message: 'Email address',
291
- placeholder: 'jane@example.com'
292
- });
293
- if (clack.isCancel(email)) {
294
- clack.log.warn('Skipped. Create a user at /setup in the UI.');
295
- } else {
296
- const password = await clack.password({ message: 'Password (min 8 characters)' });
297
- if (clack.isCancel(password)) {
298
- clack.log.warn('Skipped. Create a user at /setup in the UI.');
299
- } else {
300
- try {
301
- const res = await fetch(`${apiBase}/auth/setup`, {
302
- method: 'POST',
303
- headers: { 'Content-Type': 'application/json' },
304
- body: JSON.stringify({ name, email, password })
305
- });
306
- if (res.ok) {
307
- clack.log.success(`Account created for ${email}. You can now log in.`);
308
- } else {
309
- const err = await res.json();
310
- clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
311
- }
312
- } catch (e) {
313
- clack.log.error(`Failed to create account: ${e.message}`);
314
- }
315
- }
316
- }
317
- }
295
+ if (!interactiveAllowed()) {
296
+ clack.log.info(
297
+ `No users found. Open ${pc.cyan(`http://localhost:${frontendPort}/setup`)} to create your first account.`
298
+ );
299
+ return;
300
+ }
301
+
302
+ clack.log.info('No users found — create your first account to get started.');
303
+
304
+ const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
305
+ if (clack.isCancel(name)) {
306
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
307
+ return;
308
+ }
309
+ const email = await clack.text({ message: 'Email address', placeholder: 'jane@example.com' });
310
+ if (clack.isCancel(email)) {
311
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
312
+ return;
313
+ }
314
+ const password = await clack.password({ message: 'Password (min 8 characters)' });
315
+ if (clack.isCancel(password)) {
316
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
317
+ return;
318
+ }
319
+
320
+ try {
321
+ const res = await fetch(`${apiBase}/auth/setup`, {
322
+ method: 'POST',
323
+ headers: { 'Content-Type': 'application/json' },
324
+ body: JSON.stringify({ name, email, password })
325
+ });
326
+ if (res.ok) {
327
+ clack.log.success(`Account created for ${email}. You can now log in.`);
328
+ } else {
329
+ const err = await res.json();
330
+ clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
318
331
  }
332
+ } catch (e) {
333
+ clack.log.error(`Failed to create account: ${e.message}`);
334
+ }
335
+ }
336
+
337
+ async function serverStart() {
338
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
339
+ const cfg = await configureServer({ force: false });
340
+ applyServerConfig(cfg);
341
+ clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
342
+
343
+ execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
344
+
345
+ const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
346
+ const ready = await waitForServerReady(apiBase);
347
+
348
+ if (ready) {
349
+ await runFirstUserSetup(apiBase, cfg.frontendPort);
350
+ } else {
351
+ clack.log.warn(
352
+ `Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.\n` +
353
+ `Once it responds, open ${pc.cyan(`http://localhost:${cfg.frontendPort}/setup`)} to create your first account (if this is a fresh install).`
354
+ );
319
355
  }
320
356
 
321
357
  clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
@@ -332,21 +368,13 @@ async function serverRestart() {
332
368
 
333
369
  execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
334
370
 
335
- const apiBase = `http://localhost:${cfg.backendPort}`;
336
- const s = clack.spinner();
337
- s.start('Waiting for server to be ready…');
338
- let ready = false;
339
- for (let i = 0; i < 40; i++) {
340
- await new Promise((r) => setTimeout(r, 1500));
341
- try {
342
- const res = await fetch(`${apiBase}/auth/needs-setup`);
343
- if (res.ok) {
344
- ready = true;
345
- break;
346
- }
347
- } catch {}
371
+ const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
372
+ const ready = await waitForServerReady(apiBase);
373
+ if (!ready) {
374
+ clack.log.warn(
375
+ `Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.`
376
+ );
348
377
  }
349
- s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
350
378
  clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
351
379
  clack.log.info(`API: ${pc.cyan(`http://localhost:${cfg.backendPort}`)}`);
352
380
  clack.outro(pc.green('Server restarted.'));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plum-frontend",
3
3
  "private": true,
4
- "version": "1.3.3",
4
+ "version": "1.4.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite dev",
@@ -15,12 +15,23 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- import { API_BASE } from '$lib/constants';
18
+ import { API_BASE, REPORTS_PER_PAGE } from '$lib/constants';
19
19
 
20
- export async function fetchReports() {
21
- const res = await fetch(`${API_BASE}/reports`);
22
- const { reports } = await res.json();
23
- return reports.map((r) => ({ ...r, date: new Date(r.createdAt).toLocaleString() }));
20
+ function withDate(r) {
21
+ return { ...r, date: new Date(r.createdAt).toLocaleString() };
22
+ }
23
+
24
+ export async function fetchReports({ page = 1, limit = REPORTS_PER_PAGE } = {}) {
25
+ const params = new URLSearchParams({ page, limit });
26
+ const res = await fetch(`${API_BASE}/reports?${params}`);
27
+ const { reports, total, passCount, failCount, trend } = await res.json();
28
+ return {
29
+ reports: reports.map(withDate),
30
+ total,
31
+ passCount,
32
+ failCount,
33
+ trend: trend.map(withDate)
34
+ };
24
35
  }
25
36
 
26
37
  export async function fetchLatestReportId() {
@@ -35,8 +35,19 @@
35
35
  import { fetchRunners } from '$lib/api/runners';
36
36
  import { fetchRuns, fetchRun } from '$lib/api/repository';
37
37
  import { fetchIntegrations } from '$lib/api/settings';
38
- import { API_BASE, BROWSERS } from '$lib/constants';
38
+ import {
39
+ API_BASE,
40
+ BROWSERS,
41
+ BUILTIN_RUNNER_ID,
42
+ BUILTIN_RUNNER_LABEL,
43
+ WORKERS_MIN,
44
+ WORKERS_MAX,
45
+ RUN_PICKER_LIMIT,
46
+ RUN_TAG_DISPLAY_LIMIT,
47
+ ALL_TESTS_LABEL
48
+ } from '$lib/constants';
39
49
  import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
50
+ import Badge from '$lib/components/ui/Badge.svelte';
40
51
 
41
52
  let availableRunners = [];
42
53
  let testRuns = [];
@@ -83,10 +94,10 @@
83
94
  availableRunners = r;
84
95
  // Drop any saved selection pointing at runners that no longer exist,
85
96
  // so a deleted runner can't linger in the selection and break runs.
86
- const validIds = new Set(['built-in', ...r.map((x) => x.id)]);
97
+ const validIds = new Set([BUILTIN_RUNNER_ID, ...r.map((x) => x.id)]);
87
98
  runnerConfig.update((c) => {
88
99
  const pruned = c.selectedRunners.filter((id) => validIds.has(id));
89
- return { ...c, selectedRunners: pruned.length > 0 ? pruned : ['built-in'] };
100
+ return { ...c, selectedRunners: pruned.length > 0 ? pruned : [BUILTIN_RUNNER_ID] };
90
101
  });
91
102
  })
92
103
  .catch(() => {});
@@ -110,8 +121,8 @@
110
121
  localStorage.setItem('plum:builtInEnabled', String(v));
111
122
  } catch {}
112
123
  runnerConfig.update((c) => {
113
- if (!v && c.selectedRunners.includes('built-in')) {
114
- const others = c.selectedRunners.filter((r) => r !== 'built-in');
124
+ if (!v && c.selectedRunners.includes(BUILTIN_RUNNER_ID)) {
125
+ const others = c.selectedRunners.filter((r) => r !== BUILTIN_RUNNER_ID);
115
126
  return { ...c, selectedRunners: others.length > 0 ? others : c.selectedRunners };
116
127
  }
117
128
  return c;
@@ -155,7 +166,7 @@
155
166
  s.on('runner-lanes-init', (lanes) => {
156
167
  runnerState.update((r) => ({
157
168
  ...r,
158
- lanes: lanes.map((l) => ({ ...l, status: 'running', logs: '' }))
169
+ lanes: lanes.map((l) => ({ ...l, status: 'running', logs: '', latestScreenshot: null }))
159
170
  }));
160
171
  });
161
172
 
@@ -173,6 +184,19 @@
173
184
  }));
174
185
  });
175
186
 
187
+ s.on('step-screenshot', ({ stepName, data }) => {
188
+ runnerState.update((r) => ({ ...r, latestScreenshot: { stepName, data } }));
189
+ });
190
+
191
+ s.on('runner-lane-screenshot', ({ id, stepName, data }) => {
192
+ runnerState.update((r) => ({
193
+ ...r,
194
+ lanes: r.lanes.map((l) =>
195
+ l.id === id ? { ...l, latestScreenshot: { stepName, data } } : l
196
+ )
197
+ }));
198
+ });
199
+
176
200
  s.on('tests-changed', () => testsVersion.update((v) => v + 1));
177
201
  s.on('report-ready', () => reportsVersion.update((v) => v + 1));
178
202
 
@@ -200,17 +224,20 @@
200
224
  $: cfg = $runnerConfig;
201
225
 
202
226
  $: truncatedRunTag = (() => {
203
- if (!state.currentRun?.tag) return 'all tests';
227
+ if (!state.currentRun?.tag) return ALL_TESTS_LABEL;
204
228
  const parts = state.currentRun.tag.split(/ or /i);
205
- if (parts.length <= 5) return state.currentRun.tag;
206
- return parts.slice(0, 5).join(' or ') + ` +${parts.length - 5} more`;
229
+ if (parts.length <= RUN_TAG_DISPLAY_LIMIT) return state.currentRun.tag;
230
+ return (
231
+ parts.slice(0, RUN_TAG_DISPLAY_LIMIT).join(' or ') +
232
+ ` +${parts.length - RUN_TAG_DISPLAY_LIMIT} more`
233
+ );
207
234
  })();
208
235
  $: cronJobs = Object.keys($activeCronJobs);
209
236
  $: anyCronRunning = cronJobs.length > 0;
210
237
  $: anyRunning = state.running || anyCronRunning;
211
238
 
212
239
  $: if ($runsVersion >= 0)
213
- fetchRuns({ limit: 200 })
240
+ fetchRuns({ limit: RUN_PICKER_LIMIT })
214
241
  .then((r) => (testRuns = r.runs))
215
242
  .catch(() => {});
216
243
 
@@ -238,8 +265,8 @@
238
265
  $: currentBrowser = BROWSERS.find((b) => b.id === cfg.browser) ?? BROWSERS[0];
239
266
 
240
267
  $: runnerSummary =
241
- cfg.selectedRunners.length === 1 && cfg.selectedRunners[0] === 'built-in'
242
- ? 'Built-in'
268
+ cfg.selectedRunners.length === 1 && cfg.selectedRunners[0] === BUILTIN_RUNNER_ID
269
+ ? BUILTIN_RUNNER_LABEL
243
270
  : cfg.selectedRunners.length === 1
244
271
  ? (availableRunners.find((r) => r.id === cfg.selectedRunners[0])?.name ?? '1 node')
245
272
  : `${cfg.selectedRunners.length} nodes`;
@@ -285,7 +312,7 @@
285
312
  function adjustWorkers(delta) {
286
313
  runnerConfig.update((c) => ({
287
314
  ...c,
288
- workers: Math.max(1, Math.min(10, c.workers + delta))
315
+ workers: Math.max(WORKERS_MIN, Math.min(WORKERS_MAX, c.workers + delta))
289
316
  }));
290
317
  }
291
318
 
@@ -483,13 +510,13 @@
483
510
  <button
484
511
  class="step-btn"
485
512
  on:click={() => adjustWorkers(-1)}
486
- disabled={cfg.workers <= 1 || state.running}>−</button
513
+ disabled={cfg.workers <= WORKERS_MIN || state.running}>−</button
487
514
  >
488
515
  <span class="step-val">{cfg.workers}</span>
489
516
  <button
490
517
  class="step-btn"
491
518
  on:click={() => adjustWorkers(1)}
492
- disabled={cfg.workers >= 10 || state.running}>+</button
519
+ disabled={cfg.workers >= WORKERS_MAX || state.running}>+</button
493
520
  >
494
521
  </div>
495
522
  </div>
@@ -551,7 +578,7 @@
551
578
  <button
552
579
  class="dropdown-trigger"
553
580
  class:open={runnersOpen}
554
- class:has-remote={cfg.selectedRunners.some((r) => r !== 'built-in')}
581
+ class:has-remote={cfg.selectedRunners.some((r) => r !== BUILTIN_RUNNER_ID)}
555
582
  on:click={() => {
556
583
  if (!state.running) runnersOpen = !runnersOpen;
557
584
  }}
@@ -578,11 +605,11 @@
578
605
  <label class="runner-option">
579
606
  <input
580
607
  type="checkbox"
581
- checked={isRunnerSelected('built-in')}
582
- on:change={() => toggleRunner('built-in')}
608
+ checked={isRunnerSelected(BUILTIN_RUNNER_ID)}
609
+ on:change={() => toggleRunner(BUILTIN_RUNNER_ID)}
583
610
  />
584
611
  <span class="runner-dot built-in"></span>
585
- <span>Built-in</span>
612
+ <span>{BUILTIN_RUNNER_LABEL}</span>
586
613
  </label>
587
614
  {/if}
588
615
  {#each availableRunners as r}
@@ -704,7 +731,7 @@
704
731
  </span>
705
732
  {/if}
706
733
  </div>
707
- <span class="run-card-badge">Live</span>
734
+ <Badge variant="tag">Live</Badge>
708
735
  <svg
709
736
  width="13"
710
737
  height="13"
@@ -727,7 +754,7 @@
727
754
  <span class="run-card-label">{name}</span>
728
755
  <span class="run-card-meta">Scheduled run</span>
729
756
  </div>
730
- <span class="run-card-badge cron-badge">Scheduled</span>
757
+ <Badge variant="schedule">Scheduled</Badge>
731
758
  <svg
732
759
  width="13"
733
760
  height="13"
@@ -860,18 +887,6 @@
860
887
  animation: dotPulse 1.6s ease-in-out infinite;
861
888
  }
862
889
 
863
- @keyframes dotPulse {
864
- 0%,
865
- 100% {
866
- opacity: 1;
867
- transform: scale(1);
868
- }
869
- 50% {
870
- opacity: 0.4;
871
- transform: scale(0.65);
872
- }
873
- }
874
-
875
890
  .status-word {
876
891
  font-size: 0.72rem;
877
892
  font-weight: 600;
@@ -887,7 +902,7 @@
887
902
  color: var(--text-muted);
888
903
  background: var(--bg-subtle);
889
904
  border: 1px solid var(--border);
890
- border-radius: 100px;
905
+ border-radius: var(--radius-pill);
891
906
  padding: 0.1rem 0.45rem;
892
907
  white-space: nowrap;
893
908
  overflow: hidden;
@@ -915,7 +930,7 @@
915
930
 
916
931
  .view-report-btn:hover {
917
932
  background: var(--accent);
918
- color: #fff;
933
+ color: var(--white);
919
934
  }
920
935
 
921
936
  /* ── Center: controls ── */
@@ -1175,7 +1190,7 @@
1175
1190
  height: 30px;
1176
1191
  padding: 0 0.875rem;
1177
1192
  background: var(--accent);
1178
- color: white;
1193
+ color: var(--white);
1179
1194
  border: none;
1180
1195
  border-radius: var(--radius-sm);
1181
1196
  font-family: var(--font-body);
@@ -1199,7 +1214,7 @@
1199
1214
  width: 10px;
1200
1215
  height: 10px;
1201
1216
  border: 1.5px solid rgba(255, 255, 255, 0.35);
1202
- border-top-color: white;
1217
+ border-top-color: var(--white);
1203
1218
  border-radius: 50%;
1204
1219
  animation: spin 0.65s linear infinite;
1205
1220
  flex-shrink: 0;
@@ -1241,7 +1256,7 @@
1241
1256
  .notify-btn.active {
1242
1257
  background: var(--accent);
1243
1258
  border-color: var(--accent);
1244
- color: #fff;
1259
+ color: var(--white);
1245
1260
  }
1246
1261
 
1247
1262
  .notify-btn:disabled {
@@ -1363,23 +1378,6 @@
1363
1378
  margin: 0 0.15rem;
1364
1379
  }
1365
1380
 
1366
- .run-card-badge {
1367
- font-size: 0.62rem;
1368
- font-weight: 600;
1369
- letter-spacing: 0.07em;
1370
- text-transform: uppercase;
1371
- color: var(--accent);
1372
- background: var(--accent-soft);
1373
- padding: 0.1rem 0.4rem;
1374
- border-radius: 100px;
1375
- flex-shrink: 0;
1376
- }
1377
-
1378
- .run-card-badge.cron-badge {
1379
- color: var(--warn);
1380
- background: var(--warn-soft);
1381
- }
1382
-
1383
1381
  .run-card-arrow {
1384
1382
  color: var(--text-muted);
1385
1383
  flex-shrink: 0;
@@ -0,0 +1,56 @@
1
+ <!--
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ -->
17
+
18
+ <!--
19
+ * This file is part of Plum.
20
+ *
21
+ * Plum is free software: you can redistribute it and/or modify
22
+ * it under the terms of the GNU General Public License as published by
23
+ * the Free Software Foundation, either version 3 of the License, or
24
+ * (at your option) any later version.
25
+ *
26
+ * Plum is distributed in the hope that it will be useful,
27
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
28
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29
+ * GNU General Public License for more details.
30
+ *
31
+ * You should have received a copy of the GNU General Public License
32
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
33
+ -->
34
+
35
+ <script>
36
+ import { AUTOMATED_LABEL } from '$lib/constants';
37
+ </script>
38
+
39
+ <span class="auto-badge">{AUTOMATED_LABEL}</span>
40
+
41
+ <style>
42
+ .auto-badge {
43
+ display: inline-flex;
44
+ align-items: center;
45
+ font-size: 0.62rem;
46
+ font-weight: 600;
47
+ text-transform: uppercase;
48
+ letter-spacing: 0.05em;
49
+ color: var(--accent);
50
+ background: var(--accent-soft);
51
+ border-radius: var(--radius-pill);
52
+ padding: 0.1rem 0.45rem;
53
+ flex-shrink: 0;
54
+ white-space: nowrap;
55
+ }
56
+ </style>
@@ -0,0 +1,77 @@
1
+ <!--
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ -->
17
+
18
+ <!--
19
+ * This file is part of Plum.
20
+ *
21
+ * Plum is free software: you can redistribute it and/or modify
22
+ * it under the terms of the GNU General Public License as published by
23
+ * the Free Software Foundation, either version 3 of the License, or
24
+ * (at your option) any later version.
25
+ *
26
+ * Plum is distributed in the hope that it will be useful,
27
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
28
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29
+ * GNU General Public License for more details.
30
+ *
31
+ * You should have received a copy of the GNU General Public License
32
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
33
+ -->
34
+
35
+ <script>
36
+ export let href = '/reports';
37
+ export let label = 'Reports';
38
+ </script>
39
+
40
+ <div class="back-row">
41
+ <a {href} class="back-link">
42
+ <svg
43
+ width="14"
44
+ height="14"
45
+ viewBox="0 0 24 24"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ stroke-width="2"
49
+ stroke-linecap="round"
50
+ stroke-linejoin="round"
51
+ >
52
+ <line x1="19" y1="12" x2="5" y2="12" />
53
+ <polyline points="12 19 5 12 12 5" />
54
+ </svg>
55
+ {label}
56
+ </a>
57
+ </div>
58
+
59
+ <style>
60
+ .back-row {
61
+ margin-bottom: 1.5rem;
62
+ }
63
+
64
+ .back-link {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ gap: 0.35rem;
68
+ font-size: 0.8125rem;
69
+ color: var(--text-muted);
70
+ text-decoration: none;
71
+ transition: color var(--duration-fast);
72
+ }
73
+
74
+ .back-link:hover {
75
+ color: var(--text);
76
+ }
77
+ </style>
@@ -31,7 +31,7 @@
31
31
  letter-spacing: 0.05em;
32
32
  text-transform: uppercase;
33
33
  padding: 0.2rem 0.6rem;
34
- border-radius: 100px;
34
+ border-radius: var(--radius-pill);
35
35
  white-space: nowrap;
36
36
  }
37
37
 
@@ -74,7 +74,7 @@
74
74
  /* Variants */
75
75
  .primary {
76
76
  background: var(--accent);
77
- color: #fff;
77
+ color: var(--white);
78
78
  border-color: var(--accent);
79
79
  }
80
80