plum-e2e 2.5.0 → 2.5.1
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 +1 -1
- package/backend/prisma/migrations/20260707032554_add_perf_indexes/migration.sql +44 -0
- package/backend/prisma/schema.prisma +19 -0
- package/backend/routes/reports.routes.js +4 -2
- package/backend/services/reportService.js +34 -16
- package/bin/plum.js +96 -68
- package/frontend/src/lib/api/reports.js +16 -5
- package/frontend/src/routes/login/+page.svelte +54 -39
- package/frontend/src/routes/reports/+page.svelte +32 -24
- package/frontend/src/routes/setup/+page.svelte +1 -0
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +6 -1
- package/frontend/vite.config.js +7 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -145,7 +145,7 @@ The GPL license header at the top of every file is a legal requirement — do no
|
|
|
145
145
|
- All schema changes require a Prisma migration file in `backend/prisma/migrations/`.
|
|
146
146
|
- Run `npx prisma migrate deploy` (or rebuild Docker) to apply.
|
|
147
147
|
- Report content is stored as JSONB in the `Report.content` column — never reconstruct report metadata from filenames.
|
|
148
|
-
- The `
|
|
148
|
+
- The `getReports()` service function is paginated (`page`/`limit`) and deliberately excludes `content` for list performance — only `getReportDetail(id)` fetches it.
|
|
149
149
|
|
|
150
150
|
---
|
|
151
151
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- AlterTable
|
|
2
|
+
ALTER TABLE "Report" ALTER COLUMN "content" DROP DEFAULT;
|
|
3
|
+
|
|
4
|
+
-- AlterTable
|
|
5
|
+
ALTER TABLE "TestRun" ALTER COLUMN "status" SET DEFAULT 'backlog';
|
|
6
|
+
|
|
7
|
+
-- CreateIndex
|
|
8
|
+
CREATE INDEX "Report_createdAt_idx" ON "Report"("createdAt");
|
|
9
|
+
|
|
10
|
+
-- CreateIndex
|
|
11
|
+
CREATE INDEX "Report_status_idx" ON "Report"("status");
|
|
12
|
+
|
|
13
|
+
-- CreateIndex
|
|
14
|
+
CREATE INDEX "Report_testRunId_idx" ON "Report"("testRunId");
|
|
15
|
+
|
|
16
|
+
-- CreateIndex
|
|
17
|
+
CREATE INDEX "Report_runnerId_idx" ON "Report"("runnerId");
|
|
18
|
+
|
|
19
|
+
-- CreateIndex
|
|
20
|
+
CREATE INDEX "Report_cronJobId_idx" ON "Report"("cronJobId");
|
|
21
|
+
|
|
22
|
+
-- CreateIndex
|
|
23
|
+
CREATE INDEX "TestCase_suiteId_idx" ON "TestCase"("suiteId");
|
|
24
|
+
|
|
25
|
+
-- CreateIndex
|
|
26
|
+
CREATE INDEX "TestCaseHistory_caseId_idx" ON "TestCaseHistory"("caseId");
|
|
27
|
+
|
|
28
|
+
-- CreateIndex
|
|
29
|
+
CREATE INDEX "TestCaseHistory_runId_idx" ON "TestCaseHistory"("runId");
|
|
30
|
+
|
|
31
|
+
-- CreateIndex
|
|
32
|
+
CREATE INDEX "TestCaseHistory_reportId_idx" ON "TestCaseHistory"("reportId");
|
|
33
|
+
|
|
34
|
+
-- CreateIndex
|
|
35
|
+
CREATE INDEX "TestRun_createdAt_idx" ON "TestRun"("createdAt");
|
|
36
|
+
|
|
37
|
+
-- CreateIndex
|
|
38
|
+
CREATE INDEX "TestRunEntry_runId_idx" ON "TestRunEntry"("runId");
|
|
39
|
+
|
|
40
|
+
-- CreateIndex
|
|
41
|
+
CREATE INDEX "TestRunEntry_caseId_idx" ON "TestRunEntry"("caseId");
|
|
42
|
+
|
|
43
|
+
-- CreateIndex
|
|
44
|
+
CREATE INDEX "TestSuite_createdAt_idx" ON "TestSuite"("createdAt");
|
|
@@ -70,6 +70,12 @@ model Report {
|
|
|
70
70
|
logs String?
|
|
71
71
|
createdAt DateTime @default(now())
|
|
72
72
|
testHistory TestCaseHistory[]
|
|
73
|
+
|
|
74
|
+
@@index([createdAt])
|
|
75
|
+
@@index([status])
|
|
76
|
+
@@index([testRunId])
|
|
77
|
+
@@index([runnerId])
|
|
78
|
+
@@index([cronJobId])
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
model Project {
|
|
@@ -124,6 +130,8 @@ model TestSuite {
|
|
|
124
130
|
createdAt DateTime @default(now())
|
|
125
131
|
updatedAt DateTime @updatedAt
|
|
126
132
|
cases TestCase[]
|
|
133
|
+
|
|
134
|
+
@@index([createdAt])
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
model TestCase {
|
|
@@ -142,6 +150,8 @@ model TestCase {
|
|
|
142
150
|
steps TestStep[]
|
|
143
151
|
runEntries TestRunEntry[]
|
|
144
152
|
history TestCaseHistory[]
|
|
153
|
+
|
|
154
|
+
@@index([suiteId])
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
model TestStep {
|
|
@@ -166,6 +176,8 @@ model TestRun {
|
|
|
166
176
|
entries TestRunEntry[]
|
|
167
177
|
history TestCaseHistory[]
|
|
168
178
|
reports Report[]
|
|
179
|
+
|
|
180
|
+
@@index([createdAt])
|
|
169
181
|
}
|
|
170
182
|
|
|
171
183
|
model TestRunEntry {
|
|
@@ -182,6 +194,9 @@ model TestRunEntry {
|
|
|
182
194
|
executedAt DateTime?
|
|
183
195
|
assignedToId String?
|
|
184
196
|
assignedTo User? @relation("entryAssignee", fields: [assignedToId], references: [id])
|
|
197
|
+
|
|
198
|
+
@@index([runId])
|
|
199
|
+
@@index([caseId])
|
|
185
200
|
}
|
|
186
201
|
|
|
187
202
|
model TestCaseHistory {
|
|
@@ -198,4 +213,8 @@ model TestCaseHistory {
|
|
|
198
213
|
executedById String?
|
|
199
214
|
executedBy User? @relation(fields: [executedById], references: [id])
|
|
200
215
|
executedAt DateTime @default(now())
|
|
216
|
+
|
|
217
|
+
@@index([caseId])
|
|
218
|
+
@@index([runId])
|
|
219
|
+
@@index([reportId])
|
|
201
220
|
}
|
|
@@ -21,8 +21,10 @@ const reportService = require('../services/reportService');
|
|
|
21
21
|
|
|
22
22
|
router.get('/', async (req, res) => {
|
|
23
23
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
25
|
+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 15));
|
|
26
|
+
const result = await reportService.getReports({ page, limit });
|
|
27
|
+
res.json(result);
|
|
26
28
|
} catch {
|
|
27
29
|
res.status(500).json({ error: 'Failed to fetch reports' });
|
|
28
30
|
}
|
|
@@ -235,21 +235,39 @@ function processCucumberJson(raw) {
|
|
|
235
235
|
// Read operations
|
|
236
236
|
// ---------------------------------------------------------------------------
|
|
237
237
|
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
238
|
+
const reportListSelect = {
|
|
239
|
+
id: true,
|
|
240
|
+
status: true,
|
|
241
|
+
tags: true,
|
|
242
|
+
triggerType: true,
|
|
243
|
+
runners: true,
|
|
244
|
+
browser: true,
|
|
245
|
+
runnerName: true,
|
|
246
|
+
createdAt: true,
|
|
247
|
+
testRun: { select: { id: true, title: true } }
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const TREND_SIZE = 12;
|
|
251
|
+
|
|
252
|
+
const getReports = async ({ page = 1, limit = 15 } = {}) => {
|
|
253
|
+
const skip = (page - 1) * limit;
|
|
254
|
+
const [reports, total, passCount, trend] = await Promise.all([
|
|
255
|
+
prisma.report.findMany({
|
|
256
|
+
orderBy: { createdAt: 'desc' },
|
|
257
|
+
skip,
|
|
258
|
+
take: limit,
|
|
259
|
+
select: reportListSelect
|
|
260
|
+
}),
|
|
261
|
+
prisma.report.count(),
|
|
262
|
+
prisma.report.count({ where: { status: 'PASS' } }),
|
|
263
|
+
prisma.report.findMany({
|
|
264
|
+
orderBy: { createdAt: 'desc' },
|
|
265
|
+
take: TREND_SIZE,
|
|
266
|
+
select: { id: true, status: true, tags: true, createdAt: true }
|
|
267
|
+
})
|
|
268
|
+
]);
|
|
269
|
+
return { reports, total, passCount, failCount: total - passCount, trend };
|
|
270
|
+
};
|
|
253
271
|
|
|
254
272
|
const getLatestReportId = async () => {
|
|
255
273
|
const report = await prisma.report.findFirst({
|
|
@@ -444,7 +462,7 @@ async function syncAutomatedFromFeatures() {
|
|
|
444
462
|
}
|
|
445
463
|
|
|
446
464
|
module.exports = {
|
|
447
|
-
|
|
465
|
+
getReports,
|
|
448
466
|
getLatestReportId,
|
|
449
467
|
getReportDetail,
|
|
450
468
|
saveReport,
|
package/bin/plum.js
CHANGED
|
@@ -247,20 +247,20 @@ function applyServerConfig(cfg) {
|
|
|
247
247
|
clack.log.success('docker-compose.override.yml written');
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
250
|
+
// Node's fetch resolves "localhost" to ::1 first on many Linux distros (Debian
|
|
251
|
+
// included). If Docker only published the port on IPv4, that first attempt hangs
|
|
252
|
+
// until it times out on every poll, eating the whole budget even though the port
|
|
253
|
+
// is reachable — and reachable fine from a browser, which races both families.
|
|
254
|
+
// 127.0.0.1 sidesteps the DNS/happy-eyeballs mismatch entirely.
|
|
255
|
+
const READY_POLL_INTERVAL_MS = 2000;
|
|
256
|
+
const READY_POLL_MAX_ATTEMPTS = 90; // ~3 minutes
|
|
257
|
+
|
|
258
|
+
async function waitForServerReady(apiBase) {
|
|
259
259
|
const s = clack.spinner();
|
|
260
260
|
s.start('Waiting for server to be ready…');
|
|
261
261
|
let ready = false;
|
|
262
|
-
for (let i = 0; i <
|
|
263
|
-
await new Promise((r) => setTimeout(r,
|
|
262
|
+
for (let i = 0; i < READY_POLL_MAX_ATTEMPTS; i++) {
|
|
263
|
+
await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL_MS));
|
|
264
264
|
try {
|
|
265
265
|
const res = await fetch(`${apiBase}/auth/needs-setup`);
|
|
266
266
|
if (res.ok) {
|
|
@@ -268,54 +268,90 @@ async function serverStart() {
|
|
|
268
268
|
break;
|
|
269
269
|
}
|
|
270
270
|
} catch {}
|
|
271
|
+
if (i > 0 && i % 15 === 0) {
|
|
272
|
+
s.message(
|
|
273
|
+
`Still waiting for server to be ready… (${Math.round((i * READY_POLL_INTERVAL_MS) / 1000)}s — check "docker compose logs -f backend" if this feels stuck)`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
271
276
|
}
|
|
272
|
-
s.stop(
|
|
277
|
+
s.stop(
|
|
278
|
+
ready
|
|
279
|
+
? pc.green('✓ Server is ready')
|
|
280
|
+
: pc.yellow('Server did not respond in time — it may still be starting')
|
|
281
|
+
);
|
|
282
|
+
return ready;
|
|
283
|
+
}
|
|
273
284
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
285
|
+
async function runFirstUserSetup(apiBase, frontendPort) {
|
|
286
|
+
let needsSetup = false;
|
|
287
|
+
try {
|
|
288
|
+
const res = await fetch(`${apiBase}/auth/needs-setup`);
|
|
289
|
+
const data = await res.json();
|
|
290
|
+
needsSetup = data.needsSetup;
|
|
291
|
+
} catch {}
|
|
281
292
|
|
|
282
|
-
|
|
283
|
-
clack.log.info('No users found — create your first account to get started.');
|
|
293
|
+
if (!needsSetup) return;
|
|
284
294
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
295
|
+
if (!interactiveAllowed()) {
|
|
296
|
+
clack.log.info(
|
|
297
|
+
`No users found. Open ${pc.cyan(`http://localhost:${frontendPort}/setup`)} to create your first account.`
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
clack.log.info('No users found — create your first account to get started.');
|
|
303
|
+
|
|
304
|
+
const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
|
|
305
|
+
if (clack.isCancel(name)) {
|
|
306
|
+
clack.log.warn('Skipped. Create a user at /setup in the UI.');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const email = await clack.text({ message: 'Email address', placeholder: 'jane@example.com' });
|
|
310
|
+
if (clack.isCancel(email)) {
|
|
311
|
+
clack.log.warn('Skipped. Create a user at /setup in the UI.');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const password = await clack.password({ message: 'Password (min 8 characters)' });
|
|
315
|
+
if (clack.isCancel(password)) {
|
|
316
|
+
clack.log.warn('Skipped. Create a user at /setup in the UI.');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const res = await fetch(`${apiBase}/auth/setup`, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'Content-Type': 'application/json' },
|
|
324
|
+
body: JSON.stringify({ name, email, password })
|
|
325
|
+
});
|
|
326
|
+
if (res.ok) {
|
|
327
|
+
clack.log.success(`Account created for ${email}. You can now log in.`);
|
|
328
|
+
} else {
|
|
329
|
+
const err = await res.json();
|
|
330
|
+
clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
|
|
318
331
|
}
|
|
332
|
+
} catch (e) {
|
|
333
|
+
clack.log.error(`Failed to create account: ${e.message}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function serverStart() {
|
|
338
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
|
|
339
|
+
const cfg = await configureServer({ force: false });
|
|
340
|
+
applyServerConfig(cfg);
|
|
341
|
+
clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
|
|
342
|
+
|
|
343
|
+
execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
|
|
344
|
+
|
|
345
|
+
const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
|
|
346
|
+
const ready = await waitForServerReady(apiBase);
|
|
347
|
+
|
|
348
|
+
if (ready) {
|
|
349
|
+
await runFirstUserSetup(apiBase, cfg.frontendPort);
|
|
350
|
+
} else {
|
|
351
|
+
clack.log.warn(
|
|
352
|
+
`Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.\n` +
|
|
353
|
+
`Once it responds, open ${pc.cyan(`http://localhost:${cfg.frontendPort}/setup`)} to create your first account (if this is a fresh install).`
|
|
354
|
+
);
|
|
319
355
|
}
|
|
320
356
|
|
|
321
357
|
clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
|
|
@@ -332,21 +368,13 @@ async function serverRestart() {
|
|
|
332
368
|
|
|
333
369
|
execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
|
|
334
370
|
|
|
335
|
-
const apiBase = `http://
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
try {
|
|
342
|
-
const res = await fetch(`${apiBase}/auth/needs-setup`);
|
|
343
|
-
if (res.ok) {
|
|
344
|
-
ready = true;
|
|
345
|
-
break;
|
|
346
|
-
}
|
|
347
|
-
} catch {}
|
|
371
|
+
const apiBase = `http://127.0.0.1:${cfg.backendPort}`;
|
|
372
|
+
const ready = await waitForServerReady(apiBase);
|
|
373
|
+
if (!ready) {
|
|
374
|
+
clack.log.warn(
|
|
375
|
+
`Could not confirm the backend is ready. Check ${pc.cyan('docker compose logs -f backend')}.`
|
|
376
|
+
);
|
|
348
377
|
}
|
|
349
|
-
s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
|
|
350
378
|
clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
|
|
351
379
|
clack.log.info(`API: ${pc.cyan(`http://localhost:${cfg.backendPort}`)}`);
|
|
352
380
|
clack.outro(pc.green('Server restarted.'));
|
|
@@ -15,12 +15,23 @@
|
|
|
15
15
|
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { API_BASE } from '$lib/constants';
|
|
18
|
+
import { API_BASE, REPORTS_PER_PAGE } from '$lib/constants';
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
function withDate(r) {
|
|
21
|
+
return { ...r, date: new Date(r.createdAt).toLocaleString() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchReports({ page = 1, limit = REPORTS_PER_PAGE } = {}) {
|
|
25
|
+
const params = new URLSearchParams({ page, limit });
|
|
26
|
+
const res = await fetch(`${API_BASE}/reports?${params}`);
|
|
27
|
+
const { reports, total, passCount, failCount, trend } = await res.json();
|
|
28
|
+
return {
|
|
29
|
+
reports: reports.map(withDate),
|
|
30
|
+
total,
|
|
31
|
+
passCount,
|
|
32
|
+
failCount,
|
|
33
|
+
trend: trend.map(withDate)
|
|
34
|
+
};
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export async function fetchLatestReportId() {
|
|
@@ -16,14 +16,27 @@
|
|
|
16
16
|
-->
|
|
17
17
|
|
|
18
18
|
<script>
|
|
19
|
+
import { onMount } from 'svelte';
|
|
20
|
+
import { goto } from '$app/navigation';
|
|
19
21
|
import { auth } from '$lib/stores/auth';
|
|
20
|
-
import { login } from '$lib/api/auth';
|
|
22
|
+
import { login, checkNeedsSetup } from '$lib/api/auth';
|
|
21
23
|
import { theme } from '$lib/stores/theme';
|
|
22
24
|
|
|
23
25
|
let email = '';
|
|
24
26
|
let password = '';
|
|
25
27
|
let error = '';
|
|
26
28
|
let loading = false;
|
|
29
|
+
let checking = true;
|
|
30
|
+
|
|
31
|
+
onMount(async () => {
|
|
32
|
+
try {
|
|
33
|
+
if (await checkNeedsSetup()) {
|
|
34
|
+
goto('/setup');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
checking = false;
|
|
39
|
+
});
|
|
27
40
|
|
|
28
41
|
async function handleSubmit() {
|
|
29
42
|
error = '';
|
|
@@ -47,48 +60,50 @@
|
|
|
47
60
|
<svelte:head><title>Sign in — Plum</title></svelte:head>
|
|
48
61
|
|
|
49
62
|
<div class="page" data-theme={$theme}>
|
|
50
|
-
|
|
51
|
-
<div class="
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
<h1 class="title">Sign in</h1>
|
|
55
|
-
<p class="subtitle">Access your test workspace</p>
|
|
56
|
-
|
|
57
|
-
<div class="fields">
|
|
58
|
-
<div class="field">
|
|
59
|
-
<label class="label" for="email">Email</label>
|
|
60
|
-
<input
|
|
61
|
-
id="email"
|
|
62
|
-
type="email"
|
|
63
|
-
class="input"
|
|
64
|
-
bind:value={email}
|
|
65
|
-
placeholder="jane@example.com"
|
|
66
|
-
autocomplete="email"
|
|
67
|
-
on:keydown={onKeydown}
|
|
68
|
-
/>
|
|
63
|
+
{#if !checking}
|
|
64
|
+
<div class="card">
|
|
65
|
+
<div class="brand">
|
|
66
|
+
<span class="brand-serif">Pl</span><span class="brand-sans">um</span>
|
|
69
67
|
</div>
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class="
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
68
|
+
<h1 class="title">Sign in</h1>
|
|
69
|
+
<p class="subtitle">Access your test workspace</p>
|
|
70
|
+
|
|
71
|
+
<div class="fields">
|
|
72
|
+
<div class="field">
|
|
73
|
+
<label class="label" for="email">Email</label>
|
|
74
|
+
<input
|
|
75
|
+
id="email"
|
|
76
|
+
type="email"
|
|
77
|
+
class="input"
|
|
78
|
+
bind:value={email}
|
|
79
|
+
placeholder="jane@example.com"
|
|
80
|
+
autocomplete="email"
|
|
81
|
+
on:keydown={onKeydown}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="field">
|
|
85
|
+
<label class="label" for="password">Password</label>
|
|
86
|
+
<input
|
|
87
|
+
id="password"
|
|
88
|
+
type="password"
|
|
89
|
+
class="input"
|
|
90
|
+
bind:value={password}
|
|
91
|
+
placeholder="••••••••"
|
|
92
|
+
autocomplete="current-password"
|
|
93
|
+
on:keydown={onKeydown}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
81
96
|
</div>
|
|
82
|
-
</div>
|
|
83
97
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
{#if error}
|
|
99
|
+
<p class="error">{error}</p>
|
|
100
|
+
{/if}
|
|
87
101
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
<button class="submit-btn" on:click={handleSubmit} disabled={loading || !email || !password}>
|
|
103
|
+
{loading ? 'Signing in…' : 'Sign in'}
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
{/if}
|
|
92
107
|
</div>
|
|
93
108
|
|
|
94
109
|
<style>
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
28
28
|
|
|
29
29
|
let reports = [];
|
|
30
|
+
let total = 0;
|
|
31
|
+
let passCount = 0;
|
|
32
|
+
let failCount = 0;
|
|
33
|
+
let trend = [];
|
|
30
34
|
let currentPage = 1;
|
|
31
35
|
let animateBar = false;
|
|
32
36
|
|
|
@@ -34,21 +38,24 @@
|
|
|
34
38
|
let deleteModal = { open: false, targets: [] };
|
|
35
39
|
let deleting = false;
|
|
36
40
|
|
|
37
|
-
$: totalPages = Math.ceil(
|
|
38
|
-
$:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
42
|
-
$: passCount = reports.filter((r) => r.status === 'PASS').length;
|
|
43
|
-
$: failCount = reports.length - passCount;
|
|
44
|
-
$: passRate = reports.length ? Math.round((passCount / reports.length) * 100) : 0;
|
|
45
|
-
$: trend = reports.slice(0, 12).reverse();
|
|
46
|
-
$: allOnPageSelected = paginated.length > 0 && paginated.every((r) => selected.has(r.id));
|
|
41
|
+
$: totalPages = Math.ceil(total / REPORTS_PER_PAGE);
|
|
42
|
+
$: passRate = total ? Math.round((passCount / total) * 100) : 0;
|
|
43
|
+
$: trendDots = [...trend].reverse();
|
|
44
|
+
$: allOnPageSelected = reports.length > 0 && reports.every((r) => selected.has(r.id));
|
|
47
45
|
$: someSelected = selected.size > 0;
|
|
48
46
|
|
|
49
47
|
async function loadReports() {
|
|
50
48
|
try {
|
|
51
|
-
|
|
49
|
+
let data = await fetchReports({ page: currentPage, limit: REPORTS_PER_PAGE });
|
|
50
|
+
if (data.reports.length === 0 && currentPage > 1) {
|
|
51
|
+
currentPage = Math.max(1, Math.ceil(data.total / REPORTS_PER_PAGE));
|
|
52
|
+
data = await fetchReports({ page: currentPage, limit: REPORTS_PER_PAGE });
|
|
53
|
+
}
|
|
54
|
+
reports = data.reports;
|
|
55
|
+
total = data.total;
|
|
56
|
+
passCount = data.passCount;
|
|
57
|
+
failCount = data.failCount;
|
|
58
|
+
trend = data.trend;
|
|
52
59
|
selected = new Set();
|
|
53
60
|
await tick();
|
|
54
61
|
animateBar = true;
|
|
@@ -57,6 +64,11 @@
|
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
function goToPage(page) {
|
|
68
|
+
currentPage = page;
|
|
69
|
+
loadReports();
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
onMount(loadReports);
|
|
61
73
|
$: if ($reportsVersion) loadReports();
|
|
62
74
|
|
|
@@ -73,11 +85,11 @@
|
|
|
73
85
|
e.stopPropagation();
|
|
74
86
|
if (allOnPageSelected) {
|
|
75
87
|
const next = new Set(selected);
|
|
76
|
-
|
|
88
|
+
reports.forEach((r) => next.delete(r.id));
|
|
77
89
|
selected = next;
|
|
78
90
|
} else {
|
|
79
91
|
const next = new Set(selected);
|
|
80
|
-
|
|
92
|
+
reports.forEach((r) => next.add(r.id));
|
|
81
93
|
selected = next;
|
|
82
94
|
}
|
|
83
95
|
}
|
|
@@ -133,11 +145,11 @@
|
|
|
133
145
|
<div>
|
|
134
146
|
<h1>Reports</h1>
|
|
135
147
|
<p class="subtitle">
|
|
136
|
-
{
|
|
148
|
+
{total} run{total !== 1 ? 's' : ''} recorded
|
|
137
149
|
</p>
|
|
138
150
|
</div>
|
|
139
151
|
|
|
140
|
-
{#if
|
|
152
|
+
{#if total > 0}
|
|
141
153
|
<div class="rate-display">
|
|
142
154
|
<span
|
|
143
155
|
class="rate-number"
|
|
@@ -152,7 +164,7 @@
|
|
|
152
164
|
{/if}
|
|
153
165
|
</div>
|
|
154
166
|
|
|
155
|
-
{#if
|
|
167
|
+
{#if total > 0}
|
|
156
168
|
<div class="stats-bar">
|
|
157
169
|
<div class="pass-bar-track">
|
|
158
170
|
<div class="pass-bar-fill" style="width: {animateBar ? passRate + '%' : '0'}"></div>
|
|
@@ -166,7 +178,7 @@
|
|
|
166
178
|
<div class="trend-row">
|
|
167
179
|
<span class="trend-label">Recent</span>
|
|
168
180
|
<div class="trend-dots">
|
|
169
|
-
{#each
|
|
181
|
+
{#each trendDots as r, i}
|
|
170
182
|
<span
|
|
171
183
|
class="trend-dot"
|
|
172
184
|
class:pass={r.status === 'PASS'}
|
|
@@ -181,7 +193,7 @@
|
|
|
181
193
|
{/if}
|
|
182
194
|
</div>
|
|
183
195
|
|
|
184
|
-
{#if
|
|
196
|
+
{#if total === 0}
|
|
185
197
|
<EmptyState message="No reports yet. Run a test to generate one." />
|
|
186
198
|
{:else}
|
|
187
199
|
<div class="list-header">
|
|
@@ -202,7 +214,7 @@
|
|
|
202
214
|
</div>
|
|
203
215
|
|
|
204
216
|
<div class="report-list">
|
|
205
|
-
{#each
|
|
217
|
+
{#each reports as report, i}
|
|
206
218
|
<div class="report-row" class:is-selected={selected.has(report.id)} style={stagger(i)}>
|
|
207
219
|
<label class="row-check-wrap" title="Select">
|
|
208
220
|
<input
|
|
@@ -287,11 +299,7 @@
|
|
|
287
299
|
|
|
288
300
|
{#if totalPages > 1}
|
|
289
301
|
<div class="pagination-wrap">
|
|
290
|
-
<Pagination
|
|
291
|
-
current={currentPage}
|
|
292
|
-
total={totalPages}
|
|
293
|
-
on:change={(e) => (currentPage = e.detail)}
|
|
294
|
-
/>
|
|
302
|
+
<Pagination current={currentPage} total={totalPages} on:change={(e) => goToPage(e.detail)} />
|
|
295
303
|
</div>
|
|
296
304
|
{/if}
|
|
297
305
|
{/if}
|
|
@@ -174,13 +174,18 @@
|
|
|
174
174
|
(tc) =>
|
|
175
175
|
!runCaseIds.has(tc.id) &&
|
|
176
176
|
(!search ||
|
|
177
|
+
suite.displayId.toLowerCase().includes(search.toLowerCase()) ||
|
|
178
|
+
suite.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
177
179
|
tc.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
178
180
|
tc.displayId.toLowerCase().includes(search.toLowerCase()))
|
|
179
181
|
)
|
|
180
182
|
}))
|
|
181
183
|
.filter(
|
|
182
184
|
(suite) =>
|
|
183
|
-
!search ||
|
|
185
|
+
!search ||
|
|
186
|
+
suite.cases.length > 0 ||
|
|
187
|
+
suite.displayId.toLowerCase().includes(search.toLowerCase()) ||
|
|
188
|
+
suite.name.toLowerCase().includes(search.toLowerCase())
|
|
184
189
|
);
|
|
185
190
|
|
|
186
191
|
function addCase(tc) {
|
package/frontend/vite.config.js
CHANGED
|
@@ -19,5 +19,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|
|
19
19
|
import { defineConfig } from 'vite';
|
|
20
20
|
|
|
21
21
|
export default defineConfig({
|
|
22
|
-
plugins: [sveltekit()]
|
|
22
|
+
plugins: [sveltekit()],
|
|
23
|
+
server: {
|
|
24
|
+
// Plum is reverse-proxied behind whatever domain each self-hoster picks
|
|
25
|
+
// (see "Setting Up the Server" docs), so the Host header can't be known
|
|
26
|
+
// ahead of time. Disable Vite's dev-server host allowlist check entirely.
|
|
27
|
+
allowedHosts: true
|
|
28
|
+
}
|
|
23
29
|
});
|