feat: initial Speedboard implementation

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:36:13 +02:00
commit 280e5f133f
28 changed files with 1222 additions and 0 deletions

7
views/error.pug Normal file
View File

@@ -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

51
views/history.pug Normal file
View File

@@ -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

45
views/index.pug Normal file
View File

@@ -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)}');

21
views/layout.pug Normal file
View File

@@ -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&nbsp;
a(href='https://www.sitespeed.io' target='_blank' class='underline') sitespeed.io

15
views/partials/axe.pug Normal file
View File

@@ -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.

12
views/partials/co2.pug Normal file
View File

@@ -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 &amp; 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]

17
views/partials/cwv.pug Normal file
View File

@@ -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]

View File

@@ -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])

View File

@@ -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]

View File

@@ -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])

34
views/results.pug Normal file
View File

@@ -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()} &bull;
| #{job.browser} &bull; #{job.mobile ? 'Mobile' : 'Desktop'} &bull; #{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

73
views/running.pug Normal file
View File

@@ -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:&nbsp;
= 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:&nbsp;
strong= job.browser
span Runs:&nbsp;
strong= job.runs
span Mode:&nbsp;
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 {}
});

78
views/site.pug Normal file
View File

@@ -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 } }
}
});