plum-e2e 1.2.4 → 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.
- package/CLAUDE.md +201 -0
- package/README.md +237 -90
- package/backend/_scaffold/utils/browser.ts +5 -2
- package/backend/app.js +9 -1
- package/backend/config/scripts/generate-report.js +34 -73
- package/backend/config/scripts/run-tests.js +7 -3
- package/backend/constants/triggers.js +67 -0
- package/backend/lib/reportFilename.js +37 -0
- package/backend/lib/testChunker.js +73 -0
- package/backend/middleware/auth.js +32 -0
- package/backend/package.json +4 -2
- package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
- package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
- package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
- package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
- package/backend/prisma/schema.prisma +21 -1
- package/backend/routes/cron.routes.js +28 -0
- package/backend/routes/node.routes.js +121 -0
- package/backend/routes/reports.routes.js +23 -20
- package/backend/routes/runners.routes.js +83 -0
- package/backend/scripts/add-local-runner.js +120 -0
- package/backend/scripts/create-test.js +148 -0
- package/backend/server.js +16 -7
- package/backend/services/backupService.js +3 -30
- package/backend/services/cronService.js +220 -36
- package/backend/services/reportService.js +227 -55
- package/backend/services/runnerService.js +179 -0
- package/backend/websockets/socketHandler.js +162 -21
- package/bin/plum.js +132 -19
- package/docker-compose.node.yml +59 -0
- package/docker-compose.yml +2 -0
- package/frontend/package.json +1 -4
- package/frontend/src/app.css +20 -254
- package/frontend/src/app.html +1 -1
- package/frontend/src/lib/api/reports.js +17 -36
- package/frontend/src/lib/api/runners.js +61 -0
- package/frontend/src/lib/api/schedules.js +34 -5
- package/frontend/src/lib/api/settings.js +5 -5
- package/frontend/src/lib/api/tests.js +2 -19
- package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
- package/frontend/src/lib/components/layout/Nav.svelte +42 -47
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
- package/frontend/src/lib/components/ui/Badge.svelte +6 -1
- package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
- package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
- package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
- package/frontend/src/lib/constants.js +36 -0
- package/frontend/src/lib/stores/runner.js +23 -12
- package/frontend/src/lib/styles/global.css +176 -0
- package/frontend/src/lib/styles/reset.css +86 -0
- package/frontend/src/lib/styles/tokens.css +90 -0
- package/frontend/src/lib/utils/format.js +46 -0
- package/frontend/src/routes/+page.svelte +16 -35
- package/frontend/src/routes/reports/+page.svelte +84 -167
- package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
- package/frontend/src/routes/reports/live/+page.svelte +704 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
- package/frontend/src/routes/settings/+page.svelte +774 -127
- package/frontend/static/favicon-32x32.png +0 -0
- package/frontend/static/favicon.ico +0 -0
- package/package.json +1 -1
- package/frontend/static/favicon.png +0 -0
|
@@ -17,12 +17,23 @@
|
|
|
17
17
|
|
|
18
18
|
<script>
|
|
19
19
|
import { onMount } from 'svelte';
|
|
20
|
-
import {
|
|
21
|
-
|
|
20
|
+
import {
|
|
21
|
+
fetchCronJobs,
|
|
22
|
+
saveCronJob,
|
|
23
|
+
deleteCronJob,
|
|
24
|
+
runCronJobNow,
|
|
25
|
+
toggleCronJob
|
|
26
|
+
} from '$lib/api/schedules';
|
|
27
|
+
import { fetchRunners } from '$lib/api/runners';
|
|
22
28
|
import { activeCronJobs } from '$lib/stores/runner';
|
|
29
|
+
import { BROWSERS, WORKER_OPTIONS, TOAST_TIMEOUT_MS } from '$lib/constants';
|
|
30
|
+
import { stagger } from '$lib/utils/format';
|
|
23
31
|
import Button from '$lib/components/ui/Button.svelte';
|
|
24
32
|
import Badge from '$lib/components/ui/Badge.svelte';
|
|
25
33
|
import Modal from '$lib/components/ui/Modal.svelte';
|
|
34
|
+
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
35
|
+
import Toast from '$lib/components/ui/Toast.svelte';
|
|
36
|
+
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
26
37
|
|
|
27
38
|
const CRON_REGEX =
|
|
28
39
|
/^(\*|([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])?)$/;
|
|
@@ -38,6 +49,7 @@
|
|
|
38
49
|
];
|
|
39
50
|
|
|
40
51
|
let cronJobs = [];
|
|
52
|
+
let availableRunners = [];
|
|
41
53
|
let toast = null;
|
|
42
54
|
|
|
43
55
|
let modalOpen = false;
|
|
@@ -46,9 +58,14 @@
|
|
|
46
58
|
let editTaskName = '';
|
|
47
59
|
let taskToDelete = '';
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
let form = {
|
|
62
|
+
taskName: '',
|
|
63
|
+
cronExpression: '',
|
|
64
|
+
tags: '',
|
|
65
|
+
workers: 1,
|
|
66
|
+
browser: 'chromium',
|
|
67
|
+
runnerIds: ['built-in']
|
|
68
|
+
};
|
|
52
69
|
let selectedSchedule = '';
|
|
53
70
|
let useCustomCron = false;
|
|
54
71
|
let formError = '';
|
|
@@ -58,7 +75,6 @@
|
|
|
58
75
|
const parts = expr.trim().split(/\s+/);
|
|
59
76
|
if (parts.length !== 5) return '';
|
|
60
77
|
const [min, hour, dom, month, dow] = parts;
|
|
61
|
-
|
|
62
78
|
const fmt = (h, m) => {
|
|
63
79
|
const ap = h >= 12 ? 'PM' : 'AM';
|
|
64
80
|
return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${ap}`;
|
|
@@ -89,7 +105,7 @@
|
|
|
89
105
|
|
|
90
106
|
function showToast(type, message) {
|
|
91
107
|
toast = { type, message };
|
|
92
|
-
setTimeout(() => (toast = null),
|
|
108
|
+
setTimeout(() => (toast = null), TOAST_TIMEOUT_MS);
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
function handleScheduleChange(e) {
|
|
@@ -102,10 +118,23 @@
|
|
|
102
118
|
}
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
function toggleFormRunner(id) {
|
|
122
|
+
const current = form.runnerIds;
|
|
123
|
+
if (current.includes(id) && current.length === 1) return;
|
|
124
|
+
form.runnerIds = current.includes(id) ? current.filter((r) => r !== id) : [...current, id];
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
function openAddModal() {
|
|
106
128
|
isEditing = false;
|
|
107
129
|
editTaskName = '';
|
|
108
|
-
form = {
|
|
130
|
+
form = {
|
|
131
|
+
taskName: '',
|
|
132
|
+
cronExpression: '',
|
|
133
|
+
tags: '',
|
|
134
|
+
workers: 1,
|
|
135
|
+
browser: 'chromium',
|
|
136
|
+
runnerIds: ['built-in']
|
|
137
|
+
};
|
|
109
138
|
selectedSchedule = '';
|
|
110
139
|
useCustomCron = false;
|
|
111
140
|
formError = '';
|
|
@@ -115,11 +144,14 @@
|
|
|
115
144
|
function openEditModal(job) {
|
|
116
145
|
isEditing = true;
|
|
117
146
|
editTaskName = job.taskName;
|
|
147
|
+
const storedIds = job.runnerIds ? job.runnerIds.split(',').map((s) => s.trim()) : ['built-in'];
|
|
118
148
|
form = {
|
|
119
149
|
taskName: job.taskName,
|
|
120
150
|
cronExpression: job.cronExpression,
|
|
121
151
|
tags: job.tags,
|
|
122
|
-
workers: job.workers ?? 1
|
|
152
|
+
workers: job.workers ?? 1,
|
|
153
|
+
browser: job.browser ?? 'chromium',
|
|
154
|
+
runnerIds: storedIds
|
|
123
155
|
};
|
|
124
156
|
const isPreset = scheduleOptions.some((o) => o.value === job.cronExpression);
|
|
125
157
|
useCustomCron = !isPreset;
|
|
@@ -128,11 +160,6 @@
|
|
|
128
160
|
modalOpen = true;
|
|
129
161
|
}
|
|
130
162
|
|
|
131
|
-
function openDeleteModal(taskName) {
|
|
132
|
-
taskToDelete = taskName;
|
|
133
|
-
deleteModalOpen = true;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
163
|
async function handleSave() {
|
|
137
164
|
if (!form.taskName || !form.cronExpression || !form.tags) {
|
|
138
165
|
formError = 'All fields are required.';
|
|
@@ -157,6 +184,26 @@
|
|
|
157
184
|
}
|
|
158
185
|
}
|
|
159
186
|
|
|
187
|
+
async function handleToggle(job) {
|
|
188
|
+
const next = !job.enabled;
|
|
189
|
+
cronJobs = cronJobs.map((j) => (j.taskName === job.taskName ? { ...j, enabled: next } : j));
|
|
190
|
+
try {
|
|
191
|
+
await toggleCronJob(job.taskName, next);
|
|
192
|
+
} catch {
|
|
193
|
+
cronJobs = cronJobs.map((j) => (j.taskName === job.taskName ? { ...j, enabled: !next } : j));
|
|
194
|
+
showToast('error', 'Could not update schedule.');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function handleRunNow(taskName) {
|
|
199
|
+
try {
|
|
200
|
+
await runCronJobNow(taskName);
|
|
201
|
+
showToast('success', `Started: ${taskName}`);
|
|
202
|
+
} catch {
|
|
203
|
+
showToast('error', 'Could not trigger run.');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
160
207
|
async function handleDelete() {
|
|
161
208
|
try {
|
|
162
209
|
const data = await deleteCronJob(taskToDelete);
|
|
@@ -179,6 +226,9 @@
|
|
|
179
226
|
|
|
180
227
|
onMount(async () => {
|
|
181
228
|
cronJobs = await fetchCronJobs();
|
|
229
|
+
try {
|
|
230
|
+
availableRunners = await fetchRunners();
|
|
231
|
+
} catch {}
|
|
182
232
|
});
|
|
183
233
|
</script>
|
|
184
234
|
|
|
@@ -190,24 +240,19 @@
|
|
|
190
240
|
<div class="field">
|
|
191
241
|
<div class="field-label">
|
|
192
242
|
<span>Task Name</span>
|
|
193
|
-
<span class="field-hint">
|
|
194
|
-
{isEditing ? 'Name is the ID — cannot be changed' : 'Use a unique, meaningful name'}
|
|
195
|
-
</span>
|
|
243
|
+
<span class="field-hint">Use a unique, meaningful name</span>
|
|
196
244
|
</div>
|
|
197
245
|
<input
|
|
198
246
|
type="text"
|
|
199
247
|
class="field-input"
|
|
200
248
|
bind:value={form.taskName}
|
|
201
249
|
placeholder="nightly-login-suite"
|
|
202
|
-
disabled={isEditing}
|
|
203
250
|
required
|
|
204
251
|
/>
|
|
205
252
|
</div>
|
|
206
253
|
|
|
207
254
|
<div class="field">
|
|
208
|
-
<div class="field-label">
|
|
209
|
-
<span>Schedule</span>
|
|
210
|
-
</div>
|
|
255
|
+
<div class="field-label"><span>Schedule</span></div>
|
|
211
256
|
<select
|
|
212
257
|
class="field-input"
|
|
213
258
|
bind:value={selectedSchedule}
|
|
@@ -255,7 +300,7 @@
|
|
|
255
300
|
|
|
256
301
|
<div class="field">
|
|
257
302
|
<div class="field-label">
|
|
258
|
-
<span>
|
|
303
|
+
<span>Workers</span>
|
|
259
304
|
<span class="field-hint">Parallel workers for this job</span>
|
|
260
305
|
</div>
|
|
261
306
|
<div class="seg-control">
|
|
@@ -267,34 +312,74 @@
|
|
|
267
312
|
on:click={() => (form.workers = n)}
|
|
268
313
|
>
|
|
269
314
|
<span class="seg-num">{n}</span>
|
|
270
|
-
<span class="seg-label">{n === 1 ? '
|
|
315
|
+
<span class="seg-label">{n === 1 ? 'worker' : 'workers'}</span>
|
|
271
316
|
</button>
|
|
272
317
|
{/each}
|
|
273
318
|
</div>
|
|
274
319
|
</div>
|
|
275
320
|
|
|
321
|
+
<div class="field">
|
|
322
|
+
<div class="field-label"><span>Browser</span></div>
|
|
323
|
+
<div class="seg-control">
|
|
324
|
+
{#each BROWSERS as b}
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
class="seg-btn"
|
|
328
|
+
class:active={form.browser === b.id}
|
|
329
|
+
on:click={() => (form.browser = b.id)}
|
|
330
|
+
>
|
|
331
|
+
<span class="seg-num">{b.label}</span>
|
|
332
|
+
</button>
|
|
333
|
+
{/each}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div class="field">
|
|
338
|
+
<div class="field-label">
|
|
339
|
+
<span>Runners</span>
|
|
340
|
+
<span class="field-hint">Select one or more nodes</span>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="runner-checks">
|
|
343
|
+
<label class="runner-check-option">
|
|
344
|
+
<input
|
|
345
|
+
type="checkbox"
|
|
346
|
+
checked={form.runnerIds.includes('built-in')}
|
|
347
|
+
on:change={() => toggleFormRunner('built-in')}
|
|
348
|
+
/>
|
|
349
|
+
<span class="runner-check-dot built-in"></span>
|
|
350
|
+
<span>Built-in</span>
|
|
351
|
+
<span class="runner-check-hint">this server</span>
|
|
352
|
+
</label>
|
|
353
|
+
{#each availableRunners as r}
|
|
354
|
+
<label class="runner-check-option">
|
|
355
|
+
<input
|
|
356
|
+
type="checkbox"
|
|
357
|
+
checked={form.runnerIds.includes(r.id)}
|
|
358
|
+
on:change={() => toggleFormRunner(r.id)}
|
|
359
|
+
/>
|
|
360
|
+
<span class="runner-check-dot remote"></span>
|
|
361
|
+
<span>{r.name}</span>
|
|
362
|
+
<span class="runner-check-hint">{r.url}</span>
|
|
363
|
+
</label>
|
|
364
|
+
{/each}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
276
368
|
{#if formError}
|
|
277
369
|
<p class="form-error">{formError}</p>
|
|
278
370
|
{/if}
|
|
279
371
|
|
|
280
|
-
<Button type="submit" size="md">
|
|
281
|
-
{isEditing ? 'Save Changes' : 'Add Cron Job'}
|
|
282
|
-
</Button>
|
|
372
|
+
<Button type="submit" size="md">{isEditing ? 'Save Changes' : 'Add Cron Job'}</Button>
|
|
283
373
|
</form>
|
|
284
374
|
</Modal>
|
|
285
375
|
|
|
286
|
-
<!-- Delete confirmation
|
|
287
|
-
<
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
<Button variant="danger" on:click={handleDelete}>Delete</Button>
|
|
293
|
-
<Button variant="ghost" on:click={() => (deleteModalOpen = false)}>Cancel</Button>
|
|
294
|
-
</div>
|
|
295
|
-
</Modal>
|
|
376
|
+
<!-- Delete confirmation -->
|
|
377
|
+
<ConfirmModal bind:open={deleteModalOpen} title="Delete Cron Job" on:confirm={handleDelete}>
|
|
378
|
+
Are you sure you want to delete <strong>{taskToDelete}</strong>? This cannot be undone.
|
|
379
|
+
</ConfirmModal>
|
|
380
|
+
|
|
381
|
+
<Toast {toast} />
|
|
296
382
|
|
|
297
|
-
<!-- Page -->
|
|
298
383
|
<div class="page-header">
|
|
299
384
|
<div class="header-row">
|
|
300
385
|
<div>
|
|
@@ -305,51 +390,86 @@
|
|
|
305
390
|
</div>
|
|
306
391
|
</div>
|
|
307
392
|
|
|
308
|
-
{#if toast}
|
|
309
|
-
<div class="toast alert alert-{toast.type}" transition:fly={{ y: -8, duration: 240 }}>
|
|
310
|
-
{toast.message}
|
|
311
|
-
</div>
|
|
312
|
-
{/if}
|
|
313
|
-
|
|
314
393
|
<div class="card" style="padding: 0; overflow: hidden;">
|
|
315
394
|
{#if cronJobs.length === 0}
|
|
316
|
-
<
|
|
395
|
+
<EmptyState message="No scheduled tests yet. Create one to get started." size="sm" />
|
|
317
396
|
{:else}
|
|
318
397
|
<div class="table-wrap">
|
|
319
398
|
<table class="data-table">
|
|
320
399
|
<thead>
|
|
321
400
|
<tr>
|
|
401
|
+
<th class="th-toggle"></th>
|
|
402
|
+
<th class="th-run"></th>
|
|
322
403
|
<th>Name</th>
|
|
323
404
|
<th>Schedule</th>
|
|
324
405
|
<th>Tags</th>
|
|
325
|
-
<th>
|
|
406
|
+
<th>Workers</th>
|
|
407
|
+
<th>Browser</th>
|
|
408
|
+
<th>Runner</th>
|
|
326
409
|
<th>Actions</th>
|
|
327
410
|
</tr>
|
|
328
411
|
</thead>
|
|
329
412
|
<tbody>
|
|
330
413
|
{#each cronJobs as job, i}
|
|
331
|
-
|
|
414
|
+
{@const ids = job.runnerIds
|
|
415
|
+
? job.runnerIds.split(',').map((s) => s.trim())
|
|
416
|
+
: ['built-in']}
|
|
417
|
+
<tr style={stagger(i)} class="job-row" class:disabled={!job.enabled}>
|
|
418
|
+
<td class="td-toggle">
|
|
419
|
+
<button
|
|
420
|
+
class="toggle-btn"
|
|
421
|
+
class:on={job.enabled}
|
|
422
|
+
on:click={() => handleToggle(job)}
|
|
423
|
+
title={job.enabled ? 'Disable schedule' : 'Enable schedule'}
|
|
424
|
+
aria-label={job.enabled ? 'Disable schedule' : 'Enable schedule'}
|
|
425
|
+
aria-pressed={job.enabled}
|
|
426
|
+
>
|
|
427
|
+
<span class="toggle-knob"></span>
|
|
428
|
+
</button>
|
|
429
|
+
</td>
|
|
430
|
+
<td class="td-run">
|
|
431
|
+
<button
|
|
432
|
+
class="run-icon-btn"
|
|
433
|
+
on:click={() => handleRunNow(job.taskName)}
|
|
434
|
+
title="Run {job.taskName} now"
|
|
435
|
+
>
|
|
436
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
|
437
|
+
<polygon points="5,3 19,12 5,21" />
|
|
438
|
+
</svg>
|
|
439
|
+
</button>
|
|
440
|
+
</td>
|
|
332
441
|
<td class="job-name">
|
|
333
442
|
{#if $activeCronJobs[job.taskName]}
|
|
334
443
|
<span class="running-dot" title="Running now"></span>
|
|
335
444
|
{/if}
|
|
336
445
|
{job.taskName}
|
|
337
446
|
</td>
|
|
447
|
+
<td><Badge variant="schedule">{cronLabel(job.cronExpression)}</Badge></td>
|
|
448
|
+
<td><span class="tag-text">{job.tags}</span></td>
|
|
338
449
|
<td>
|
|
339
|
-
<
|
|
340
|
-
</td>
|
|
341
|
-
<td>
|
|
342
|
-
<span class="tag-text">{job.tags}</span>
|
|
450
|
+
<span class="workers-badge" class:multi={job.workers > 1}>×{job.workers}</span>
|
|
343
451
|
</td>
|
|
452
|
+
<td><span class="browser-badge">{job.browser ?? 'chromium'}</span></td>
|
|
344
453
|
<td>
|
|
345
|
-
<span class="
|
|
346
|
-
|
|
454
|
+
<span class="runner-badge" class:multi-node={ids.length > 1}>
|
|
455
|
+
{#if ids.length === 1 && ids[0] === 'built-in'}
|
|
456
|
+
built-in
|
|
457
|
+
{:else if ids.length === 1}
|
|
458
|
+
{availableRunners.find((r) => r.id === ids[0])?.name ?? ids[0]}
|
|
459
|
+
{:else}
|
|
460
|
+
{ids.length} nodes
|
|
461
|
+
{/if}
|
|
347
462
|
</span>
|
|
348
463
|
</td>
|
|
349
464
|
<td class="actions-cell">
|
|
350
465
|
<Button variant="ghost" size="sm" on:click={() => openEditModal(job)}>Edit</Button>
|
|
351
|
-
<Button
|
|
352
|
-
|
|
466
|
+
<Button
|
|
467
|
+
variant="danger"
|
|
468
|
+
size="sm"
|
|
469
|
+
on:click={() => {
|
|
470
|
+
taskToDelete = job.taskName;
|
|
471
|
+
deleteModalOpen = true;
|
|
472
|
+
}}>Delete</Button
|
|
353
473
|
>
|
|
354
474
|
</td>
|
|
355
475
|
</tr>
|
|
@@ -388,11 +508,6 @@
|
|
|
388
508
|
animation: fadeUp 0.32s var(--ease-out) both;
|
|
389
509
|
}
|
|
390
510
|
|
|
391
|
-
.toast {
|
|
392
|
-
margin-bottom: 1.25rem;
|
|
393
|
-
border-radius: var(--radius-md);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
511
|
.table-wrap {
|
|
397
512
|
overflow-x: auto;
|
|
398
513
|
}
|
|
@@ -433,48 +548,104 @@
|
|
|
433
548
|
color: var(--text-muted);
|
|
434
549
|
}
|
|
435
550
|
|
|
436
|
-
.
|
|
437
|
-
|
|
438
|
-
|
|
551
|
+
.th-toggle,
|
|
552
|
+
.td-toggle {
|
|
553
|
+
width: 44px;
|
|
554
|
+
padding-right: 0;
|
|
555
|
+
text-align: center;
|
|
556
|
+
padding-top: 20px;
|
|
439
557
|
}
|
|
440
558
|
|
|
441
|
-
.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
559
|
+
.th-run,
|
|
560
|
+
.td-run {
|
|
561
|
+
width: 36px;
|
|
562
|
+
padding-right: 0;
|
|
445
563
|
text-align: center;
|
|
446
564
|
}
|
|
447
565
|
|
|
448
|
-
/*
|
|
449
|
-
.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
566
|
+
/* Toggle switch */
|
|
567
|
+
.toggle-btn {
|
|
568
|
+
position: relative;
|
|
569
|
+
display: inline-flex;
|
|
570
|
+
align-items: center;
|
|
571
|
+
width: 32px;
|
|
572
|
+
height: 18px;
|
|
573
|
+
border-radius: 9px;
|
|
574
|
+
border: none;
|
|
575
|
+
background: var(--border);
|
|
576
|
+
cursor: pointer;
|
|
577
|
+
transition: background var(--duration-fast);
|
|
578
|
+
flex-shrink: 0;
|
|
579
|
+
padding: 0;
|
|
453
580
|
}
|
|
454
581
|
|
|
455
|
-
.
|
|
456
|
-
|
|
457
|
-
color: var(--fail);
|
|
582
|
+
.toggle-btn.on {
|
|
583
|
+
background: var(--pass);
|
|
458
584
|
}
|
|
459
585
|
|
|
460
|
-
.
|
|
461
|
-
|
|
586
|
+
.toggle-knob {
|
|
587
|
+
position: absolute;
|
|
588
|
+
left: 2px;
|
|
589
|
+
width: 14px;
|
|
590
|
+
height: 14px;
|
|
591
|
+
border-radius: 50%;
|
|
592
|
+
background: #fff;
|
|
593
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
594
|
+
transition: left var(--duration-fast);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.toggle-btn.on .toggle-knob {
|
|
598
|
+
left: 16px;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.job-row.disabled {
|
|
602
|
+
opacity: 0.45;
|
|
603
|
+
}
|
|
604
|
+
.job-row.disabled .toggle-btn {
|
|
605
|
+
opacity: 1;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.run-icon-btn {
|
|
609
|
+
display: flex;
|
|
610
|
+
align-items: center;
|
|
611
|
+
justify-content: center;
|
|
612
|
+
width: 24px;
|
|
613
|
+
height: 24px;
|
|
614
|
+
border-radius: 50%;
|
|
615
|
+
border: 1px solid var(--border);
|
|
616
|
+
background: transparent;
|
|
462
617
|
color: var(--text-muted);
|
|
463
|
-
|
|
618
|
+
cursor: pointer;
|
|
619
|
+
flex-shrink: 0;
|
|
620
|
+
transition:
|
|
621
|
+
background var(--duration-fast),
|
|
622
|
+
color var(--duration-fast),
|
|
623
|
+
border-color var(--duration-fast);
|
|
464
624
|
}
|
|
465
625
|
|
|
466
|
-
.
|
|
467
|
-
|
|
468
|
-
|
|
626
|
+
.run-icon-btn:hover {
|
|
627
|
+
background: var(--accent);
|
|
628
|
+
color: #fff;
|
|
629
|
+
border-color: var(--accent);
|
|
469
630
|
}
|
|
470
631
|
|
|
471
|
-
.
|
|
632
|
+
.actions-cell {
|
|
472
633
|
display: flex;
|
|
473
|
-
gap: 0.
|
|
474
|
-
|
|
634
|
+
gap: 0.375rem;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/* Modal form */
|
|
638
|
+
.modal-form {
|
|
639
|
+
display: flex;
|
|
640
|
+
flex-direction: column;
|
|
641
|
+
gap: 1rem;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
.form-error {
|
|
645
|
+
font-size: 0.8125rem;
|
|
646
|
+
color: var(--fail);
|
|
475
647
|
}
|
|
476
648
|
|
|
477
|
-
/* Custom cron input */
|
|
478
649
|
.cron-input {
|
|
479
650
|
margin-top: 0.5rem;
|
|
480
651
|
font-family: 'JetBrains Mono', monospace;
|
|
@@ -534,22 +705,19 @@
|
|
|
534
705
|
.seg-btn:last-child {
|
|
535
706
|
border-right: none;
|
|
536
707
|
}
|
|
537
|
-
|
|
538
708
|
.seg-btn:hover {
|
|
539
709
|
background: var(--bg-subtle);
|
|
540
710
|
color: var(--text);
|
|
541
711
|
}
|
|
542
|
-
|
|
543
712
|
.seg-btn.active {
|
|
544
713
|
background: var(--accent);
|
|
545
714
|
color: #fff;
|
|
546
715
|
}
|
|
547
|
-
|
|
548
716
|
.seg-btn.active .seg-label {
|
|
549
717
|
opacity: 0.85;
|
|
550
718
|
}
|
|
551
719
|
|
|
552
|
-
/*
|
|
720
|
+
/* Table badges */
|
|
553
721
|
.workers-badge {
|
|
554
722
|
font-family: 'JetBrains Mono', monospace;
|
|
555
723
|
font-size: 0.75rem;
|
|
@@ -560,4 +728,76 @@
|
|
|
560
728
|
color: var(--accent);
|
|
561
729
|
font-weight: 500;
|
|
562
730
|
}
|
|
731
|
+
|
|
732
|
+
.browser-badge,
|
|
733
|
+
.runner-badge {
|
|
734
|
+
font-family: 'JetBrains Mono', monospace;
|
|
735
|
+
font-size: 0.72rem;
|
|
736
|
+
color: var(--text-muted);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.runner-badge.multi-node {
|
|
740
|
+
color: var(--node);
|
|
741
|
+
font-weight: 500;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/* Runner checkboxes in modal */
|
|
745
|
+
.runner-checks {
|
|
746
|
+
display: flex;
|
|
747
|
+
flex-direction: column;
|
|
748
|
+
gap: 0.25rem;
|
|
749
|
+
border: 1px solid var(--border);
|
|
750
|
+
border-radius: var(--radius-sm);
|
|
751
|
+
padding: 0.375rem 0.5rem;
|
|
752
|
+
background: var(--bg-subtle);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.runner-check-option {
|
|
756
|
+
display: flex;
|
|
757
|
+
align-items: center;
|
|
758
|
+
gap: 0.5rem;
|
|
759
|
+
padding: 0.3rem 0.375rem;
|
|
760
|
+
border-radius: var(--radius-sm);
|
|
761
|
+
cursor: pointer;
|
|
762
|
+
font-size: 0.8125rem;
|
|
763
|
+
color: var(--text);
|
|
764
|
+
transition: background var(--duration-fast);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.runner-check-option:hover {
|
|
768
|
+
background: var(--bg-elevated);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.runner-check-option input[type='checkbox'] {
|
|
772
|
+
accent-color: var(--accent);
|
|
773
|
+
width: 13px;
|
|
774
|
+
height: 13px;
|
|
775
|
+
flex-shrink: 0;
|
|
776
|
+
cursor: pointer;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.runner-check-dot {
|
|
780
|
+
width: 6px;
|
|
781
|
+
height: 6px;
|
|
782
|
+
border-radius: 50%;
|
|
783
|
+
flex-shrink: 0;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
.runner-check-dot.built-in {
|
|
787
|
+
background: var(--accent);
|
|
788
|
+
}
|
|
789
|
+
.runner-check-dot.remote {
|
|
790
|
+
background: var(--node);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
.runner-check-hint {
|
|
794
|
+
margin-left: auto;
|
|
795
|
+
font-size: 0.7rem;
|
|
796
|
+
font-family: 'JetBrains Mono', monospace;
|
|
797
|
+
color: var(--text-muted);
|
|
798
|
+
white-space: nowrap;
|
|
799
|
+
overflow: hidden;
|
|
800
|
+
text-overflow: ellipsis;
|
|
801
|
+
max-width: 180px;
|
|
802
|
+
}
|
|
563
803
|
</style>
|