plum-e2e 1.2.3 → 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 +191 -47
- 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 +2 -2
- package/frontend/static/favicon.png +0 -0
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
import { slide } from 'svelte/transition';
|
|
21
21
|
import { fetchSuites } from '$lib/api/tests';
|
|
22
22
|
import { runnerConfig, triggerRun, testsVersion } from '$lib/stores/runner';
|
|
23
|
+
import { COPY_TIMEOUT_MS } from '$lib/constants';
|
|
24
|
+
import { stagger } from '$lib/utils/format';
|
|
25
|
+
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
23
26
|
|
|
24
27
|
let suites = [];
|
|
25
28
|
let search = '';
|
|
@@ -36,8 +39,6 @@
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
onMount(loadSuites);
|
|
39
|
-
|
|
40
|
-
// Re-fetch when the backend notifies us that test files changed
|
|
41
42
|
$: if ($testsVersion) loadSuites();
|
|
42
43
|
|
|
43
44
|
function suiteIds(suite) {
|
|
@@ -53,11 +54,8 @@
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function toggleSteps(id) {
|
|
56
|
-
if (expandedSteps.has(id))
|
|
57
|
-
|
|
58
|
-
} else {
|
|
59
|
-
expandedSteps.add(id);
|
|
60
|
-
}
|
|
57
|
+
if (expandedSteps.has(id)) expandedSteps.delete(id);
|
|
58
|
+
else expandedSteps.add(id);
|
|
61
59
|
expandedSteps = expandedSteps;
|
|
62
60
|
}
|
|
63
61
|
|
|
@@ -67,15 +65,13 @@
|
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
function runSuite(suite) {
|
|
70
|
-
|
|
71
|
-
run(id);
|
|
68
|
+
run(suiteIds(suite)[0]);
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
async function copyId(id) {
|
|
75
72
|
await navigator.clipboard.writeText(id);
|
|
76
73
|
copiedIds.add(id);
|
|
77
74
|
copiedIds = copiedIds;
|
|
78
|
-
|
|
79
75
|
if (copyTimers.has(id)) clearTimeout(copyTimers.get(id));
|
|
80
76
|
copyTimers.set(
|
|
81
77
|
id,
|
|
@@ -83,7 +79,7 @@
|
|
|
83
79
|
copiedIds.delete(id);
|
|
84
80
|
copiedIds = copiedIds;
|
|
85
81
|
copyTimers.delete(id);
|
|
86
|
-
},
|
|
82
|
+
}, COPY_TIMEOUT_MS)
|
|
87
83
|
);
|
|
88
84
|
}
|
|
89
85
|
|
|
@@ -166,13 +162,11 @@
|
|
|
166
162
|
</div>
|
|
167
163
|
|
|
168
164
|
{#if filtered.length === 0}
|
|
169
|
-
<
|
|
170
|
-
{q ? `No tests matching "${search}"` : 'No test suites found.'}
|
|
171
|
-
</p>
|
|
165
|
+
<EmptyState message={q ? `No tests matching "${search}"` : 'No test suites found.'} />
|
|
172
166
|
{:else}
|
|
173
167
|
<div class="suites">
|
|
174
168
|
{#each filtered as suite, si}
|
|
175
|
-
<div class="suite" style=
|
|
169
|
+
<div class="suite" style={stagger(si, 55)}>
|
|
176
170
|
<div class="suite-header">
|
|
177
171
|
<div class="suite-meta">
|
|
178
172
|
<div class="suite-badges">
|
|
@@ -205,7 +199,7 @@
|
|
|
205
199
|
{#each suite.tests as test, ti}
|
|
206
200
|
{@const pid = primaryId(test)}
|
|
207
201
|
{@const stepsOpen = expandedSteps.has(pid)}
|
|
208
|
-
<div class="test-row" style=
|
|
202
|
+
<div class="test-row" style={stagger(si * 4 + ti, 30)}>
|
|
209
203
|
<div class="test-main">
|
|
210
204
|
<button
|
|
211
205
|
class="run-icon-btn"
|
|
@@ -276,19 +270,15 @@
|
|
|
276
270
|
<div class="examples-table-wrap">
|
|
277
271
|
<table class="examples-table">
|
|
278
272
|
<thead>
|
|
279
|
-
<tr
|
|
280
|
-
{#each test.examples.headers as h}
|
|
281
|
-
|
|
282
|
-
{/each}
|
|
283
|
-
</tr>
|
|
273
|
+
<tr
|
|
274
|
+
>{#each test.examples.headers as h}<th>{h}</th>{/each}</tr
|
|
275
|
+
>
|
|
284
276
|
</thead>
|
|
285
277
|
<tbody>
|
|
286
278
|
{#each test.examples.rows as row}
|
|
287
|
-
<tr
|
|
288
|
-
{#each row as cell}
|
|
289
|
-
|
|
290
|
-
{/each}
|
|
291
|
-
</tr>
|
|
279
|
+
<tr
|
|
280
|
+
>{#each row as cell}<td>{cell}</td>{/each}</tr
|
|
281
|
+
>
|
|
292
282
|
{/each}
|
|
293
283
|
</tbody>
|
|
294
284
|
</table>
|
|
@@ -557,7 +547,6 @@
|
|
|
557
547
|
.id-pill:hover {
|
|
558
548
|
filter: brightness(0.92);
|
|
559
549
|
}
|
|
560
|
-
|
|
561
550
|
.id-pill.copied {
|
|
562
551
|
color: var(--pass);
|
|
563
552
|
background: var(--pass-soft);
|
|
@@ -632,7 +621,6 @@
|
|
|
632
621
|
.steps-toggle svg {
|
|
633
622
|
transition: transform var(--duration-fast) var(--ease-out);
|
|
634
623
|
}
|
|
635
|
-
|
|
636
624
|
.steps-toggle svg.rotated {
|
|
637
625
|
transform: rotate(90deg);
|
|
638
626
|
}
|
|
@@ -718,13 +706,6 @@
|
|
|
718
706
|
border-bottom: none;
|
|
719
707
|
}
|
|
720
708
|
|
|
721
|
-
.empty {
|
|
722
|
-
color: var(--text-muted);
|
|
723
|
-
font-size: 0.9375rem;
|
|
724
|
-
padding: 3rem 0;
|
|
725
|
-
text-align: center;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
709
|
@keyframes copiedPop {
|
|
729
710
|
0% {
|
|
730
711
|
opacity: 0;
|
|
@@ -17,42 +17,35 @@
|
|
|
17
17
|
|
|
18
18
|
<script>
|
|
19
19
|
import { onMount, tick } from 'svelte';
|
|
20
|
-
import { fetchReports, deleteReport, deleteReports } from '$lib/api/reports';
|
|
20
|
+
import { fetchReports, deleteReport, deleteReports, reportUrl } from '$lib/api/reports';
|
|
21
21
|
import { reportsVersion } from '$lib/stores/runner';
|
|
22
|
+
import { REPORTS_PER_PAGE } from '$lib/constants';
|
|
23
|
+
import { isScheduled, triggerLabel, triggerVariant, stagger } from '$lib/utils/format';
|
|
22
24
|
import Badge from '$lib/components/ui/Badge.svelte';
|
|
23
25
|
import Pagination from '$lib/components/ui/Pagination.svelte';
|
|
24
|
-
import
|
|
26
|
+
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
27
|
+
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
25
28
|
|
|
26
29
|
let reports = [];
|
|
27
30
|
let currentPage = 1;
|
|
28
|
-
const PER_PAGE = 15;
|
|
29
31
|
let animateBar = false;
|
|
30
32
|
|
|
31
33
|
let selected = new Set();
|
|
32
34
|
let deleteModal = { open: false, targets: [] };
|
|
33
35
|
let deleting = false;
|
|
34
36
|
|
|
35
|
-
$: totalPages = Math.ceil(reports.length /
|
|
36
|
-
$: paginated = reports.slice(
|
|
37
|
+
$: totalPages = Math.ceil(reports.length / REPORTS_PER_PAGE);
|
|
38
|
+
$: paginated = reports.slice(
|
|
39
|
+
(currentPage - 1) * REPORTS_PER_PAGE,
|
|
40
|
+
currentPage * REPORTS_PER_PAGE
|
|
41
|
+
);
|
|
37
42
|
$: passCount = reports.filter((r) => r.status === 'PASS').length;
|
|
38
43
|
$: failCount = reports.length - passCount;
|
|
39
44
|
$: passRate = reports.length ? Math.round((passCount / reports.length) * 100) : 0;
|
|
40
45
|
$: trend = reports.slice(0, 12).reverse();
|
|
41
|
-
$: allOnPageSelected = paginated.length > 0 && paginated.every((r) => selected.has(r.
|
|
46
|
+
$: allOnPageSelected = paginated.length > 0 && paginated.every((r) => selected.has(r.id));
|
|
42
47
|
$: someSelected = selected.size > 0;
|
|
43
48
|
|
|
44
|
-
function triggerLabel(type) {
|
|
45
|
-
if (type === 'manual-trigger') return 'Manual';
|
|
46
|
-
if (type === 'command-line-trigger' || type === 'undefined') return 'CLI';
|
|
47
|
-
return 'Scheduled';
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function triggerVariant(type) {
|
|
51
|
-
if (type === 'manual-trigger') return 'tag';
|
|
52
|
-
if (type === 'command-line-trigger' || type === 'undefined') return 'neutral';
|
|
53
|
-
return 'schedule';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
49
|
async function loadReports() {
|
|
57
50
|
try {
|
|
58
51
|
reports = await fetchReports();
|
|
@@ -67,12 +60,12 @@
|
|
|
67
60
|
onMount(loadReports);
|
|
68
61
|
$: if ($reportsVersion) loadReports();
|
|
69
62
|
|
|
70
|
-
function toggleSelect(
|
|
63
|
+
function toggleSelect(id, e) {
|
|
71
64
|
e.preventDefault();
|
|
72
65
|
e.stopPropagation();
|
|
73
66
|
const next = new Set(selected);
|
|
74
|
-
if (next.has(
|
|
75
|
-
else next.add(
|
|
67
|
+
if (next.has(id)) next.delete(id);
|
|
68
|
+
else next.add(id);
|
|
76
69
|
selected = next;
|
|
77
70
|
}
|
|
78
71
|
|
|
@@ -80,11 +73,11 @@
|
|
|
80
73
|
e.stopPropagation();
|
|
81
74
|
if (allOnPageSelected) {
|
|
82
75
|
const next = new Set(selected);
|
|
83
|
-
paginated.forEach((r) => next.delete(r.
|
|
76
|
+
paginated.forEach((r) => next.delete(r.id));
|
|
84
77
|
selected = next;
|
|
85
78
|
} else {
|
|
86
79
|
const next = new Set(selected);
|
|
87
|
-
paginated.forEach((r) => next.add(r.
|
|
80
|
+
paginated.forEach((r) => next.add(r.id));
|
|
88
81
|
selected = next;
|
|
89
82
|
}
|
|
90
83
|
}
|
|
@@ -93,10 +86,10 @@
|
|
|
93
86
|
deleteModal = { open: true, targets };
|
|
94
87
|
}
|
|
95
88
|
|
|
96
|
-
function openSingleDelete(
|
|
89
|
+
function openSingleDelete(id, e) {
|
|
97
90
|
e.preventDefault();
|
|
98
91
|
e.stopPropagation();
|
|
99
|
-
openDeleteModal([
|
|
92
|
+
openDeleteModal([id]);
|
|
100
93
|
}
|
|
101
94
|
|
|
102
95
|
async function confirmDelete() {
|
|
@@ -105,7 +98,7 @@
|
|
|
105
98
|
if (deleteModal.targets.length === 1) {
|
|
106
99
|
await deleteReport(deleteModal.targets[0]);
|
|
107
100
|
} else {
|
|
108
|
-
await deleteReports(deleteModal.targets);
|
|
101
|
+
await deleteReports([...deleteModal.targets]);
|
|
109
102
|
}
|
|
110
103
|
deleteModal = { open: false, targets: [] };
|
|
111
104
|
await loadReports();
|
|
@@ -119,28 +112,21 @@
|
|
|
119
112
|
|
|
120
113
|
<svelte:head><title>Reports — Plum</title></svelte:head>
|
|
121
114
|
|
|
122
|
-
<
|
|
115
|
+
<ConfirmModal
|
|
123
116
|
bind:open={deleteModal.open}
|
|
124
117
|
title={deleteModal.targets.length === 1
|
|
125
118
|
? 'Delete report?'
|
|
126
119
|
: `Delete ${deleteModal.targets.length} reports?`}
|
|
120
|
+
confirmLabel="Delete"
|
|
121
|
+
loading={deleting}
|
|
122
|
+
on:confirm={confirmDelete}
|
|
127
123
|
>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
</p>
|
|
135
|
-
<div class="modal-actions">
|
|
136
|
-
<button class="btn-cancel" on:click={() => (deleteModal.open = false)} disabled={deleting}>
|
|
137
|
-
Cancel
|
|
138
|
-
</button>
|
|
139
|
-
<button class="btn-danger" on:click={confirmDelete} disabled={deleting}>
|
|
140
|
-
{deleting ? 'Deleting…' : 'Delete'}
|
|
141
|
-
</button>
|
|
142
|
-
</div>
|
|
143
|
-
</Modal>
|
|
124
|
+
{#if deleteModal.targets.length === 1}
|
|
125
|
+
This will permanently remove the report and its data file.
|
|
126
|
+
{:else}
|
|
127
|
+
This will permanently remove {deleteModal.targets.length} reports and their data files.
|
|
128
|
+
{/if}
|
|
129
|
+
</ConfirmModal>
|
|
144
130
|
|
|
145
131
|
<div class="page-header">
|
|
146
132
|
<div class="header-top">
|
|
@@ -151,27 +137,19 @@
|
|
|
151
137
|
</p>
|
|
152
138
|
</div>
|
|
153
139
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
class:fail={passRate < 50}
|
|
168
|
-
>
|
|
169
|
-
{passRate}%
|
|
170
|
-
</span>
|
|
171
|
-
<span class="rate-label">passing</span>
|
|
172
|
-
</div>
|
|
173
|
-
{/if}
|
|
174
|
-
</div>
|
|
140
|
+
{#if reports.length > 0}
|
|
141
|
+
<div class="rate-display">
|
|
142
|
+
<span
|
|
143
|
+
class="rate-number"
|
|
144
|
+
class:pass={passRate >= 80}
|
|
145
|
+
class:warn={passRate < 80 && passRate >= 50}
|
|
146
|
+
class:fail={passRate < 50}
|
|
147
|
+
>
|
|
148
|
+
{passRate}%
|
|
149
|
+
</span>
|
|
150
|
+
<span class="rate-label">passing</span>
|
|
151
|
+
</div>
|
|
152
|
+
{/if}
|
|
175
153
|
</div>
|
|
176
154
|
|
|
177
155
|
{#if reports.length > 0}
|
|
@@ -193,7 +171,7 @@
|
|
|
193
171
|
class="trend-dot"
|
|
194
172
|
class:pass={r.status === 'PASS'}
|
|
195
173
|
class:fail={r.status !== 'PASS'}
|
|
196
|
-
style=
|
|
174
|
+
style={stagger(i, 35)}
|
|
197
175
|
title="{r.status} · {r.tags} · {r.date}"
|
|
198
176
|
></span>
|
|
199
177
|
{/each}
|
|
@@ -204,7 +182,7 @@
|
|
|
204
182
|
</div>
|
|
205
183
|
|
|
206
184
|
{#if reports.length === 0}
|
|
207
|
-
<
|
|
185
|
+
<EmptyState message="No reports yet. Run a test to generate one." />
|
|
208
186
|
{:else}
|
|
209
187
|
<div class="list-header">
|
|
210
188
|
<label class="select-all-wrap" title="Select all on this page">
|
|
@@ -216,21 +194,22 @@
|
|
|
216
194
|
on:change={toggleAll}
|
|
217
195
|
/>
|
|
218
196
|
</label>
|
|
197
|
+
{#if someSelected}
|
|
198
|
+
<button class="btn-delete-selected" on:click={() => openDeleteModal([...selected])}>
|
|
199
|
+
Delete ({selected.size})
|
|
200
|
+
</button>
|
|
201
|
+
{/if}
|
|
219
202
|
</div>
|
|
220
203
|
|
|
221
204
|
<div class="report-list">
|
|
222
205
|
{#each paginated as report, i}
|
|
223
|
-
<div
|
|
224
|
-
class="report-row"
|
|
225
|
-
class:is-selected={selected.has(report.fileName)}
|
|
226
|
-
style="animation-delay: {i * 45}ms"
|
|
227
|
-
>
|
|
206
|
+
<div class="report-row" class:is-selected={selected.has(report.id)} style={stagger(i)}>
|
|
228
207
|
<label class="row-check-wrap" title="Select">
|
|
229
208
|
<input
|
|
230
209
|
type="checkbox"
|
|
231
210
|
class="checkbox"
|
|
232
|
-
checked={selected.has(report.
|
|
233
|
-
on:change={(e) => toggleSelect(report.
|
|
211
|
+
checked={selected.has(report.id)}
|
|
212
|
+
on:change={(e) => toggleSelect(report.id, e)}
|
|
234
213
|
/>
|
|
235
214
|
</label>
|
|
236
215
|
|
|
@@ -238,7 +217,7 @@
|
|
|
238
217
|
class="report-item"
|
|
239
218
|
class:is-pass={report.status === 'PASS'}
|
|
240
219
|
class:is-fail={report.status !== 'PASS'}
|
|
241
|
-
href=
|
|
220
|
+
href={reportUrl(report.id)}
|
|
242
221
|
>
|
|
243
222
|
<div class="item-left">
|
|
244
223
|
<span
|
|
@@ -249,14 +228,16 @@
|
|
|
249
228
|
{report.status === 'PASS' ? '✓' : '✗'}
|
|
250
229
|
</span>
|
|
251
230
|
<div class="item-meta">
|
|
252
|
-
<span class="item-tags"
|
|
231
|
+
<span class="item-tags"
|
|
232
|
+
>{isScheduled(report.triggerType) ? report.triggerType : report.tags}</span
|
|
233
|
+
>
|
|
253
234
|
<div class="item-badges">
|
|
254
235
|
<Badge variant={triggerVariant(report.triggerType)}>
|
|
255
236
|
{triggerLabel(report.triggerType)}
|
|
256
237
|
</Badge>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
238
|
+
{#if report.browser && report.browser !== 'chromium'}
|
|
239
|
+
<Badge variant="neutral">{report.browser}</Badge>
|
|
240
|
+
{/if}
|
|
260
241
|
</div>
|
|
261
242
|
</div>
|
|
262
243
|
</div>
|
|
@@ -281,7 +262,7 @@
|
|
|
281
262
|
<button
|
|
282
263
|
class="row-delete-btn"
|
|
283
264
|
title="Delete report"
|
|
284
|
-
on:click={(e) => openSingleDelete(report.
|
|
265
|
+
on:click={(e) => openSingleDelete(report.id, e)}
|
|
285
266
|
>
|
|
286
267
|
<svg
|
|
287
268
|
width="14"
|
|
@@ -339,33 +320,6 @@
|
|
|
339
320
|
font-size: 0.875rem;
|
|
340
321
|
}
|
|
341
322
|
|
|
342
|
-
.header-actions {
|
|
343
|
-
display: flex;
|
|
344
|
-
align-items: center;
|
|
345
|
-
gap: 1rem;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
.btn-delete-selected {
|
|
349
|
-
height: 32px;
|
|
350
|
-
padding: 0 0.875rem;
|
|
351
|
-
font-size: 0.8125rem;
|
|
352
|
-
font-family: inherit;
|
|
353
|
-
font-weight: 500;
|
|
354
|
-
color: var(--fail);
|
|
355
|
-
background: var(--fail-soft, rgba(239, 68, 68, 0.08));
|
|
356
|
-
border: 1px solid var(--fail);
|
|
357
|
-
border-radius: var(--radius-sm);
|
|
358
|
-
cursor: pointer;
|
|
359
|
-
transition:
|
|
360
|
-
background var(--duration-fast),
|
|
361
|
-
opacity var(--duration-fast);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
.btn-delete-selected:hover {
|
|
365
|
-
background: var(--fail);
|
|
366
|
-
color: #fff;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
323
|
/* ── Pass rate ── */
|
|
370
324
|
.rate-display {
|
|
371
325
|
display: flex;
|
|
@@ -422,6 +376,7 @@
|
|
|
422
376
|
display: flex;
|
|
423
377
|
gap: 1rem;
|
|
424
378
|
}
|
|
379
|
+
|
|
425
380
|
.legend-pass {
|
|
426
381
|
font-size: 0.75rem;
|
|
427
382
|
color: var(--pass);
|
|
@@ -479,10 +434,11 @@
|
|
|
479
434
|
opacity: 0.6;
|
|
480
435
|
}
|
|
481
436
|
|
|
482
|
-
/* ── Select
|
|
437
|
+
/* ── Select-all row ── */
|
|
483
438
|
.list-header {
|
|
484
439
|
display: flex;
|
|
485
440
|
align-items: center;
|
|
441
|
+
justify-content: space-between;
|
|
486
442
|
padding: 0 0.5rem 0.375rem;
|
|
487
443
|
}
|
|
488
444
|
|
|
@@ -493,6 +449,27 @@
|
|
|
493
449
|
padding: 0.25rem;
|
|
494
450
|
}
|
|
495
451
|
|
|
452
|
+
.btn-delete-selected {
|
|
453
|
+
height: 30px;
|
|
454
|
+
padding: 0 0.75rem;
|
|
455
|
+
font-size: 0.78rem;
|
|
456
|
+
font-family: inherit;
|
|
457
|
+
font-weight: 500;
|
|
458
|
+
color: var(--fail);
|
|
459
|
+
background: var(--fail-soft, rgba(239, 68, 68, 0.08));
|
|
460
|
+
border: 1px solid var(--fail);
|
|
461
|
+
border-radius: var(--radius-sm);
|
|
462
|
+
cursor: pointer;
|
|
463
|
+
transition:
|
|
464
|
+
background var(--duration-fast),
|
|
465
|
+
opacity var(--duration-fast);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.btn-delete-selected:hover {
|
|
469
|
+
background: var(--fail);
|
|
470
|
+
color: #fff;
|
|
471
|
+
}
|
|
472
|
+
|
|
496
473
|
/* ── Report rows ── */
|
|
497
474
|
.report-list {
|
|
498
475
|
display: flex;
|
|
@@ -654,68 +631,8 @@
|
|
|
654
631
|
background: var(--fail-soft, rgba(239, 68, 68, 0.08));
|
|
655
632
|
}
|
|
656
633
|
|
|
657
|
-
/* ── Modal ── */
|
|
658
|
-
.modal-actions {
|
|
659
|
-
display: flex;
|
|
660
|
-
justify-content: flex-end;
|
|
661
|
-
gap: 0.5rem;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
.modal-body {
|
|
665
|
-
font-size: 0.9rem;
|
|
666
|
-
color: var(--text-muted);
|
|
667
|
-
line-height: 1.6;
|
|
668
|
-
margin: 0;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
.btn-cancel {
|
|
672
|
-
height: 34px;
|
|
673
|
-
padding: 0 1rem;
|
|
674
|
-
font-size: 0.8125rem;
|
|
675
|
-
font-family: inherit;
|
|
676
|
-
background: var(--bg-elevated);
|
|
677
|
-
border: 1px solid var(--border);
|
|
678
|
-
border-radius: var(--radius-sm);
|
|
679
|
-
cursor: pointer;
|
|
680
|
-
color: var(--text);
|
|
681
|
-
transition: background var(--duration-fast);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
.btn-cancel:hover {
|
|
685
|
-
background: var(--bg-subtle);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
.btn-danger {
|
|
689
|
-
height: 34px;
|
|
690
|
-
padding: 0 1rem;
|
|
691
|
-
font-size: 0.8125rem;
|
|
692
|
-
font-family: inherit;
|
|
693
|
-
font-weight: 500;
|
|
694
|
-
background: var(--fail);
|
|
695
|
-
border: 1px solid var(--fail);
|
|
696
|
-
border-radius: var(--radius-sm);
|
|
697
|
-
cursor: pointer;
|
|
698
|
-
color: #fff;
|
|
699
|
-
transition: opacity var(--duration-fast);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
.btn-danger:hover:not(:disabled) {
|
|
703
|
-
opacity: 0.85;
|
|
704
|
-
}
|
|
705
|
-
.btn-danger:disabled {
|
|
706
|
-
opacity: 0.5;
|
|
707
|
-
cursor: not-allowed;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
634
|
/* ── Misc ── */
|
|
711
635
|
.pagination-wrap {
|
|
712
636
|
margin-top: 1.25rem;
|
|
713
637
|
}
|
|
714
|
-
|
|
715
|
-
.empty {
|
|
716
|
-
color: var(--text-muted);
|
|
717
|
-
font-size: 0.9375rem;
|
|
718
|
-
padding: 3rem 0;
|
|
719
|
-
text-align: center;
|
|
720
|
-
}
|
|
721
638
|
</style>
|