Beyond the overview, the dashboard needs detailed report pages. This module builds two focused reports: a speed report showing Web Vitals distributions and per-page performance, and an error report showing error frequency, trends, and expandable details.
The speed report answers the question every site owner asks: how fast is my site? It combines three layers of data into a single page:
All data comes from GET /api/performance, which returns per-page aggregates from the performance table. The API accepts start and end query parameters for date filtering.
The card backgrounds use the Web Vitals color scheme: green for good, orange for needs work, red for poor. This lets someone glance at the page and immediately know the site's health without reading any numbers.
Google defines three Core Web Vitals, each with specific thresholds that determine whether the user experience is good, needs improvement, or poor. The speed report uses these thresholds to color-code every metric:
| Metric | Good | Needs Work | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2500ms | 2500 – 4000ms | > 4000ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1 – 0.25 | > 0.25 |
| INP (Interaction to Next Paint) | < 200ms | 200 – 500ms | > 500ms |
The color values used in the report:
#0cce6b (green)#ffa400 (orange)#ff4e42 (red)These are the official Web Vitals colors from Google's web.dev documentation. Using the standard colors means anyone familiar with Web Vitals will instantly understand the report without a legend.
PERCENTILE_CONT(0.75).
The speed-report.html file is a self-contained page with inline CSS and JavaScript. Here is how it works step by step.
On page load, and whenever the user changes the date range, the page fetches data from the reporting API:
async function loadData() {
const start = document.getElementById('startDate').value;
const end = document.getElementById('endDate').value;
const url = '/api/performance?start=' +
encodeURIComponent(start) + '&end=' + encodeURIComponent(end);
const resp = await fetch(url, { credentials: 'include' });
const json = await resp.json();
if (!json.success) {
showError(json.error);
return;
}
renderCards(json.data.byPage);
renderTable(json.data.byPage);
}
The credentials: 'include' option sends the session cookie with the cross-origin request. Without it, the API would return 401 because the session cookie would not be included.
The card rendering function computes site-wide averages across all pages, then applies the threshold color:
function getVitalColor(metric, value) {
const thresholds = {
lcp: { good: 2500, poor: 4000 },
cls: { good: 0.1, poor: 0.25 },
inp: { good: 200, poor: 500 }
};
const t = thresholds[metric];
if (value < t.good) return '#0cce6b';
if (value < t.poor) return '#ffa400';
return '#ff4e42';
}
function renderCards(byPage) {
// Compute weighted averages across all pages
let totalLcp = 0, totalCls = 0, totalSamples = 0;
for (const row of byPage) {
totalLcp += row.avg_lcp * row.samples;
totalCls += row.avg_cls * row.samples;
totalSamples += row.samples;
}
const avgLcp = totalSamples ? totalLcp / totalSamples : 0;
const avgCls = totalSamples ? totalCls / totalSamples : 0;
// Set card backgrounds
setCard('lcpCard', avgLcp.toFixed(0) + 'ms', getVitalColor('lcp', avgLcp));
setCard('clsCard', avgCls.toFixed(3), getVitalColor('cls', avgCls));
}
The setCard helper updates the card's value text and background color. It uses textContent rather than innerHTML to prevent any XSS from unexpected data values.
The table is built by iterating over the byPage array and creating rows. URLs are inserted using textContent since they come from user-generated data (the pages visitors actually visited):
function renderTable(byPage) {
const tbody = document.getElementById('perfBody');
tbody.innerHTML = '';
for (const row of byPage) {
const tr = document.createElement('tr');
if (row.avg_load_ms > 3000) {
tr.style.borderLeft = '4px solid #ff4e42';
}
const urlTd = document.createElement('td');
urlTd.textContent = row.url; // XSS-safe
tr.appendChild(urlTd);
const fields = ['avg_load_ms', 'avg_ttfb_ms', 'avg_lcp', 'avg_cls', 'samples'];
for (const f of fields) {
const td = document.createElement('td');
td.textContent = row[f];
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
url field comes from the analytics database, which stores whatever URL the visitor was on. A malicious visitor could craft a URL containing <script> tags. Using textContent ensures the URL is rendered as plain text, not parsed as HTML. Never use innerHTML for user-sourced data.
The error report helps developers find and fix JavaScript errors affecting real users. It combines four elements:
Data comes from GET /api/errors, which returns two datasets: byMessage (grouped errors with counts) and trend (daily error counts for the chart).
The error-report.html file follows the same self-contained pattern. Let's walk through the key pieces.
async function loadData() {
const start = document.getElementById('startDate').value;
const end = document.getElementById('endDate').value;
const url = '/api/errors?start=' +
encodeURIComponent(start) + '&end=' + encodeURIComponent(end);
const resp = await fetch(url, { credentials: 'include' });
const json = await resp.json();
if (!json.success) {
showError(json.error);
return;
}
renderSummary(json.data.byMessage);
renderChart(json.data.trend);
renderTable(json.data.byMessage);
}
The error trend chart reuses the canvas line chart pattern from the overview page. The key function draws a line graph on a <canvas> element:
function renderChart(trend) {
const canvas = document.getElementById('trendChart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const pad = { top: 20, right: 20, bottom: 40, left: 50 };
ctx.clearRect(0, 0, W, H);
if (trend.length === 0) return;
const maxVal = Math.max(...trend.map(d => d.error_count));
const xStep = (W - pad.left - pad.right) / (trend.length - 1 || 1);
// Draw axes
ctx.strokeStyle = '#ccc';
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left, H - pad.bottom);
ctx.lineTo(W - pad.right, H - pad.bottom);
ctx.stroke();
// Draw line
ctx.strokeStyle = '#2E86C1';
ctx.lineWidth = 2;
ctx.beginPath();
trend.forEach((d, i) => {
const x = pad.left + i * xStep;
const y = H - pad.bottom -
(d.error_count / (maxVal || 1)) * (H - pad.top - pad.bottom);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Draw dots and labels
// ... (abbreviated — see full source in error-report.html)
}
The chart scales dynamically to the data range. The y-axis maps from 0 to the maximum daily error count, and the x-axis spaces days evenly across the canvas width.
The error table uses a simple click-to-expand pattern. Each error row is followed by a hidden detail row:
function renderTable(byMessage) {
const tbody = document.getElementById('errorBody');
tbody.innerHTML = '';
for (const row of byMessage) {
// Summary row (always visible)
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const msgTd = document.createElement('td');
msgTd.textContent = row.error_message; // XSS-safe
tr.appendChild(msgTd);
const countTd = document.createElement('td');
countTd.textContent = row.occurrences;
tr.appendChild(countTd);
const dateTd = document.createElement('td');
dateTd.textContent = row.last_seen;
tr.appendChild(dateTd);
// Detail row (hidden by default)
const detailTr = document.createElement('tr');
detailTr.style.display = 'none';
const detailTd = document.createElement('td');
detailTd.colSpan = 3;
detailTd.style.background = '#f8f9fa';
detailTd.style.whiteSpace = 'pre-wrap';
detailTd.style.fontFamily = 'monospace';
detailTd.style.fontSize = '12px';
detailTd.textContent = row.error_message; // Full message
detailTr.appendChild(detailTd);
// Toggle on click
tr.addEventListener('click', () => {
detailTr.style.display =
detailTr.style.display === 'none' ? '' : 'none';
});
tbody.appendChild(tr);
tbody.appendChild(detailTr);
}
}
https://example.com/<img src=x onerror=alert(1)> — if the page throws an error containing this URL, and you render it with innerHTML, the script executes. Always use textContent.
These report pages can be integrated into the dashboard in two ways, depending on your architecture:
If the dashboard uses single-page app routing with hash fragments, add routes for #/performance and #/errors:
// In the main dashboard router
function handleRoute(hash) {
switch (hash) {
case '#/':
loadOverview();
break;
case '#/performance':
loadPerformanceReport();
break;
case '#/errors':
loadErrorReport();
break;
case '#/admin':
loadAdminPanel();
break;
default:
loadOverview();
}
}
Each route function would fetch the appropriate API endpoint and render the content into the main content area of the SPA.
The demo files (speed-report.html and error-report.html) work as standalone pages. Link to them directly from the dashboard navigation:
<nav>
<a href="overview.html">Overview</a>
<a href="speed-report.html">Performance</a>
<a href="error-report.html">Errors</a>
<a href="admin.html">Admin</a>
</nav>
Standalone pages are simpler to build and debug. Each page manages its own state and data fetching. The trade-off is that navigation causes a full page reload rather than a smooth SPA transition. For an analytics dashboard where users spend most of their time on a single report, this trade-off is often acceptable.