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