The dashboard is where analytics data becomes visual and actionable. It is a single-page application that consumes the reporting API and renders charts, tables, and summary cards. Users log in, select date ranges, and explore their site's performance, errors, and traffic.
The dashboard provides four main views, each focused on a different aspect of your analytics data. Every view fetches its data from the corresponding reporting API endpoint and renders it client-side.
The landing page after login. It presents a high-level snapshot of site activity: summary cards showing total page views, unique sessions, average load time, and error count for the selected date range. Below the cards, a line chart plots page views over time (one data point per day). At the bottom, a top pages table lists the most-visited URLs ranked by view count.
API endpoint: GET /api/overview?start=...&end=...
Focuses on Core Web Vitals and load times. Displays median and p75 values for LCP, CLS, INP, FCP, TTFB, and full load time. A bar chart compares these metrics against Google's "good," "needs improvement," and "poor" thresholds. A per-page breakdown table shows which pages are fastest and which are dragging down the averages.
API endpoint: GET /api/performance?start=...&end=...
Shows JavaScript error frequency and trends. A line chart plots error count per day. A ranked table groups errors by message, showing occurrence count, most-affected page, and most-affected browser. Clicking an error message reveals the full stack trace and a timeline of when the error first and last appeared.
API endpoint: GET /api/errors?start=...&end=...
User management panel, restricted to owner and admin roles. Lists all dashboard accounts with their email, display name, role, and last login date. Allows creating new users, changing roles, and deleting accounts. The owner account cannot be deleted or demoted.
API endpoint: GET /api/users, POST /api/users, PUT /api/users/:id, DELETE /api/users/:id
The dashboard is a single-page application (SPA). One HTML file is served to the browser. JavaScript handles all routing, data fetching, and rendering. The page never fully reloads after the initial load.
The browser loads dashboard.html once. A JavaScript router listens for hashchange events on window.location.hash. When the hash changes (e.g., from #/overview to #/errors), the router calls the corresponding view function. That function uses fetch() to request data from the reporting API, then renders HTML into the main content area using DOM manipulation.
No server-side rendering is involved. The server's only job is to serve the static HTML/JS/CSS files and respond to API requests. This separation makes the dashboard a pure API consumer — it could be hosted on any static file server.
Hash fragments (#/overview) do not trigger a server request. The browser handles them entirely on the client side. This means no server configuration is needed to handle client-side routes — no rewrite rules, no catch-all routes. The server sees every request as a request for dashboard.html, regardless of the hash.
Each screen in the dashboard maps to a hash route, an API endpoint, and a set of UI components. The following table is the complete inventory:
| Screen | API Endpoint | Key Components | User Roles |
|---|---|---|---|
| Login | POST /api/login |
Email input, password input, submit button, error message area | All (unauthenticated) |
| Overview | GET /api/overview |
4 summary cards, line chart (pageviews/day), top pages table | owner, admin, viewer |
| Performance Report | GET /api/performance |
Web Vitals gauges, bar chart (metric distribution), per-page table | owner, admin, viewer |
| Error Report | GET /api/errors |
Error trend line chart, grouped error table, stack trace detail panel | owner, admin, viewer |
| Admin Panel | GET/POST/PUT/DELETE /api/users |
User list table, create user form, role dropdown, delete button | owner, admin |
viewer navigates to #/admin, they are redirected to #/overview. The API also enforces this server-side — client-side checks are for UX, not security.
The dashboard needs two chart types: line charts for trends over time (page views per day, error frequency) and bar charts for comparisons (Web Vitals distribution, top pages). That is it. No pie charts, no scatter plots, no complex visualizations.
The HTML <canvas> element provides a 2D drawing context. You can draw lines, rectangles, text, and arcs with simple JavaScript calls. For a line chart, the steps are:
ctx.moveTo() and ctx.lineTo()ctx.fillText()ctx.lineTo() for each pointctx.stroke()For a bar chart, replace the line path with ctx.fillRect() calls for each bar.
| Option | Size | Tradeoff |
|---|---|---|
| Vanilla Canvas | 0 KB (built-in) | Full control, educational value, more code to write |
| Chart.js | ~200 KB | Feature-rich, well-documented, responsive out of the box |
| uPlot | ~35 KB | Extremely fast rendering, time-series focused, minimal API |
The dashboard uses a classic sidebar + header + content layout built with CSS Grid. The sidebar provides navigation between views. The header displays the logo, a date range picker, and user account controls. The main content area renders whichever view is currently active.
The layout is defined with a two-column grid. The sidebar occupies a fixed-width column, the content area takes the remaining space, and the header and footer span both columns:
.dashboard-shell {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header"
"sidebar content"
"footer footer";
min-height: 100vh;
}
.dashboard-header { grid-area: header; }
.dashboard-sidebar { grid-area: sidebar; }
.dashboard-content { grid-area: content; }
.dashboard-footer { grid-area: footer; }
The sidebar contains anchor links that set the URL hash: <a href="#/overview">Overview</a>, <a href="#/performance">Speed</a>, etc. The router highlights the active link by comparing the current hash to each link's href. No page reload occurs — only the content area updates.
#/overview)<input type="date"> fields for start and end dates#/loginThe dashboard must work on tablets and phones, not just desktop monitors. The layout adapts at a 768px breakpoint.
overflow-x: auto so they scroll horizontally instead of breaking the layout
/* Desktop: sidebar + content side by side */
.dashboard-shell {
grid-template-columns: 200px 1fr;
}
/* Mobile: single column, sidebar hidden */
@media (max-width: 768px) {
.dashboard-shell {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"content"
"footer";
}
.dashboard-sidebar {
display: none; /* toggled via JS hamburger button */
}
.dashboard-sidebar.open {
display: block;
position: fixed;
top: 0; left: 0;
width: 240px; height: 100vh;
z-index: 200;
background: #2c3e50;
}
.summary-cards {
grid-template-columns: 1fr; /* stack vertically */
}
.table-wrapper {
overflow-x: auto; /* horizontal scroll for wide tables */
}
}
Every data view in the dashboard is filtered by a date range. The date range picker sits in the header and applies globally to whichever view is currently active.
When the dashboard loads, the default range is the last 30 days: start date is today minus 30 days, end date is today. The picker displays these defaults and the initial data fetch uses them.
When the user changes either date input, the dashboard:
#/overview?start=2024-01-01&end=2024-01-31Storing the date range in the URL hash means users can bookmark a specific view with a specific date range and return to it later. Sharing the URL with a teammate opens the same view with the same dates. The router parses the hash on page load to restore the state:
// Parse hash: #/overview?start=2024-01-01&end=2024-01-31
function parseHash(hash) {
const [path, query] = hash.replace('#', '').split('?');
const params = new URLSearchParams(query || '');
return {
route: path || '/overview',
start: params.get('start') || defaultStart(),
end: params.get('end') || defaultEnd()
};
}
function defaultStart() {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().slice(0, 10);
}
function defaultEnd() {
return new Date().toISOString().slice(0, 10);
}
Every fetch() call to the reporting API is asynchronous. The user needs visual feedback while data is loading, and clear messaging when something goes wrong.
While a fetch is in progress, display a skeleton screen or spinner in the content area. Skeleton screens are preferred because they hint at the structure of the incoming content (gray rectangles where cards will appear, a placeholder box where the chart will render). This reduces perceived wait time.
function showLoading(container) {
container.innerHTML = `
<div class="skeleton-cards">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
<div class="skeleton-chart"></div>
`;
}
async function loadOverview(start, end) {
const content = document.getElementById('content');
showLoading(content);
try {
const res = await fetch(`/api/overview?start=${start}&end=${end}`);
if (!res.ok) throw new Error(`API returned ${res.status}`);
const data = await res.json();
renderOverview(content, data);
} catch (err) {
showError(content, err.message);
}
}
When the API returns an error, display a clear message in the content area with the HTTP status code and a retry button. Do not silently fail. Do not show a blank screen.
If any API response returns 401 Unauthorized, the auth token has expired or is invalid. The dashboard should immediately clear the stored token and redirect to the login screen (#/login). Do not show the data view with stale or missing data.
async function apiFetch(url) {
const token = localStorage.getItem('token');
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.status === 401) {
localStorage.removeItem('token');
window.location.hash = '#/login';
return null;
}
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
An analytics dashboard renders data that originated from untrusted sources: visitor URLs, user-agent strings, error messages, referrer headers, and page titles. Even though this data was sanitized during ingestion at the collector and server-processing layers, defense in depth demands that the dashboard also protect itself.
Use textContent, not innerHTML, when rendering any value that originated from analytics data. textContent treats the value as plain text and will not execute HTML or JavaScript embedded in it. innerHTML parses the string as HTML, which means a malicious URL like <img src=x onerror=alert(1)> would execute if rendered with innerHTML.
// SAFE: textContent escapes HTML entities automatically
const cell = document.createElement('td');
cell.textContent = row.url; // "<script>alert(1)</script>" renders as text
// DANGEROUS: innerHTML parses and executes HTML
cell.innerHTML = row.url; // <script>alert(1)</script> EXECUTES
document.write() — it replaces the entire document and parses its argument as HTMLeval() — it executes arbitrary JavaScript. There is no legitimate reason to use it in a dashboardhref attributes — if rendering clickable links from analytics URLs, validate that the URL starts with http:// or https://. A javascript: URL in an href executes when clicked${variable} is just as dangerous as innerHTML if the variable contains unsanitized datatextContent for all user-sourced fields. Every layer assumes the previous layer might have missed something. That is how you prevent XSS.
Build table rows and card content using DOM methods instead of HTML string concatenation:
function buildPageRow(page) {
const tr = document.createElement('tr');
const urlCell = document.createElement('td');
urlCell.textContent = page.url; // safe: never innerHTML
tr.appendChild(urlCell);
const viewsCell = document.createElement('td');
viewsCell.textContent = page.views; // safe: numeric, but still use textContent
tr.appendChild(viewsCell);
return tr;
}
// Build the table body safely:
const tbody = document.getElementById('top-pages-body');
tbody.innerHTML = ''; // clear previous rows (safe: no user data)
data.topPages.forEach(page => {
tbody.appendChild(buildPageRow(page));
});
Build the login screen, authenticate against the reporting API, store the JWT token, and protect dashboard routes from unauthenticated access.
Start Module →Render summary cards, draw a line chart of daily page views on canvas, and build the top pages table with data from the overview API endpoint.
Start Module →Build the performance report with Web Vitals bar charts and the error report with trend lines, grouped error tables, and stack trace detail panels.
Start Module →Create the user management interface: list accounts, add new users, change roles, and delete accounts. Enforce role-based access on both client and API.
Start Module →