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:
7
views/error.pug
Normal file
7
views/error.pug
Normal 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
51
views/history.pug
Normal 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
45
views/index.pug
Normal 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
21
views/layout.pug
Normal 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
|
||||
a(href='https://www.sitespeed.io' target='_blank' class='underline') sitespeed.io
|
||||
15
views/partials/axe.pug
Normal file
15
views/partials/axe.pug
Normal 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
12
views/partials/co2.pug
Normal 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 & 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
17
views/partials/cwv.pug
Normal 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]
|
||||
25
views/partials/resources.pug
Normal file
25
views/partials/resources.pug
Normal 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])
|
||||
12
views/partials/scorecard.pug
Normal file
12
views/partials/scorecard.pug
Normal 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]
|
||||
23
views/partials/timings.pug
Normal file
23
views/partials/timings.pug
Normal 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
34
views/results.pug
Normal 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()} •
|
||||
| #{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
|
||||
73
views/running.pug
Normal file
73
views/running.pug
Normal 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:
|
||||
= 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 {}
|
||||
});
|
||||
78
views/site.pug
Normal file
78
views/site.pug
Normal 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 } }
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user