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

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