From 280e5f133f38a04e8d6fa3873805c41c3c87e084 Mon Sep 17 00:00:00 2001 From: Malin Date: Mon, 6 Apr 2026 19:36:13 +0200 Subject: [PATCH] feat: initial Speedboard implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sitespeed.io web UI with Express/Pug/SQLite — port 3132. Includes job queue, SSE live log, full metrics dashboard, site history, CO2/axe/CWV sections, and Docker support. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + Dockerfile | 36 +++++++ app.js | 54 ++++++++++ db.js | 185 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 28 ++++++ package.json | 15 +++ parser.js | 175 +++++++++++++++++++++++++++++++++ queue.js | 82 ++++++++++++++++ routes/history.js | 11 +++ routes/index.js | 11 +++ routes/results.js | 21 ++++ routes/site.js | 14 +++ routes/status.js | 74 ++++++++++++++ routes/test.js | 30 ++++++ runner.js | 68 +++++++++++++ views/error.pug | 7 ++ views/history.pug | 51 ++++++++++ views/index.pug | 45 +++++++++ views/layout.pug | 21 ++++ views/partials/axe.pug | 15 +++ views/partials/co2.pug | 12 +++ views/partials/cwv.pug | 17 ++++ views/partials/resources.pug | 25 +++++ views/partials/scorecard.pug | 12 +++ views/partials/timings.pug | 23 +++++ views/results.pug | 34 +++++++ views/running.pug | 73 ++++++++++++++ views/site.pug | 78 +++++++++++++++ 28 files changed, 1222 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.js create mode 100644 db.js create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 parser.js create mode 100644 queue.js create mode 100644 routes/history.js create mode 100644 routes/index.js create mode 100644 routes/results.js create mode 100644 routes/site.js create mode 100644 routes/status.js create mode 100644 routes/test.js create mode 100644 runner.js create mode 100644 views/error.pug create mode 100644 views/history.pug create mode 100644 views/index.pug create mode 100644 views/layout.pug create mode 100644 views/partials/axe.pug create mode 100644 views/partials/co2.pug create mode 100644 views/partials/cwv.pug create mode 100644 views/partials/resources.pug create mode 100644 views/partials/scorecard.pug create mode 100644 views/partials/timings.pug create mode 100644 views/results.pug create mode 100644 views/running.pug create mode 100644 views/site.pug diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..922eb84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +reports/ +speedboard.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20bd885 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Speedboard Docker image +# +# Based on the official sitespeed.io image which already includes: +# - Node.js 20 +# - Chrome & Firefox (headless) +# - Xvfb +# - sitespeed.io CLI +# ───────────────────────────────────────────────────────────────────────────── +FROM sitespeedio/sitespeed.io:latest + +WORKDIR /app + +# Copy speedboard app files +COPY package.json ./ +RUN npm install --omit=dev + +COPY . . + +# Create persistent directories +RUN mkdir -p /data/reports + +# Symlink reports dir into app folder +RUN ln -sf /data/reports /app/reports + +# Runtime env +ENV PORT=3132 \ + IN_DOCKER=1 \ + # sitespeed.io is already installed at /usr/local/lib/node_modules/sitespeed.io/bin/sitespeed.js + # but we ship our own copy — point to the bundled one inside image + SITESPEED_BIN=/usr/local/lib/node_modules/sitespeed.io/bin/sitespeed.js \ + NODE_ENV=production + +EXPOSE 3000 + +CMD ["node", "app.js"] diff --git a/app.js b/app.js new file mode 100644 index 0000000..c6f678b --- /dev/null +++ b/app.js @@ -0,0 +1,54 @@ +import express from 'express'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { getDb } from './db.js'; + +// Routes +import indexRouter from './routes/index.js'; +import testRouter from './routes/test.js'; +import statusRouter from './routes/status.js'; +import resultsRouter from './routes/results.js'; +import historyRouter from './routes/history.js'; +import siteRouter from './routes/site.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const app = express(); +const PORT = process.env.PORT || 3132; + +// Initialize DB on startup +getDb(); + +// View engine +app.set('views', join(__dirname, 'views')); +app.set('view engine', 'pug'); + +// Middleware +app.use(express.urlencoded({ extended: true })); +app.use(express.json()); + +// Static reports +app.use('/reports', express.static(join(__dirname, 'reports'))); + +// Routes +app.use('/', indexRouter); +app.use('/test', testRouter); +app.use('/status', statusRouter); +app.use('/results', resultsRouter); +app.use('/history', historyRouter); +app.use('/site', siteRouter); + +// 404 +app.use((req, res) => { + res.status(404).render('error', { title: '404 Not Found', message: 'Page not found.' }); +}); + +// Error handler +app.use((err, req, res, _next) => { + console.error(err); + res.status(500).render('error', { title: 'Server Error', message: err.message }); +}); + +app.listen(PORT, () => { + console.log(`Speedboard running at http://localhost:${PORT}`); +}); diff --git a/db.js b/db.js new file mode 100644 index 0000000..2925341 --- /dev/null +++ b/db.js @@ -0,0 +1,185 @@ +import Database from 'better-sqlite3'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DB_PATH || join(__dirname, 'speedboard.db'); + +let db; + +export function getDb() { + if (!db) { + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + initSchema(); + } + return db; +} + +function initSchema() { + db.exec(` + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + browser TEXT NOT NULL DEFAULT 'chrome', + mobile INTEGER NOT NULL DEFAULT 0, + runs INTEGER NOT NULL DEFAULT 3, + status TEXT NOT NULL DEFAULT 'queued', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + finished_at TEXT, + error_msg TEXT, + + -- Core Web Vitals (ms / score) + lcp REAL, + cls REAL, + tbt REAL, + fcp REAL, + ttfb REAL, + max_potential_fid REAL, + + -- Visual metrics + speed_index REAL, + first_visual_change REAL, + last_visual_change REAL, + visual_complete_85 REAL, + perceptual_speed_index REAL, + + -- Navigation timings (ms) + page_load_time REAL, + fully_loaded REAL, + dom_content_loaded REAL, + dom_interactive REAL, + front_end_time REAL, + back_end_time REAL, + time_to_first_byte REAL, + + -- Coach scores (0-100) + score_overall REAL, + score_performance REAL, + score_accessibility REAL, + score_bestpractice REAL, + score_privacy REAL, + + -- Resource sizes (bytes) + transfer_total REAL, + transfer_html REAL, + transfer_js REAL, + transfer_css REAL, + transfer_image REAL, + transfer_font REAL, + transfer_other REAL, + + -- Request counts + requests_total INTEGER, + requests_js INTEGER, + requests_css INTEGER, + requests_image INTEGER, + requests_font INTEGER, + + -- Third-party + third_party_requests INTEGER, + third_party_transfer REAL, + + -- Accessibility (axe) + axe_critical INTEGER, + axe_serious INTEGER, + axe_moderate INTEGER, + axe_minor INTEGER, + + -- CPU + long_tasks_count INTEGER, + long_tasks_duration REAL, + + -- CO2 + co2_per_page_view REAL, + co2_total REAL, + co2_first_party REAL, + co2_third_party REAL, + + -- Raw JSON paths for drilling down + report_folder TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_jobs_url ON jobs(url); + CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); + `); +} + +export function createJob(id, url, browser, mobile, runs) { + const stmt = getDb().prepare(` + INSERT INTO jobs (id, url, browser, mobile, runs, status, created_at) + VALUES (?, ?, ?, ?, ?, 'queued', datetime('now')) + `); + stmt.run(id, url, browser, mobile ? 1 : 0, runs); +} + +export function updateJobStatus(id, status, extras = {}) { + const db = getDb(); + const fields = ['status = ?']; + const values = [status]; + + if (status === 'running') { + fields.push('started_at = datetime(\'now\')'); + } + if (status === 'done' || status === 'error') { + fields.push('finished_at = datetime(\'now\')'); + } + if (extras.error_msg !== undefined) { + fields.push('error_msg = ?'); + values.push(extras.error_msg); + } + if (extras.report_folder !== undefined) { + fields.push('report_folder = ?'); + values.push(extras.report_folder); + } + + values.push(id); + db.prepare(`UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`).run(...values); +} + +export function updateJobMetrics(id, metrics) { + const db = getDb(); + const keys = Object.keys(metrics); + if (keys.length === 0) return; + const sets = keys.map(k => `${k} = ?`).join(', '); + const values = keys.map(k => metrics[k]); + values.push(id); + db.prepare(`UPDATE jobs SET ${sets} WHERE id = ?`).run(...values); +} + +export function getJob(id) { + return getDb().prepare('SELECT * FROM jobs WHERE id = ?').get(id); +} + +export function getHistory(limit = 100) { + return getDb().prepare(` + SELECT id, url, browser, mobile, runs, status, created_at, finished_at, + lcp, fcp, tbt, speed_index, score_overall, score_performance, + transfer_total, requests_total + FROM jobs + ORDER BY created_at DESC + LIMIT ? + `).all(limit); +} + +export function getSiteHistory(url, limit = 20) { + return getDb().prepare(` + SELECT * FROM jobs + WHERE url = ? AND status = 'done' + ORDER BY created_at DESC + LIMIT ? + `).all(url, limit); +} + +export function getDistinctUrls() { + return getDb().prepare(` + SELECT url, COUNT(*) as count, MAX(created_at) as last_tested, + (SELECT status FROM jobs j2 WHERE j2.url = jobs.url ORDER BY created_at DESC LIMIT 1) as last_status + FROM jobs + GROUP BY url + ORDER BY MAX(created_at) DESC + `).all(); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6e2d1a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.9' + +services: + speedboard: + build: . + image: speedboard:latest + container_name: speedboard + restart: unless-stopped + ports: + - "3132:3132" + volumes: + # Persist reports and SQLite database outside the container + - speedboard-reports:/data/reports + - speedboard-db:/data/db + environment: + PORT: "3132" + IN_DOCKER: "1" + # Store SQLite DB on the persistent volume + DB_PATH: /data/db/speedboard.db + # sitespeed.io needs /dev/shm for Chrome + shm_size: '2gb' + # Allow Chrome --no-sandbox + security_opt: + - seccomp:unconfined + +volumes: + speedboard-reports: + speedboard-db: diff --git a/package.json b/package.json new file mode 100644 index 0000000..0f57997 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "speedboard", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node app.js", + "dev": "node --watch app.js" + }, + "dependencies": { + "better-sqlite3": "^12.8.0", + "express": "^5.2.1", + "pug": "^3.0.4", + "uuid": "^11.1.0" + } +} diff --git a/parser.js b/parser.js new file mode 100644 index 0000000..20ac1ae --- /dev/null +++ b/parser.js @@ -0,0 +1,175 @@ +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; + +/** + * Walk the outputFolder looking for the pageSummary JSON files + * produced by sitespeed.io. The structure is: + * outputFolder/pages///.pageSummary.json + */ +async function findPageSummaries(outputFolder) { + const pagesDir = join(outputFolder, 'pages'); + const summaries = {}; + + async function walk(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) { + await walk(full); + } else if (e.name.endsWith('.pageSummary.json')) { + const plugin = e.name.replace('.pageSummary.json', ''); + if (!summaries[plugin]) summaries[plugin] = []; + summaries[plugin].push(full); + } + } + } + + await walk(pagesDir); + return summaries; +} + +async function readJson(filePath) { + const raw = await readFile(filePath, 'utf8'); + return JSON.parse(raw); +} + +function safe(obj, ...path) { + let cur = obj; + for (const key of path) { + if (cur == null || typeof cur !== 'object') return null; + cur = cur[key]; + } + return cur ?? null; +} + +function median(obj) { + return safe(obj, 'median') ?? safe(obj, 'mean') ?? null; +} + +export async function parseResults(outputFolder, _url) { + const summaries = await findPageSummaries(outputFolder); + const metrics = {}; + + // ─── browsertime.pageSummary ─────────────────────────────────────────────── + const btFiles = summaries['browsertime'] || []; + if (btFiles.length > 0) { + const bt = await readJson(btFiles[0]); + const stats = safe(bt, 'statistics'); + const timings = safe(stats, 'timings'); + const pageTimings = safe(timings, 'pageTimings'); + const userTimings = safe(timings, 'userTimings'); + const visualMetrics = safe(stats, 'visualMetrics'); + const cpu = safe(stats, 'cpu'); + const axe = safe(bt, 'accessibility', 'summary'); + + // Core Web Vitals / timings + if (timings) { + metrics.ttfb = median(safe(timings, 'timeToFirstByte')); + metrics.fcp = median(safe(timings, 'firstContentfulPaint')); + metrics.lcp = median(safe(timings, 'largestContentfulPaint')); + metrics.cls = median(safe(timings, 'cumulativeLayoutShift')); + metrics.tbt = median(safe(timings, 'totalBlockingTime')); + metrics.max_potential_fid = median(safe(timings, 'maxPotentialFID')); + } + + if (pageTimings) { + metrics.page_load_time = median(safe(pageTimings, 'pageLoadTime')); + metrics.fully_loaded = median(safe(pageTimings, 'fullyLoaded')); + metrics.dom_content_loaded = median(safe(pageTimings, 'domContentLoadedEventEnd')); + metrics.dom_interactive = median(safe(pageTimings, 'domInteractive')); + metrics.front_end_time = median(safe(pageTimings, 'frontEndTime')); + metrics.back_end_time = median(safe(pageTimings, 'backEndTime')); + metrics.time_to_first_byte = median(safe(pageTimings, 'timeToFirstByte')) + ?? metrics.ttfb; + } + + if (visualMetrics) { + metrics.speed_index = median(safe(visualMetrics, 'SpeedIndex')); + metrics.first_visual_change = median(safe(visualMetrics, 'FirstVisualChange')); + metrics.last_visual_change = median(safe(visualMetrics, 'LastVisualChange')); + metrics.visual_complete_85 = median(safe(visualMetrics, 'VisualComplete85')); + metrics.perceptual_speed_index = median(safe(visualMetrics, 'PerceptualSpeedIndex')); + } + + // CPU / Long Tasks + if (cpu) { + metrics.long_tasks_count = median(safe(cpu, 'longTasks', 'tasks')); + metrics.long_tasks_duration = median(safe(cpu, 'longTasks', 'totalDuration')); + } + + // Axe accessibility + if (axe) { + metrics.axe_critical = safe(axe, 'critical') ?? 0; + metrics.axe_serious = safe(axe, 'serious') ?? 0; + metrics.axe_moderate = safe(axe, 'moderate') ?? 0; + metrics.axe_minor = safe(axe, 'minor') ?? 0; + } + } + + // ─── coach.pageSummary ───────────────────────────────────────────────────── + const coachFiles = summaries['coach'] || []; + if (coachFiles.length > 0) { + const coach = await readJson(coachFiles[0]); + const advice = safe(coach, 'advice'); + if (advice) { + metrics.score_overall = safe(advice, 'score') ?? safe(advice, 'overall', 'score'); + metrics.score_performance = safe(advice, 'performance', 'score'); + metrics.score_accessibility = safe(advice, 'accessibility', 'score'); + metrics.score_bestpractice = safe(advice, 'bestpractice', 'score'); + metrics.score_privacy = safe(advice, 'privacy', 'score'); + } + } + + // ─── pagexray.pageSummary ────────────────────────────────────────────────── + const xrayFiles = summaries['pagexray'] || []; + if (xrayFiles.length > 0) { + const xray = await readJson(xrayFiles[0]); + // pagexray has multiple runs — use the first or median-like object + const page = Array.isArray(xray) ? xray[0] : xray; + const ct = safe(page, 'contentTypes'); + + if (ct) { + metrics.transfer_total = safe(page, 'transferSize'); + metrics.requests_total = safe(page, 'requests'); + metrics.transfer_html = safe(ct, 'html', 'transferSize'); + metrics.transfer_js = safe(ct, 'javascript', 'transferSize'); + metrics.transfer_css = safe(ct, 'css', 'transferSize'); + metrics.transfer_image = safe(ct, 'image', 'transferSize'); + metrics.transfer_font = safe(ct, 'font', 'transferSize'); + metrics.requests_js = safe(ct, 'javascript', 'requests'); + metrics.requests_css = safe(ct, 'css', 'requests'); + metrics.requests_image = safe(ct, 'image', 'requests'); + metrics.requests_font = safe(ct, 'font', 'requests'); + } + + const tp = safe(page, 'thirdParty'); + if (tp) { + metrics.third_party_requests = safe(tp, 'requests'); + metrics.third_party_transfer = safe(tp, 'transferSize'); + } + } + + // ─── sustainable.pageSummary ─────────────────────────────────────────────── + const sustainFiles = summaries['sustainable'] || []; + if (sustainFiles.length > 0) { + const sust = await readJson(sustainFiles[0]); + metrics.co2_per_page_view = safe(sust, 'co2PerPageView') + ?? safe(sust, 'statistics', 'co2PerPageView', 'median'); + metrics.co2_total = safe(sust, 'totalCO2') + ?? safe(sust, 'statistics', 'totalCO2', 'median'); + metrics.co2_first_party = safe(sust, 'firstParty', 'co2') + ?? safe(sust, 'statistics', 'firstParty', 'co2', 'median'); + metrics.co2_third_party = safe(sust, 'thirdParty', 'co2') + ?? safe(sust, 'statistics', 'thirdParty', 'co2', 'median'); + } + + // Remove null values to avoid overwriting real DB values with NULL + return Object.fromEntries( + Object.entries(metrics).filter(([, v]) => v !== null && v !== undefined) + ); +} diff --git a/queue.js b/queue.js new file mode 100644 index 0000000..dcdfd11 --- /dev/null +++ b/queue.js @@ -0,0 +1,82 @@ +import { runTest } from './runner.js'; +import { parseResults } from './parser.js'; +import { updateJobStatus, updateJobMetrics } from './db.js'; + +// SSE subscribers: jobId -> Set of send functions +const subscribers = new Map(); + +// Job queue +const queue = []; +let running = false; + +export function subscribe(jobId, sendFn) { + if (!subscribers.has(jobId)) subscribers.set(jobId, new Set()); + subscribers.get(jobId).add(sendFn); + return () => { + const set = subscribers.get(jobId); + if (set) { + set.delete(sendFn); + if (set.size === 0) subscribers.delete(jobId); + } + }; +} + +function emit(jobId, event, data) { + const set = subscribers.get(jobId); + if (!set) return; + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const send of set) { + try { send(payload); } catch {} + } +} + +export function enqueue(job) { + queue.push(job); + emit(job.id, 'status', { message: 'Queued, waiting for runner...', phase: 'queued' }); + processQueue(); +} + +async function processQueue() { + if (running || queue.length === 0) return; + running = true; + const job = queue.shift(); + + try { + updateJobStatus(job.id, 'running'); + emit(job.id, 'status', { message: 'Test starting...', phase: 'running' }); + + const outputFolder = await runTest(job, (line) => { + emit(job.id, 'log', { line }); + }); + + emit(job.id, 'status', { message: 'Parsing results...', phase: 'parsing' }); + updateJobStatus(job.id, 'running', { report_folder: outputFolder }); + + let metrics = {}; + try { + metrics = await parseResults(outputFolder, job.url); + updateJobMetrics(job.id, metrics); + } catch (err) { + emit(job.id, 'log', { line: `[parser warning] ${err.message}` }); + } + + updateJobStatus(job.id, 'done', { report_folder: outputFolder }); + emit(job.id, 'status', { message: 'Done!', phase: 'done' }); + emit(job.id, 'done', { jobId: job.id }); + } catch (err) { + updateJobStatus(job.id, 'error', { error_msg: err.message }); + emit(job.id, 'error', { message: err.message }); + } finally { + running = false; + // Small delay then process next + setTimeout(processQueue, 500); + } +} + +export function getQueueLength() { + return queue.length; +} + +export function isRunning() { + return running; +} diff --git a/routes/history.js b/routes/history.js new file mode 100644 index 0000000..9f86d57 --- /dev/null +++ b/routes/history.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { getHistory } from '../db.js'; + +const router = Router(); + +router.get('/', (req, res) => { + const jobs = getHistory(200); + res.render('history', { title: 'Test History', jobs }); +}); + +export default router; diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 0000000..90572a4 --- /dev/null +++ b/routes/index.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +const router = Router(); + +router.get('/', (req, res) => { + res.render('index', { + title: 'Speedboard — Test a URL', + prefillUrl: req.query.url || '', + }); +}); + +export default router; diff --git a/routes/results.js b/routes/results.js new file mode 100644 index 0000000..31b2fad --- /dev/null +++ b/routes/results.js @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { getJob } from '../db.js'; + +const router = Router(); + +router.get('/:id', (req, res) => { + const job = getJob(req.params.id); + if (!job) return res.status(404).render('error', { title: '404', message: 'Job not found.' }); + + if (job.status !== 'done') return res.redirect(`/status/${job.id}`); + + res.render('results', { + title: `Results — ${job.url}`, + job, + reportUrl: job.report_folder + ? `/reports/${job.id}/index.html` + : null, + }); +}); + +export default router; diff --git a/routes/site.js b/routes/site.js new file mode 100644 index 0000000..e64a68b --- /dev/null +++ b/routes/site.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { getSiteHistory } from '../db.js'; + +const router = Router(); + +router.get('/', (req, res) => { + const url = req.query.url; + if (!url) return res.redirect('/history'); + + const jobs = getSiteHistory(url, 20); + res.render('site', { title: `Site History — ${url}`, url, jobs }); +}); + +export default router; diff --git a/routes/status.js b/routes/status.js new file mode 100644 index 0000000..7df6c34 --- /dev/null +++ b/routes/status.js @@ -0,0 +1,74 @@ +import { Router } from 'express'; +import { getJob } from '../db.js'; +import { subscribe } from '../queue.js'; + +const router = Router(); + +// Status page (HTML) +router.get('/:id', (req, res) => { + const job = getJob(req.params.id); + if (!job) return res.status(404).render('error', { title: '404', message: 'Job not found.' }); + + // Already done — redirect immediately + if (job.status === 'done') return res.redirect(`/results/${job.id}`); + if (job.status === 'error') { + return res.render('running', { title: 'Test Failed', job, error: job.error_msg }); + } + + res.render('running', { title: `Testing ${job.url}`, job }); +}); + +// SSE stream +router.get('/:id/stream', (req, res) => { + const job = getJob(req.params.id); + if (!job) { + res.status(404).end(); + return; + } + + // If already done, immediately emit done event + if (job.status === 'done') { + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write(`event: done\ndata: ${JSON.stringify({ jobId: job.id })}\n\n`); + res.end(); + return; + } + + if (job.status === 'error') { + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write(`event: error\ndata: ${JSON.stringify({ message: job.error_msg })}\n\n`); + res.end(); + return; + } + + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + // Keep-alive ping every 15s + const ping = setInterval(() => { + res.write(': ping\n\n'); + }, 15000); + + const unsubscribe = subscribe(job.id, (payload) => { + res.write(payload); + }); + + req.on('close', () => { + clearInterval(ping); + unsubscribe(); + }); +}); + +export default router; diff --git a/routes/test.js b/routes/test.js new file mode 100644 index 0000000..e29d525 --- /dev/null +++ b/routes/test.js @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { createJob } from '../db.js'; +import { enqueue } from '../queue.js'; + +const router = Router(); + +router.post('/', (req, res) => { + let { url, browser, mobile, runs } = req.body; + + // Validate + if (!url || !url.startsWith('http')) { + return res.status(400).render('error', { + title: 'Invalid URL', + message: 'Please provide a valid URL starting with http:// or https://', + }); + } + + browser = ['chrome', 'firefox'].includes(browser) ? browser : 'chrome'; + mobile = mobile === '1' || mobile === 'on' || mobile === true; + runs = Math.min(Math.max(parseInt(runs) || 3, 1), 9); + + const id = uuidv4(); + createJob(id, url, browser, mobile, runs); + enqueue({ id, url, browser, mobile, runs }); + + res.redirect(`/status/${id}`); +}); + +export default router; diff --git a/runner.js b/runner.js new file mode 100644 index 0000000..57d1ac1 --- /dev/null +++ b/runner.js @@ -0,0 +1,68 @@ +import { spawn } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Path to sitespeed.io — works both locally and in Docker +const SITESPEED_BIN = process.env.SITESPEED_BIN || + join(__dirname, '..', 'sitespeed.io', 'bin', 'sitespeed.js'); + +export function runTest(job, onLine) { + return new Promise((resolve, reject) => { + const outputFolder = join(__dirname, 'reports', job.id); + + const args = [ + SITESPEED_BIN, + job.url, + '--browser', job.browser, + '-n', String(job.runs), + '--outputFolder', outputFolder, + '--json', + '--sustainable.enable', + '--axe.enable', + '--coach', + '--headless', + ]; + + if (job.mobile) { + args.push('--mobile'); + } + + // In Docker we need --xvfb false and --browsertime.chrome.args=no-sandbox + if (process.env.IN_DOCKER) { + args.push('--browsertime.chrome.args', 'no-sandbox'); + args.push('--browsertime.chrome.args', 'disable-dev-shm-usage'); + } + + onLine(`[runner] Starting: node ${args.slice(0, 3).join(' ')} ...`); + onLine(`[runner] Output folder: ${outputFolder}`); + + const child = spawn('node', args, { + cwd: __dirname, + env: { ...process.env, DISPLAY: process.env.DISPLAY || ':99' }, + }); + + child.stdout.on('data', (data) => { + const lines = data.toString().split('\n').filter(Boolean); + for (const line of lines) onLine(line); + }); + + child.stderr.on('data', (data) => { + const lines = data.toString().split('\n').filter(Boolean); + for (const line of lines) onLine(`[stderr] ${line}`); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(outputFolder); + } else { + reject(new Error(`sitespeed.io exited with code ${code}`)); + } + }); + + child.on('error', (err) => { + reject(new Error(`Failed to spawn sitespeed.io: ${err.message}`)); + }); + }); +} diff --git a/views/error.pug b/views/error.pug new file mode 100644 index 0000000..e28bf98 --- /dev/null +++ b/views/error.pug @@ -0,0 +1,7 @@ +extends layout + +block content + div(class='text-center py-20') + h1(class='text-4xl font-bold text-red-600 mb-4')= title + p(class='text-gray-600 mb-6')= message + a(href='/' class='bg-indigo-600 text-white px-5 py-2 rounded hover:bg-indigo-700') Back Home diff --git a/views/history.pug b/views/history.pug new file mode 100644 index 0000000..9a955d4 --- /dev/null +++ b/views/history.pug @@ -0,0 +1,51 @@ +extends layout + +block content + div(class='flex items-center justify-between mb-6') + h1(class='text-2xl font-bold') Test History + a(href='/' class='bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700') + New Test + + if jobs.length === 0 + div(class='text-center py-20 text-gray-400') + p No tests yet. Run your first test! + + else + div(class='overflow-x-auto') + table(class='w-full bg-white border border-gray-200 rounded-xl overflow-hidden text-sm') + thead(class='bg-gray-50 text-gray-500 text-xs uppercase') + tr + th(class='px-4 py-3 text-left') URL + th(class='px-4 py-3 text-center') Status + th(class='px-4 py-3 text-center') Browser + th(class='px-4 py-3 text-right') LCP + th(class='px-4 py-3 text-right') FCP + th(class='px-4 py-3 text-right') TBT + th(class='px-4 py-3 text-right') Score + th(class='px-4 py-3 text-right') Tested + th(class='px-4 py-3 text-center') Actions + tbody + each job in jobs + tr(class='border-t border-gray-100 hover:bg-gray-50') + td(class='px-4 py-3 max-w-xs') + a(href=`/site?url=${encodeURIComponent(job.url)}` class='text-indigo-600 hover:underline truncate block max-w-xs' title=job.url)= job.url + td(class='px-4 py-3 text-center') + span(class=`text-xs px-2 py-0.5 rounded-full font-medium ${job.status === 'done' ? 'bg-green-100 text-green-700' : job.status === 'error' ? 'bg-red-100 text-red-700' : job.status === 'running' ? 'bg-blue-100 text-blue-700' : 'bg-yellow-100 text-yellow-700'}`) + = job.status + td(class='px-4 py-3 text-center text-gray-600')= job.browser + (job.mobile ? ' 📱' : '') + td(class='px-4 py-3 text-right')= job.lcp ? (job.lcp / 1000).toFixed(2) + 's' : '—' + td(class='px-4 py-3 text-right')= job.fcp ? (job.fcp / 1000).toFixed(2) + 's' : '—' + td(class='px-4 py-3 text-right')= job.tbt ? job.tbt.toFixed(0) + 'ms' : '—' + td(class='px-4 py-3 text-right') + if job.score_performance + span(class=`font-bold ${job.score_performance >= 90 ? 'text-green-600' : job.score_performance >= 50 ? 'text-yellow-600' : 'text-red-600'}`) + = job.score_performance + else + = '—' + td(class='px-4 py-3 text-right text-gray-400 text-xs') + = new Date(job.created_at).toLocaleDateString() + td(class='px-4 py-3 text-center') + if job.status === 'done' + a(href=`/results/${job.id}` class='text-indigo-600 hover:underline text-xs mr-2') Results + else if job.status === 'running' || job.status === 'queued' + a(href=`/status/${job.id}` class='text-blue-600 hover:underline text-xs mr-2') Live + a(href=`/?url=${encodeURIComponent(job.url)}` class='text-gray-500 hover:underline text-xs') Retest diff --git a/views/index.pug b/views/index.pug new file mode 100644 index 0000000..c69a535 --- /dev/null +++ b/views/index.pug @@ -0,0 +1,45 @@ +extends layout + +block content + div(class='max-w-2xl mx-auto') + h1(class='text-3xl font-bold mb-2 text-indigo-700') Website Speed Test + p(class='text-gray-500 mb-8') Test any URL with sitespeed.io. Get Core Web Vitals, coach scores, resources, accessibility & CO₂ metrics. + + form(action='/test' method='POST' class='bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-5') + div + label(for='url' class='block text-sm font-semibold mb-1') URL + input#url( + type='url' + name='url' + required + placeholder='https://example.com' + value=prefillUrl + class='w-full border border-gray-300 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500' + ) + + div(class='grid grid-cols-3 gap-4') + div + label(for='browser' class='block text-sm font-semibold mb-1') Browser + select#browser(name='browser' class='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500') + option(value='chrome') Chrome + option(value='firefox') Firefox + + div + label(for='runs' class='block text-sm font-semibold mb-1') Runs + select#runs(name='runs' class='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500') + option(value='1') 1 run + option(value='3' selected) 3 runs + option(value='5') 5 runs + option(value='9') 9 runs + + div(class='flex items-end pb-0.5') + label(class='flex items-center gap-2 cursor-pointer') + input#mobile(type='checkbox' name='mobile' value='1' class='w-4 h-4 text-indigo-600') + span(class='text-sm font-semibold') Mobile mode + + button(type='submit' class='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-lg transition') + | ⚡ Run Test + + if prefillUrl + script. + document.getElementById('url').value = decodeURIComponent('!{encodeURIComponent(prefillUrl)}'); diff --git a/views/layout.pug b/views/layout.pug new file mode 100644 index 0000000..7030f64 --- /dev/null +++ b/views/layout.pug @@ -0,0 +1,21 @@ +doctype html +html(lang='en') + head + meta(charset='UTF-8') + meta(name='viewport' content='width=device-width, initial-scale=1.0') + title= title + ' | Speedboard' + script(src='https://cdn.tailwindcss.com') + style. + .log-line { font-family: monospace; font-size: 0.78rem; } + .score-circle { border-radius: 50%; display:inline-flex; align-items:center; justify-content:center; font-weight:700; } + .metric-card { background:#fff; border:1px solid #e5e7eb; border-radius:0.5rem; padding:1rem; } + body(class='bg-gray-50 text-gray-800 min-h-screen') + nav(class='bg-indigo-700 text-white px-6 py-3 flex items-center gap-6 shadow') + a(href='/' class='font-bold text-lg tracking-tight') ⚡ Speedboard + a(href='/history' class='text-indigo-200 hover:text-white text-sm') History + a(href='/' class='text-indigo-200 hover:text-white text-sm') New Test + main(class='max-w-6xl mx-auto px-4 py-8') + block content + footer(class='text-center text-xs text-gray-400 py-6') + | Powered by  + a(href='https://www.sitespeed.io' target='_blank' class='underline') sitespeed.io diff --git a/views/partials/axe.pug b/views/partials/axe.pug new file mode 100644 index 0000000..58ce36e --- /dev/null +++ b/views/partials/axe.pug @@ -0,0 +1,15 @@ +- function num(v) { return v != null ? v : '—'; } +- const hasAxe = job.axe_critical != null || job.axe_serious != null + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-4') Accessibility (axe) + if !hasAxe + p(class='text-sm text-gray-400') No axe data collected. + else + div(class='grid grid-cols-2 sm:grid-cols-4 gap-3') + each item in [['Critical', job.axe_critical, 'bg-red-50 border-red-300 text-red-700'], ['Serious', job.axe_serious, 'bg-orange-50 border-orange-300 text-orange-700'], ['Moderate', job.axe_moderate, 'bg-yellow-50 border-yellow-300 text-yellow-700'], ['Minor', job.axe_minor, 'bg-blue-50 border-blue-300 text-blue-700']] + div(class=`metric-card border ${item[2]} text-center`) + div(class='text-2xl font-bold')= num(item[1]) + div(class='text-xs mt-1')= item[0] + if job.axe_critical === 0 && job.axe_serious === 0 + p(class='text-sm text-green-600 mt-3 font-medium') No critical or serious violations found. diff --git a/views/partials/co2.pug b/views/partials/co2.pug new file mode 100644 index 0000000..ab004b7 --- /dev/null +++ b/views/partials/co2.pug @@ -0,0 +1,12 @@ +- function g(v) { return v != null ? v.toFixed(4)+' g CO₂' : '—'; } + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-4') Sustainability & CO₂ + if job.co2_per_page_view == null + p(class='text-sm text-gray-400') No CO₂ data collected. + else + div(class='grid grid-cols-2 sm:grid-cols-4 gap-3') + each item in [['Per page view', job.co2_per_page_view], ['Total', job.co2_total], ['First-party', job.co2_first_party], ['Third-party', job.co2_third_party]] + div(class='metric-card text-center') + div(class='text-lg font-bold text-green-700')= g(item[1]) + div(class='text-xs text-gray-500 mt-1')= item[0] diff --git a/views/partials/cwv.pug b/views/partials/cwv.pug new file mode 100644 index 0000000..3043ae9 --- /dev/null +++ b/views/partials/cwv.pug @@ -0,0 +1,17 @@ +- function ms(v) { return v != null ? (v/1000).toFixed(2)+'s' : '—'; } +- function raw(v, unit) { return v != null ? v.toFixed(unit === 'ms' ? 0 : 3)+unit : '—'; } +- function cwvClass(metric, val) { +- if (val == null) return 'bg-gray-50 border-gray-200'; +- const thresholds = { lcp: [2500, 4000], fcp: [1800, 3000], tbt: [200, 600], cls: [0.1, 0.25], ttfb: [800, 1800] }; +- const t = thresholds[metric]; +- if (!t) return 'bg-gray-50 border-gray-200'; +- return val <= t[0] ? 'bg-green-50 border-green-300' : val <= t[1] ? 'bg-yellow-50 border-yellow-300' : 'bg-red-50 border-red-300'; +- } + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-4') Core Web Vitals + div(class='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3') + each item in [['LCP', 'lcp', ms(job.lcp)], ['FCP', 'fcp', ms(job.fcp)], ['TBT', 'tbt', raw(job.tbt,'ms')], ['CLS', 'cls', raw(job.cls,'')], ['TTFB', 'ttfb', ms(job.ttfb)]] + div(class=`metric-card border ${cwvClass(item[1], job[item[1]])} text-center`) + div(class='text-2xl font-bold')= item[2] + div(class='text-xs text-gray-500 mt-1')= item[0] diff --git a/views/partials/resources.pug b/views/partials/resources.pug new file mode 100644 index 0000000..bb9c657 --- /dev/null +++ b/views/partials/resources.pug @@ -0,0 +1,25 @@ +- function kb(v) { return v != null ? (v/1024).toFixed(1)+' KB' : '—'; } +- function num(v) { return v != null ? v : '—'; } + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-4') Resources + div(class='grid grid-cols-1 md:grid-cols-2 gap-6') + div + h3(class='text-xs font-semibold text-gray-400 uppercase mb-2') Transfer Size + table(class='w-full text-sm') + tbody + each row in [['Total', job.transfer_total], ['HTML', job.transfer_html], ['JavaScript', job.transfer_js], ['CSS', job.transfer_css], ['Images', job.transfer_image], ['Fonts', job.transfer_font]] + tr(class='border-b border-gray-100') + td(class='py-1.5 text-gray-500')= row[0] + td(class='py-1.5 text-right font-medium')= kb(row[1]) + tr(class='border-t border-gray-200') + td(class='py-1.5 text-gray-500') Third-party + td(class='py-1.5 text-right font-medium')= kb(job.third_party_transfer) + div + h3(class='text-xs font-semibold text-gray-400 uppercase mb-2') Request Counts + table(class='w-full text-sm') + tbody + each row in [['Total requests', job.requests_total], ['JavaScript', job.requests_js], ['CSS', job.requests_css], ['Images', job.requests_image], ['Fonts', job.requests_font], ['Third-party', job.third_party_requests]] + tr(class='border-b border-gray-100') + td(class='py-1.5 text-gray-500')= row[0] + td(class='py-1.5 text-right font-medium')= num(row[1]) diff --git a/views/partials/scorecard.pug b/views/partials/scorecard.pug new file mode 100644 index 0000000..a7f63c5 --- /dev/null +++ b/views/partials/scorecard.pug @@ -0,0 +1,12 @@ +- function scoreColor(s) { return s == null ? '#9ca3af' : s >= 90 ? '#22c55e' : s >= 50 ? '#f59e0b' : '#ef4444'; } +- function scoreBg(s) { return s == null ? 'bg-gray-100 text-gray-400' : s >= 90 ? 'bg-green-100 text-green-700' : s >= 50 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'; } +- const scores = [['Overall', job.score_overall], ['Performance', job.score_performance], ['Accessibility', job.score_accessibility], ['Best Practice', job.score_bestpractice], ['Privacy', job.score_privacy]] + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-4') Coach Scores + div(class='flex flex-wrap gap-4 justify-start') + each item in scores + div(class='flex flex-col items-center gap-1') + div(class=`score-circle w-16 h-16 text-xl ${scoreBg(item[1])}`) + = item[1] != null ? Math.round(item[1]) : '?' + span(class='text-xs text-gray-500 text-center')= item[0] diff --git a/views/partials/timings.pug b/views/partials/timings.pug new file mode 100644 index 0000000..0c84ad3 --- /dev/null +++ b/views/partials/timings.pug @@ -0,0 +1,23 @@ +- function ms(v) { return v != null ? v.toFixed(0)+' ms' : '—'; } +- const rows = [ +- ['Page Load Time', job.page_load_time], +- ['Fully Loaded', job.fully_loaded], +- ['DOM Content Loaded', job.dom_content_loaded], +- ['DOM Interactive', job.dom_interactive], +- ['Speed Index', job.speed_index], +- ['First Visual Change', job.first_visual_change], +- ['Last Visual Change', job.last_visual_change], +- ['Visual Complete 85%', job.visual_complete_85], +- ['Perceptual Speed Index', job.perceptual_speed_index], +- ['Front End Time', job.front_end_time], +- ['Back End Time', job.back_end_time], +- ['Max Potential FID', job.max_potential_fid], +- ] + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-4') Navigation & Visual Timings + div(class='grid grid-cols-2 sm:grid-cols-3 gap-2') + each row in rows + div(class='bg-gray-50 rounded-lg px-3 py-2 flex justify-between items-center') + span(class='text-xs text-gray-500')= row[0] + span(class='text-sm font-semibold')= ms(row[1]) diff --git a/views/results.pug b/views/results.pug new file mode 100644 index 0000000..f07a921 --- /dev/null +++ b/views/results.pug @@ -0,0 +1,34 @@ +extends layout + +block content + div(class='space-y-6') + //- Header + div(class='flex flex-wrap items-start justify-between gap-4') + div + h1(class='text-2xl font-bold break-all')= job.url + p(class='text-xs text-gray-400 mt-1') + | Tested #{new Date(job.finished_at).toLocaleString()} • + | #{job.browser} • #{job.mobile ? 'Mobile' : 'Desktop'} • #{job.runs} run(s) + div(class='flex gap-2 flex-wrap') + a(href=`/?url=${encodeURIComponent(job.url)}` class='text-sm bg-indigo-600 text-white px-4 py-1.5 rounded hover:bg-indigo-700') Retest + a(href=`/site?url=${encodeURIComponent(job.url)}` class='text-sm bg-gray-200 text-gray-700 px-4 py-1.5 rounded hover:bg-gray-300') Site History + if reportUrl + a(href=reportUrl target='_blank' class='text-sm bg-green-600 text-white px-4 py-1.5 rounded hover:bg-green-700') Full Report ↗ + + //- Coach Scores + include partials/scorecard + + //- Core Web Vitals + include partials/cwv + + //- Navigation Timings + include partials/timings + + //- Resources + include partials/resources + + //- Accessibility + include partials/axe + + //- CO2 + include partials/co2 diff --git a/views/running.pug b/views/running.pug new file mode 100644 index 0000000..e89c3f2 --- /dev/null +++ b/views/running.pug @@ -0,0 +1,73 @@ +extends layout + +block content + div(class='max-w-3xl mx-auto') + div(class='flex items-center justify-between mb-4') + div + h1(class='text-2xl font-bold') Testing URL + p(class='text-sm text-gray-500 truncate max-w-xl')= job.url + span#status-badge(class='text-xs px-3 py-1 rounded-full bg-yellow-100 text-yellow-800 font-semibold') Queued + + if error + div(class='bg-red-50 border border-red-300 text-red-700 rounded-lg p-4 mb-4') + strong Test failed:  + = error + + div(class='bg-gray-900 text-green-400 rounded-xl p-4 h-96 overflow-y-auto text-xs font-mono' id='log-box') + p(class='text-gray-500') Waiting for output... + + div(class='mt-4 text-sm text-gray-500 flex gap-4') + span Browser:  + strong= job.browser + span Runs:  + strong= job.runs + span Mode:  + strong= job.mobile ? 'Mobile' : 'Desktop' + + script. + const jobId = '!{job.id}'; + const logBox = document.getElementById('log-box'); + const badge = document.getElementById('status-badge'); + let firstLine = true; + + const es = new EventSource(`/status/${jobId}/stream`); + + es.addEventListener('log', (e) => { + const d = JSON.parse(e.data); + if (firstLine) { logBox.innerHTML = ''; firstLine = false; } + const el = document.createElement('div'); + el.className = 'log-line'; + el.textContent = d.line; + logBox.appendChild(el); + logBox.scrollTop = logBox.scrollHeight; + }); + + es.addEventListener('status', (e) => { + const d = JSON.parse(e.data); + badge.textContent = d.message; + badge.className = 'text-xs px-3 py-1 rounded-full font-semibold ' + + (d.phase === 'done' ? 'bg-green-100 text-green-800' : + d.phase === 'error' ? 'bg-red-100 text-red-800' : + d.phase === 'parsing' ? 'bg-blue-100 text-blue-800' : + 'bg-yellow-100 text-yellow-800'); + }); + + es.addEventListener('done', (e) => { + es.close(); + badge.textContent = 'Done! Redirecting...'; + badge.className = 'text-xs px-3 py-1 rounded-full bg-green-100 text-green-800 font-semibold'; + setTimeout(() => { window.location.href = `/results/${jobId}`; }, 800); + }); + + es.addEventListener('error', (e) => { + es.close(); + badge.textContent = 'Error'; + badge.className = 'text-xs px-3 py-1 rounded-full bg-red-100 text-red-800 font-semibold'; + try { + const d = JSON.parse(e.data); + const el = document.createElement('div'); + el.className = 'text-red-400 log-line'; + el.textContent = '[ERROR] ' + d.message; + logBox.appendChild(el); + } catch {} + }); diff --git a/views/site.pug b/views/site.pug new file mode 100644 index 0000000..44146e9 --- /dev/null +++ b/views/site.pug @@ -0,0 +1,78 @@ +extends layout + +block content + div(class='space-y-6') + div(class='flex items-start justify-between gap-4 flex-wrap') + div + h1(class='text-xl font-bold break-all')= url + p(class='text-sm text-gray-400 mt-1') #{jobs.length} completed test(s) on record + a(href=`/?url=${encodeURIComponent(url)}` class='bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700') + Retest + + if jobs.length === 0 + div(class='text-center py-16 text-gray-400') No completed tests found for this URL. + + else + //- LCP trend chart + div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-3') LCP Trend (ms) + canvas#lcpChart(height='80') + + //- Timeline table + div(class='overflow-x-auto') + table(class='w-full bg-white border border-gray-200 rounded-xl overflow-hidden text-sm') + thead(class='bg-gray-50 text-gray-500 text-xs uppercase') + tr + th(class='px-4 py-3 text-left') Date + th(class='px-4 py-3 text-center') Browser + th(class='px-4 py-3 text-right') LCP + th(class='px-4 py-3 text-right') FCP + th(class='px-4 py-3 text-right') TBT + th(class='px-4 py-3 text-right') Speed Index + th(class='px-4 py-3 text-right') Perf Score + th(class='px-4 py-3 text-right') Transfer + th + tbody + each job in jobs + tr(class='border-t border-gray-100 hover:bg-gray-50') + td(class='px-4 py-3 text-gray-500 text-xs')= new Date(job.finished_at).toLocaleString() + td(class='px-4 py-3 text-center text-gray-600 text-xs')= job.browser + (job.mobile ? ' 📱' : '') + td(class='px-4 py-3 text-right')= job.lcp ? (job.lcp/1000).toFixed(2)+'s' : '—' + td(class='px-4 py-3 text-right')= job.fcp ? (job.fcp/1000).toFixed(2)+'s' : '—' + td(class='px-4 py-3 text-right')= job.tbt ? job.tbt.toFixed(0)+'ms' : '—' + td(class='px-4 py-3 text-right')= job.speed_index ? job.speed_index.toFixed(0) : '—' + td(class='px-4 py-3 text-right') + if job.score_performance + span(class=`font-bold ${job.score_performance >= 90 ? 'text-green-600' : job.score_performance >= 50 ? 'text-yellow-600' : 'text-red-600'}`) + = job.score_performance + else + = '—' + td(class='px-4 py-3 text-right text-xs') + = job.transfer_total ? (job.transfer_total/1024).toFixed(0)+'KB' : '—' + td(class='px-4 py-3') + a(href=`/results/${job.id}` class='text-indigo-600 hover:underline text-xs') View + + script(src='https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js') + script. + const jobs = !{JSON.stringify(jobs.map(j => ({ date: j.finished_at, lcp: j.lcp })))}; + const labels = jobs.map(j => new Date(j.date).toLocaleDateString()).reverse(); + const data = jobs.map(j => j.lcp || null).reverse(); + new Chart(document.getElementById('lcpChart'), { + type: 'line', + data: { + labels, + datasets: [{ + label: 'LCP (ms)', + data, + borderColor: '#6366f1', + backgroundColor: 'rgba(99,102,241,0.1)', + tension: 0.3, + fill: true, + pointRadius: 4, + }] + }, + options: { + responsive: true, + plugins: { legend: { display: false } }, + scales: { y: { beginAtZero: true } } + } + });