plum-e2e 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +27 -25
- package/.husky/pre-commit +2 -2
- package/README.md +142 -70
- package/backend/Dockerfile +4 -2
- package/backend/app.js +4 -2
- package/backend/config/scripts/generate-report.js +38 -30
- package/backend/entrypoint.sh +22 -0
- package/backend/package-lock.json +453 -10
- package/backend/package.json +5 -2
- package/backend/prisma/migrations/20260614000000_init/migration.sql +35 -0
- package/backend/prisma/migrations/20260614000001_add_project/migration.sql +8 -0
- package/backend/prisma/migrations/migration_lock.toml +3 -0
- package/backend/prisma/schema.prisma +53 -0
- package/backend/routes/backup.routes.js +50 -0
- package/backend/routes/cron.routes.js +9 -60
- package/backend/routes/reports.routes.js +39 -6
- package/backend/routes/settings.routes.js +43 -0
- package/backend/server.js +52 -1
- package/backend/services/backupService.js +88 -0
- package/backend/services/cronService.js +68 -78
- package/backend/services/{scheduleService.js → prisma.js} +3 -15
- package/backend/services/reportService.js +44 -16
- package/backend/{routes/schedules.routes.js → services/settingsService.js} +17 -13
- package/bin/plum.js +213 -32
- package/docker-compose.yml +24 -0
- package/frontend/package-lock.json +2 -2
- package/frontend/package.json +1 -1
- package/frontend/src/lib/api/reports.js +38 -27
- package/frontend/src/lib/api/schedules.js +9 -25
- package/frontend/src/lib/api/settings.js +48 -0
- package/frontend/src/lib/components/layout/Nav.svelte +2 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +160 -21
- package/frontend/src/lib/components/ui/Terminal.svelte +2 -2
- package/frontend/src/lib/stores/runner.js +9 -0
- package/frontend/src/routes/+page.svelte +10 -3
- package/frontend/src/routes/reports/+page.svelte +342 -51
- package/frontend/src/routes/reports/[slug]/+page.svelte +2 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +247 -11
- package/frontend/src/routes/settings/+page.svelte +410 -0
- package/license-config.json +2 -2
- package/package.json +6 -2
- package/backend/config/scripts/create-settings.js +0 -53
|
@@ -18,17 +18,27 @@
|
|
|
18
18
|
<script>
|
|
19
19
|
import { onMount } from 'svelte';
|
|
20
20
|
import { fly } from 'svelte/transition';
|
|
21
|
-
import {
|
|
21
|
+
import { fetchCronJobs, saveCronJob, deleteCronJob } from '$lib/api/schedules';
|
|
22
|
+
import { activeCronJobs } from '$lib/stores/runner';
|
|
22
23
|
import Button from '$lib/components/ui/Button.svelte';
|
|
23
24
|
import Badge from '$lib/components/ui/Badge.svelte';
|
|
24
25
|
import Modal from '$lib/components/ui/Modal.svelte';
|
|
25
26
|
|
|
26
27
|
const CRON_REGEX =
|
|
27
|
-
/^(\*|([0-5]?[0-9])) (\*|([01]?[0-9]|2[0-3])) (\*|([01]?[0-9]|3[01])) (\*|([1-9]|1[0-2])) (\*|[0-6])$/;
|
|
28
|
+
/^(\*|([0-5]?[0-9])|\*\/[0-9]+) (\*|([01]?[0-9]|2[0-3])) (\*|([01]?[0-9]|3[01])) (\*|([1-9]|1[0-2])) (\*|[0-6](-[0-6])?)$/;
|
|
29
|
+
|
|
30
|
+
const CUSTOM_SENTINEL = '__custom__';
|
|
31
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
32
|
+
|
|
33
|
+
const scheduleOptions = [
|
|
34
|
+
{ label: 'Every minute', value: '* * * * *' },
|
|
35
|
+
{ label: 'Every hour', value: '0 * * * *' },
|
|
36
|
+
{ label: 'Every midnight', value: '0 0 * * *' },
|
|
37
|
+
{ label: 'Every Sunday', value: '0 0 * * 0' }
|
|
38
|
+
];
|
|
28
39
|
|
|
29
40
|
let cronJobs = [];
|
|
30
|
-
let
|
|
31
|
-
let toast = null; // { type: 'success' | 'error', message }
|
|
41
|
+
let toast = null;
|
|
32
42
|
|
|
33
43
|
let modalOpen = false;
|
|
34
44
|
let deleteModalOpen = false;
|
|
@@ -36,18 +46,68 @@
|
|
|
36
46
|
let editTaskName = '';
|
|
37
47
|
let taskToDelete = '';
|
|
38
48
|
|
|
39
|
-
|
|
49
|
+
const WORKER_OPTIONS = [1, 2, 4, 8];
|
|
50
|
+
|
|
51
|
+
let form = { taskName: '', cronExpression: '', tags: '', workers: 1 };
|
|
52
|
+
let selectedSchedule = '';
|
|
53
|
+
let useCustomCron = false;
|
|
40
54
|
let formError = '';
|
|
41
55
|
|
|
56
|
+
function describeCron(expr) {
|
|
57
|
+
if (!expr) return '';
|
|
58
|
+
const parts = expr.trim().split(/\s+/);
|
|
59
|
+
if (parts.length !== 5) return '';
|
|
60
|
+
const [min, hour, dom, month, dow] = parts;
|
|
61
|
+
|
|
62
|
+
const fmt = (h, m) => {
|
|
63
|
+
const ap = h >= 12 ? 'PM' : 'AM';
|
|
64
|
+
return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${ap}`;
|
|
65
|
+
};
|
|
66
|
+
const isNum = (s) => /^\d+$/.test(s);
|
|
67
|
+
const h = isNum(hour) ? +hour : null;
|
|
68
|
+
const m = isNum(min) ? +min : null;
|
|
69
|
+
|
|
70
|
+
if (expr === '* * * * *') return 'Every minute';
|
|
71
|
+
const everyN = min.match(/^\*\/(\d+)$/);
|
|
72
|
+
if (everyN && hour === '*' && dom === '*' && month === '*' && dow === '*')
|
|
73
|
+
return `Every ${everyN[1]} minutes`;
|
|
74
|
+
if (m !== null && hour === '*' && dom === '*' && month === '*' && dow === '*')
|
|
75
|
+
return `Every hour at :${String(m).padStart(2, '0')}`;
|
|
76
|
+
if (m !== null && h !== null && dom === '*' && month === '*' && dow === '1-5')
|
|
77
|
+
return `Weekdays at ${fmt(h, m)}`;
|
|
78
|
+
if (m !== null && h !== null && dom === '*' && month === '*' && dow === '*')
|
|
79
|
+
return `Daily at ${fmt(h, m)}`;
|
|
80
|
+
if (m !== null && h !== null && dom === '*' && month === '*' && /^\d$/.test(dow))
|
|
81
|
+
return `Every ${DAYS[+dow]} at ${fmt(h, m)}`;
|
|
82
|
+
const d = isNum(dom) ? +dom : null;
|
|
83
|
+
if (m !== null && h !== null && d !== null && month === '*' && dow === '*') {
|
|
84
|
+
const sfx = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th';
|
|
85
|
+
return `Monthly on the ${d}${sfx} at ${fmt(h, m)}`;
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
42
90
|
function showToast(type, message) {
|
|
43
91
|
toast = { type, message };
|
|
44
92
|
setTimeout(() => (toast = null), 4000);
|
|
45
93
|
}
|
|
46
94
|
|
|
95
|
+
function handleScheduleChange(e) {
|
|
96
|
+
if (e.target.value === CUSTOM_SENTINEL) {
|
|
97
|
+
useCustomCron = true;
|
|
98
|
+
form.cronExpression = '';
|
|
99
|
+
} else {
|
|
100
|
+
useCustomCron = false;
|
|
101
|
+
form.cronExpression = e.target.value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
47
105
|
function openAddModal() {
|
|
48
106
|
isEditing = false;
|
|
49
107
|
editTaskName = '';
|
|
50
|
-
form = { taskName: '', cronExpression: '', tags: '' };
|
|
108
|
+
form = { taskName: '', cronExpression: '', tags: '', workers: 1 };
|
|
109
|
+
selectedSchedule = '';
|
|
110
|
+
useCustomCron = false;
|
|
51
111
|
formError = '';
|
|
52
112
|
modalOpen = true;
|
|
53
113
|
}
|
|
@@ -55,7 +115,15 @@
|
|
|
55
115
|
function openEditModal(job) {
|
|
56
116
|
isEditing = true;
|
|
57
117
|
editTaskName = job.taskName;
|
|
58
|
-
form = {
|
|
118
|
+
form = {
|
|
119
|
+
taskName: job.taskName,
|
|
120
|
+
cronExpression: job.cronExpression,
|
|
121
|
+
tags: job.tags,
|
|
122
|
+
workers: job.workers ?? 1
|
|
123
|
+
};
|
|
124
|
+
const isPreset = scheduleOptions.some((o) => o.value === job.cronExpression);
|
|
125
|
+
useCustomCron = !isPreset;
|
|
126
|
+
selectedSchedule = isPreset ? job.cronExpression : CUSTOM_SENTINEL;
|
|
59
127
|
formError = '';
|
|
60
128
|
modalOpen = true;
|
|
61
129
|
}
|
|
@@ -110,10 +178,12 @@
|
|
|
110
178
|
}
|
|
111
179
|
|
|
112
180
|
onMount(async () => {
|
|
113
|
-
|
|
181
|
+
cronJobs = await fetchCronJobs();
|
|
114
182
|
});
|
|
115
183
|
</script>
|
|
116
184
|
|
|
185
|
+
<svelte:head><title>Scheduled Tests — Plum</title></svelte:head>
|
|
186
|
+
|
|
117
187
|
<!-- Add/Edit modal -->
|
|
118
188
|
<Modal bind:open={modalOpen} title={isEditing ? 'Edit Cron Job' : 'New Cron Job'}>
|
|
119
189
|
<form on:submit|preventDefault={handleSave} class="modal-form">
|
|
@@ -138,12 +208,35 @@
|
|
|
138
208
|
<div class="field-label">
|
|
139
209
|
<span>Schedule</span>
|
|
140
210
|
</div>
|
|
141
|
-
<select
|
|
142
|
-
|
|
211
|
+
<select
|
|
212
|
+
class="field-input"
|
|
213
|
+
bind:value={selectedSchedule}
|
|
214
|
+
on:change={handleScheduleChange}
|
|
215
|
+
required
|
|
216
|
+
>
|
|
217
|
+
<option value="" disabled>Select a schedule</option>
|
|
143
218
|
{#each scheduleOptions as opt}
|
|
144
219
|
<option value={opt.value}>{opt.label}</option>
|
|
145
220
|
{/each}
|
|
221
|
+
<option value={CUSTOM_SENTINEL}>Custom…</option>
|
|
146
222
|
</select>
|
|
223
|
+
|
|
224
|
+
{#if useCustomCron}
|
|
225
|
+
<input
|
|
226
|
+
type="text"
|
|
227
|
+
class="field-input cron-input"
|
|
228
|
+
bind:value={form.cronExpression}
|
|
229
|
+
placeholder="0 9 * * 1-5"
|
|
230
|
+
spellcheck="false"
|
|
231
|
+
/>
|
|
232
|
+
{#if form.cronExpression}
|
|
233
|
+
<p class="cron-desc" class:cron-invalid={!CRON_REGEX.test(form.cronExpression)}>
|
|
234
|
+
{CRON_REGEX.test(form.cronExpression)
|
|
235
|
+
? describeCron(form.cronExpression) || form.cronExpression
|
|
236
|
+
: 'Invalid cron expression'}
|
|
237
|
+
</p>
|
|
238
|
+
{/if}
|
|
239
|
+
{/if}
|
|
147
240
|
</div>
|
|
148
241
|
|
|
149
242
|
<div class="field">
|
|
@@ -160,6 +253,26 @@
|
|
|
160
253
|
/>
|
|
161
254
|
</div>
|
|
162
255
|
|
|
256
|
+
<div class="field">
|
|
257
|
+
<div class="field-label">
|
|
258
|
+
<span>Runners</span>
|
|
259
|
+
<span class="field-hint">Parallel workers for this job</span>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="seg-control">
|
|
262
|
+
{#each WORKER_OPTIONS as n}
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
class="seg-btn"
|
|
266
|
+
class:active={form.workers === n}
|
|
267
|
+
on:click={() => (form.workers = n)}
|
|
268
|
+
>
|
|
269
|
+
<span class="seg-num">{n}</span>
|
|
270
|
+
<span class="seg-label">{n === 1 ? 'runner' : 'runners'}</span>
|
|
271
|
+
</button>
|
|
272
|
+
{/each}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
163
276
|
{#if formError}
|
|
164
277
|
<p class="form-error">{formError}</p>
|
|
165
278
|
{/if}
|
|
@@ -209,19 +322,30 @@
|
|
|
209
322
|
<th>Name</th>
|
|
210
323
|
<th>Schedule</th>
|
|
211
324
|
<th>Tags</th>
|
|
325
|
+
<th>Runners</th>
|
|
212
326
|
<th>Actions</th>
|
|
213
327
|
</tr>
|
|
214
328
|
</thead>
|
|
215
329
|
<tbody>
|
|
216
330
|
{#each cronJobs as job, i}
|
|
217
331
|
<tr style="animation-delay: {i * 45}ms" class="job-row">
|
|
218
|
-
<td class="job-name">
|
|
332
|
+
<td class="job-name">
|
|
333
|
+
{#if $activeCronJobs[job.taskName]}
|
|
334
|
+
<span class="running-dot" title="Running now"></span>
|
|
335
|
+
{/if}
|
|
336
|
+
{job.taskName}
|
|
337
|
+
</td>
|
|
219
338
|
<td>
|
|
220
339
|
<Badge variant="schedule">{cronLabel(job.cronExpression)}</Badge>
|
|
221
340
|
</td>
|
|
222
341
|
<td>
|
|
223
342
|
<span class="tag-text">{job.tags}</span>
|
|
224
343
|
</td>
|
|
344
|
+
<td>
|
|
345
|
+
<span class="workers-badge" class:multi={job.workers > 1}>
|
|
346
|
+
×{job.workers}
|
|
347
|
+
</span>
|
|
348
|
+
</td>
|
|
225
349
|
<td class="actions-cell">
|
|
226
350
|
<Button variant="ghost" size="sm" on:click={() => openEditModal(job)}>Edit</Button>
|
|
227
351
|
<Button variant="danger" size="sm" on:click={() => openDeleteModal(job.taskName)}
|
|
@@ -276,6 +400,31 @@
|
|
|
276
400
|
.job-name {
|
|
277
401
|
font-weight: 400;
|
|
278
402
|
font-size: 0.875rem;
|
|
403
|
+
display: flex;
|
|
404
|
+
align-items: center;
|
|
405
|
+
gap: 0.5rem;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.running-dot {
|
|
409
|
+
display: inline-block;
|
|
410
|
+
width: 7px;
|
|
411
|
+
height: 7px;
|
|
412
|
+
border-radius: 50%;
|
|
413
|
+
background: var(--pass);
|
|
414
|
+
flex-shrink: 0;
|
|
415
|
+
animation: statusPulse 1.6s ease-in-out infinite;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@keyframes statusPulse {
|
|
419
|
+
0%,
|
|
420
|
+
100% {
|
|
421
|
+
opacity: 1;
|
|
422
|
+
transform: scale(1);
|
|
423
|
+
}
|
|
424
|
+
50% {
|
|
425
|
+
opacity: 0.4;
|
|
426
|
+
transform: scale(0.65);
|
|
427
|
+
}
|
|
279
428
|
}
|
|
280
429
|
|
|
281
430
|
.tag-text {
|
|
@@ -324,4 +473,91 @@
|
|
|
324
473
|
gap: 0.625rem;
|
|
325
474
|
padding-top: 0.25rem;
|
|
326
475
|
}
|
|
476
|
+
|
|
477
|
+
/* Custom cron input */
|
|
478
|
+
.cron-input {
|
|
479
|
+
margin-top: 0.5rem;
|
|
480
|
+
font-family: 'JetBrains Mono', monospace;
|
|
481
|
+
font-size: 0.875rem;
|
|
482
|
+
letter-spacing: 0.03em;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.cron-desc {
|
|
486
|
+
margin-top: 0.375rem;
|
|
487
|
+
font-size: 0.8rem;
|
|
488
|
+
color: var(--pass);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.cron-desc.cron-invalid {
|
|
492
|
+
color: var(--fail);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* Segmented control */
|
|
496
|
+
.seg-control {
|
|
497
|
+
display: flex;
|
|
498
|
+
border: 1px solid var(--border);
|
|
499
|
+
border-radius: var(--radius-sm);
|
|
500
|
+
overflow: hidden;
|
|
501
|
+
width: fit-content;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.seg-btn {
|
|
505
|
+
display: flex;
|
|
506
|
+
flex-direction: column;
|
|
507
|
+
align-items: center;
|
|
508
|
+
padding: 0.375rem 0.875rem;
|
|
509
|
+
font-family: inherit;
|
|
510
|
+
background: var(--bg-elevated);
|
|
511
|
+
color: var(--text-muted);
|
|
512
|
+
border: none;
|
|
513
|
+
border-right: 1px solid var(--border);
|
|
514
|
+
cursor: pointer;
|
|
515
|
+
transition:
|
|
516
|
+
background var(--duration-fast),
|
|
517
|
+
color var(--duration-fast);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.seg-num {
|
|
521
|
+
font-size: 0.875rem;
|
|
522
|
+
font-weight: 600;
|
|
523
|
+
line-height: 1.2;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.seg-label {
|
|
527
|
+
font-size: 0.625rem;
|
|
528
|
+
letter-spacing: 0.04em;
|
|
529
|
+
text-transform: uppercase;
|
|
530
|
+
opacity: 0.75;
|
|
531
|
+
line-height: 1.2;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.seg-btn:last-child {
|
|
535
|
+
border-right: none;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.seg-btn:hover {
|
|
539
|
+
background: var(--bg-subtle);
|
|
540
|
+
color: var(--text);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.seg-btn.active {
|
|
544
|
+
background: var(--accent);
|
|
545
|
+
color: #fff;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.seg-btn.active .seg-label {
|
|
549
|
+
opacity: 0.85;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/* Workers badge in table */
|
|
553
|
+
.workers-badge {
|
|
554
|
+
font-family: 'JetBrains Mono', monospace;
|
|
555
|
+
font-size: 0.75rem;
|
|
556
|
+
color: var(--text-muted);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.workers-badge.multi {
|
|
560
|
+
color: var(--accent);
|
|
561
|
+
font-weight: 500;
|
|
562
|
+
}
|
|
327
563
|
</style>
|