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

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