plum-e2e 2.4.13 → 2.5.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 (49) hide show
  1. package/backend/_scaffold/utils/browser.ts +47 -2
  2. package/backend/_scaffold/utils/hooks.ts +25 -2
  3. package/backend/config/scripts/run-tests.js +35 -13
  4. package/backend/logs/runner-cmr8o2x3n0000mg01410tpuck.log +20 -0
  5. package/backend/logs/runner-cmr90hvcx0000n2016edtmrtv.log +43 -0
  6. package/backend/logs/runner-cmr90i7h20001n201g2wg6v3n.log +43 -0
  7. package/backend/logs/runner-cmr90iilm0002n201mmisva95.log +43 -0
  8. package/backend/logs/runner-cmr90j70r0003n201xz8525dz.log +20 -0
  9. package/backend/logs/runner-cmr90yup70000qp017ianc2gp.log +43 -0
  10. package/backend/logs/runner-cmr90zbtm0001qp01u9tfdkh1.log +20 -0
  11. package/backend/logs/runner-cmr91a3mw0000tb01414rd1df.log +20 -0
  12. package/backend/logs/runner-cmr91dhla0001tb01jp7thjnl.log +20 -0
  13. package/backend/prisma/migrations/20260706000000_add_report_logs/migration.sql +2 -0
  14. package/backend/prisma/schema.prisma +1 -0
  15. package/backend/routes/node.routes.js +42 -4
  16. package/backend/services/reportService.js +24 -4
  17. package/backend/services/runnerService.js +11 -1
  18. package/backend/websockets/socketHandler.js +162 -16
  19. package/frontend/package.json +1 -1
  20. package/frontend/src/lib/components/layout/RunnerPanel.svelte +53 -55
  21. package/frontend/src/lib/components/ui/AutomatedBadge.svelte +56 -0
  22. package/frontend/src/lib/components/ui/BackLink.svelte +77 -0
  23. package/frontend/src/lib/components/ui/Badge.svelte +1 -1
  24. package/frontend/src/lib/components/ui/Button.svelte +1 -1
  25. package/frontend/src/lib/components/ui/CaseIdChip.svelte +59 -0
  26. package/frontend/src/lib/components/ui/ConfirmModal.svelte +1 -1
  27. package/frontend/src/lib/components/ui/Pagination.svelte +1 -1
  28. package/frontend/src/lib/components/ui/PriorityBadge.svelte +83 -0
  29. package/frontend/src/lib/components/ui/ResultChip.svelte +92 -0
  30. package/frontend/src/lib/components/ui/StatusDot.svelte +64 -0
  31. package/frontend/src/lib/components/ui/StepKeyword.svelte +79 -0
  32. package/frontend/src/lib/components/ui/StepStatusIcon.svelte +66 -0
  33. package/frontend/src/lib/components/ui/TagChip.svelte +51 -0
  34. package/frontend/src/lib/constants.js +14 -0
  35. package/frontend/src/lib/stores/runner.js +5 -3
  36. package/frontend/src/lib/styles/tokens.css +26 -0
  37. package/frontend/src/lib/utils/format.js +74 -0
  38. package/frontend/src/routes/+page.svelte +3 -3
  39. package/frontend/src/routes/login/+page.svelte +1 -1
  40. package/frontend/src/routes/reports/+page.svelte +3 -3
  41. package/frontend/src/routes/reports/[id]/+page.svelte +703 -215
  42. package/frontend/src/routes/reports/live/+page.svelte +281 -283
  43. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -3
  44. package/frontend/src/routes/settings/+page.svelte +6 -6
  45. package/frontend/src/routes/setup/+page.svelte +1 -1
  46. package/frontend/src/routes/test-repository/+page.svelte +5 -5
  47. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +27 -151
  48. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +4 -4
  49. package/package.json +1 -1
@@ -138,7 +138,13 @@ async function fetchReportContent(runner, jobId, onLog) {
138
138
  * @param {(log: string) => void} onLog Called with each new log chunk
139
139
  * @param {(exitCode: number, reportContent: string|null) => void} onDone
140
140
  */
141
- async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDone) {
141
+ async function dispatchAndPoll(
142
+ runnerId,
143
+ { tags, browser, workers },
144
+ onLog,
145
+ onDone,
146
+ onScreenshot = null
147
+ ) {
142
148
  // The async poll callback can overlap if a tick takes longer than the interval;
143
149
  // guard so the run resolves exactly once and can't be finalised while a lane
144
150
  // is still in flight.
@@ -195,6 +201,10 @@ async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDo
195
201
  logOffset += body.logs.length;
196
202
  }
197
203
 
204
+ if (onScreenshot && Array.isArray(body.screenshots)) {
205
+ for (const ss of body.screenshots) onScreenshot(ss);
206
+ }
207
+
198
208
  if (body.status === 'done' || body.status === 'error') {
199
209
  clearInterval(poll);
200
210
  const content = await fetchReportContent(runner, jobId, onLog);
@@ -16,12 +16,16 @@
16
16
  */
17
17
 
18
18
  const { spawn } = require('child_process');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
19
22
  const runnerService = require('../services/runnerService');
20
23
  const reportService = require('../services/reportService');
21
24
  const notificationService = require('../services/notificationService');
22
25
  const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
23
26
  const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
24
27
  const { readCucumberReportFile } = require('../lib/reportFilename');
28
+ const { getTestSuites } = require('../services/testService');
25
29
  const prisma = require('../services/prisma');
26
30
 
27
31
  const socketHandler = (io) => {
@@ -110,10 +114,80 @@ const socketHandler = (io) => {
110
114
  });
111
115
  };
112
116
 
117
+ // ---------------------------------------------------------------------------
118
+ // Helpers
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function makeSyntheticFailReport(laneName, testIds, reason) {
122
+ // Build id → scenario title from local feature files so names match the real report.
123
+ const nameMap = {};
124
+ try {
125
+ const { suites } = getTestSuites();
126
+ for (const suite of suites) {
127
+ for (const test of suite.tests) {
128
+ for (const id of Array.isArray(test.id) ? test.id : [test.id]) {
129
+ nameMap[id] = test.testCase;
130
+ }
131
+ }
132
+ }
133
+ } catch {}
134
+
135
+ return JSON.stringify([
136
+ {
137
+ id: 'runner-error',
138
+ uri: 'runner-error',
139
+ name: `Runner: ${laneName}`,
140
+ keyword: 'Feature',
141
+ elements: testIds.map((id) => ({
142
+ id: id.replace(/^@/, '').toLowerCase(),
143
+ // Use real scenario title; fall back to bare ID without @
144
+ name: nameMap[id] || id.replace(/^@/, ''),
145
+ keyword: 'Scenario',
146
+ type: 'scenario',
147
+ // ids from getTestIdsForTag already carry the @ — don't add a second one
148
+ tags: [{ name: id.startsWith('@') ? id : `@${id}` }],
149
+ steps: [
150
+ {
151
+ keyword: 'Given ',
152
+ name: 'the scenario was assigned to this runner',
153
+ result: {
154
+ status: 'failed',
155
+ error_message: `Runner "${laneName}" did not complete: ${reason}`,
156
+ duration: 0
157
+ }
158
+ }
159
+ ]
160
+ }))
161
+ }
162
+ ]);
163
+ }
164
+
113
165
  // ---------------------------------------------------------------------------
114
166
  // Single built-in runner
115
167
  // ---------------------------------------------------------------------------
116
168
 
169
+ function startSsPoller(ssDir, onScreenshot) {
170
+ const seenFiles = new Set();
171
+ return setInterval(() => {
172
+ try {
173
+ const files = fs
174
+ .readdirSync(ssDir)
175
+ .filter((f) => f.endsWith('.ss.json'))
176
+ .sort();
177
+ for (const f of files) {
178
+ if (seenFiles.has(f)) continue;
179
+ seenFiles.add(f);
180
+ const filePath = path.join(ssDir, f);
181
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
182
+ onScreenshot(data);
183
+ try {
184
+ fs.unlinkSync(filePath);
185
+ } catch {}
186
+ }
187
+ } catch {}
188
+ }, 400);
189
+ }
190
+
117
191
  function runBuiltIn(
118
192
  io,
119
193
  socket,
@@ -125,12 +199,16 @@ function runBuiltIn(
125
199
  notifyDiscord,
126
200
  notifySlack
127
201
  ) {
202
+ const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}`);
203
+ fs.mkdirSync(ssDir, { recursive: true });
204
+
128
205
  const env = {
129
206
  ...process.env,
130
207
  TAG: tag,
131
208
  TRIGGER: TRIGGER_TYPE.MANUAL,
132
209
  REPORT_RUNNERS: String(workers),
133
- BROWSER: browser
210
+ BROWSER: browser,
211
+ PLUM_SS_DIR: ssDir
134
212
  };
135
213
  if (workers > 1) env.PARALLEL = String(workers);
136
214
  if (testRunId) env.TEST_RUN_ID = testRunId;
@@ -138,15 +216,47 @@ function runBuiltIn(
138
216
  const proc = spawn('npm', ['run', 'test'], { env, shell: true });
139
217
  activeProcs.add(proc);
140
218
 
141
- proc.stdout.on('data', (d) => socket.emit('log', d.toString()));
142
- proc.stderr.on('data', (d) => socket.emit('log', `[ERROR] ${d.toString()}`));
219
+ const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
220
+ socket.emit('step-screenshot', { stepName, data });
221
+ });
143
222
 
144
- proc.on('close', (code) => {
223
+ let logBuffer = '';
224
+ proc.stdout.on('data', (d) => {
225
+ const text = d.toString();
226
+ logBuffer += text;
227
+ socket.emit('log', text);
228
+ });
229
+ proc.stderr.on('data', (d) => {
230
+ const text = `[ERROR] ${d.toString()}`;
231
+ logBuffer += text;
232
+ socket.emit('log', text);
233
+ });
234
+
235
+ proc.on('close', async (code) => {
236
+ clearInterval(ssPoller);
237
+ fs.rm(ssDir, { recursive: true, force: true }, () => {});
145
238
  activeProcs.delete(proc);
146
239
  socket.emit('log', `\nTest finished with code ${code}`);
147
240
  socket.emit('done', code);
148
241
  io.emit('report-ready');
149
242
 
243
+ // Attach accumulated logs to the report generate-report.js just saved
244
+ try {
245
+ const latest = await prisma.report.findFirst({
246
+ where: { triggerType: TRIGGER_TYPE.MANUAL },
247
+ orderBy: { createdAt: 'desc' },
248
+ select: { id: true }
249
+ });
250
+ if (latest) {
251
+ await prisma.report.update({
252
+ where: { id: latest.id },
253
+ data: { logs: logBuffer || null }
254
+ });
255
+ }
256
+ } catch (e) {
257
+ console.error('[socket] Failed to save run logs:', e.message);
258
+ }
259
+
150
260
  if (notifyDiscord || notifySlack) {
151
261
  prisma.report
152
262
  .findFirst({
@@ -224,6 +334,8 @@ async function runDistributed(
224
334
 
225
335
  const total = activeRunnerIds.length;
226
336
  const collectedReports = new Array(total).fill(null);
337
+ const laneLogs = {};
338
+ for (const l of laneInfos) laneLogs[l.id] = '';
227
339
  let doneCount = 0;
228
340
  let overallCode = 0;
229
341
 
@@ -244,7 +356,8 @@ async function runDistributed(
244
356
  tag,
245
357
  triggerType: TRIGGER_TYPE.MANUAL,
246
358
  browser,
247
- testRunId: testRunId ?? null
359
+ testRunId: testRunId ?? null,
360
+ laneLogs
248
361
  })
249
362
  .then((saved) => {
250
363
  // Result is authoritative from the merged report, not the exit code —
@@ -278,40 +391,73 @@ async function runDistributed(
278
391
  for (let i = 0; i < activeRunnerIds.length; i++) {
279
392
  const lane = laneInfos[i];
280
393
  const chunkTag = buildTagExpression(chunks[i]);
394
+ const chunkIds = chunks[i];
281
395
 
282
396
  if (lane.id === BUILT_IN_RUNNER_ID) {
397
+ const laneId = lane.id;
398
+ const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}-${i}`);
399
+ fs.mkdirSync(ssDir, { recursive: true });
400
+
283
401
  const env = {
284
402
  ...process.env,
285
403
  TAG: chunkTag,
286
404
  TRIGGER: TRIGGER_REMOTE,
287
405
  BROWSER: browser,
288
406
  REPORT_RUNNERS: String(workers),
289
- PLUM_MODE: 'node'
407
+ PLUM_MODE: 'node',
408
+ PLUM_SS_DIR: ssDir
290
409
  };
291
410
  if (workers > 1) env.PARALLEL = String(workers);
292
411
 
293
412
  const proc = spawn('npm', ['run', 'test'], { env, shell: true });
294
413
  activeProcs.add(proc);
295
414
 
296
- proc.stdout.on('data', (d) =>
297
- socket.emit('runner-lane-log', { id: lane.id, log: d.toString() })
298
- );
299
- proc.stderr.on('data', (d) =>
300
- socket.emit('runner-lane-log', { id: lane.id, log: `[ERROR] ${d.toString()}` })
301
- );
415
+ const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
416
+ socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
417
+ });
418
+
419
+ proc.stdout.on('data', (d) => {
420
+ const text = d.toString();
421
+ laneLogs[laneId] += text;
422
+ socket.emit('runner-lane-log', { id: laneId, log: text });
423
+ });
424
+ proc.stderr.on('data', (d) => {
425
+ const text = `[ERROR] ${d.toString()}`;
426
+ laneLogs[laneId] += text;
427
+ socket.emit('runner-lane-log', { id: laneId, log: text });
428
+ });
302
429
 
303
430
  const idx = i;
304
431
  proc.on('close', (code) => {
432
+ clearInterval(ssPoller);
433
+ fs.rm(ssDir, { recursive: true, force: true }, () => {});
305
434
  activeProcs.delete(proc);
306
- onLaneDone(idx, lane.id, code, readCucumberReportFile());
435
+ const content =
436
+ readCucumberReportFile() ??
437
+ makeSyntheticFailReport(lane.name, chunkIds, 'process exited with error');
438
+ onLaneDone(idx, laneId, code, content);
307
439
  });
308
440
  } else {
309
441
  const idx = i;
442
+ const laneId = lane.id;
310
443
  runnerService.dispatchAndPoll(
311
- lane.id,
444
+ laneId,
312
445
  { tags: chunkTag, browser, workers },
313
- (log) => socket.emit('runner-lane-log', { id: lane.id, log }),
314
- (code, content) => onLaneDone(idx, lane.id, code, content)
446
+ (log) => {
447
+ laneLogs[laneId] += log;
448
+ socket.emit('runner-lane-log', { id: laneId, log });
449
+ },
450
+ (code, content) =>
451
+ onLaneDone(
452
+ idx,
453
+ laneId,
454
+ code,
455
+ content ??
456
+ makeSyntheticFailReport(lane.name, chunkIds, 'could not fetch report from runner')
457
+ ),
458
+ ({ stepName, data }) => {
459
+ socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
460
+ }
315
461
  );
316
462
  }
317
463
  }
@@ -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",
@@ -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>