Add a Stats page to the web UI
Statistics used to live only in terdut-tui; the account page said so. The page shows the same figures as the TUI's Stats tab -- incident counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All) that the TUI does not have. The ranges are day-granular because the server reads from/to as whole UTC dates, so there is no 24h chip. No server change: the page uses the existing /api/stats/* endpoints, which already scope to the caller's teams. Charts are inline SVG and plain elements sized from script, because the CSP forbids inline styles, inline scripts and CDN libraries. Removes the "statistics are in terdut-tui" notes from the account page and the README.
This commit is contained in:
@@ -51,8 +51,9 @@ archive), who is on call, the alert feed, and an *Account* tab for your own
|
|||||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
|
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
|
||||||
it navigates through a hamburger menu and has a sticky action bar, it follows the
|
it navigates through a hamburger menu and has a sticky action bar, it follows the
|
||||||
system's dark mode, and it can be added to the home screen. From 900px wide it switches
|
system's dark mode, and it can be added to the home screen. From 900px wide it switches
|
||||||
to a sidebar with the queue and the incident side by side. Statistics remain in
|
to a sidebar with the queue and the incident side by side. The Stats page shows
|
||||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
incident counts, MTTA and MTTR, and alert frequency by name, hour and day over a
|
||||||
|
chosen range.
|
||||||
|
|
||||||
You sign in with a username and password. Users have no password until one is
|
You sign in with a username and password. Users have no password until one is
|
||||||
set, and a user without one can only use API keys:
|
set, and a user without one can only use API keys:
|
||||||
|
|||||||
@@ -867,3 +867,40 @@ button.rota-day:hover { background: var(--surface-2); }
|
|||||||
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||||
.overview-item p { margin: 4px 0 0; }
|
.overview-item p { margin: 4px 0 0; }
|
||||||
|
|
||||||
|
/* ---------- stats page ---------- */
|
||||||
|
|
||||||
|
#view-stats .chips { padding-left: 0; padding-right: 0; }
|
||||||
|
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
|
||||||
|
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.stat-tile {
|
||||||
|
--sev: var(--border-strong);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--sev); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); padding: 12px;
|
||||||
|
}
|
||||||
|
.stat-tile.st-triggered { --sev: var(--crit); }
|
||||||
|
.stat-tile.st-acknowledged { --sev: var(--warn); }
|
||||||
|
.stat-tile.st-resolved { --sev: var(--ok); }
|
||||||
|
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
|
||||||
|
.chart-card + .chart-card { margin-top: 0; }
|
||||||
|
.chart-title {
|
||||||
|
margin-bottom: 10px; font-size: 13px; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.hbars { list-style: none; display: grid; gap: 6px; }
|
||||||
|
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
|
||||||
|
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
|
||||||
|
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.columns { display: block; width: 100%; height: auto; }
|
||||||
|
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
|
||||||
|
.columns .col-hit { fill: transparent; }
|
||||||
|
.columns .col-bar { fill: var(--accent); }
|
||||||
|
.columns .col:hover .col-bar { opacity: 0.75; }
|
||||||
|
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,10 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||||
<span class="nav-label">Alerts</span>
|
<span class="nav-label">Alerts</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
|
||||||
|
<span class="nav-label">Stats</span>
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
||||||
<span class="nav-label">Team</span>
|
<span class="nav-label">Team</span>
|
||||||
@@ -128,6 +132,7 @@
|
|||||||
|
|
||||||
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
||||||
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||||
|
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
|
||||||
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
||||||
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||||
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ function render() {
|
|||||||
|
|
||||||
h('div', { class: 'page-head' }),
|
h('div', { class: 'page-head' }),
|
||||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||||||
h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
|||||||
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
||||||
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
||||||
|
|
||||||
|
// stats
|
||||||
|
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
|
||||||
|
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
|
||||||
|
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
|
||||||
|
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
|
||||||
|
|
||||||
// alerts
|
// alerts
|
||||||
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as queue from './queue.js';
|
|||||||
import * as incident from './incident.js';
|
import * as incident from './incident.js';
|
||||||
import * as oncall from './oncall.js';
|
import * as oncall from './oncall.js';
|
||||||
import * as alerts from './alerts.js';
|
import * as alerts from './alerts.js';
|
||||||
|
import * as stats from './stats.js';
|
||||||
import * as account from './account.js';
|
import * as account from './account.js';
|
||||||
import * as team from './team.js';
|
import * as team from './team.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
@@ -23,6 +24,7 @@ const SECTIONS = {
|
|||||||
queue: { title: 'Queue', view: queue },
|
queue: { title: 'Queue', view: queue },
|
||||||
oncall: { title: 'On-call', view: oncall },
|
oncall: { title: 'On-call', view: oncall },
|
||||||
alerts: { title: 'Alerts', view: alerts },
|
alerts: { title: 'Alerts', view: alerts },
|
||||||
|
stats: { title: 'Stats', view: stats },
|
||||||
team: { title: 'Team', view: team },
|
team: { title: 'Team', view: team },
|
||||||
admin: { title: 'Admin', view: admin },
|
admin: { title: 'Admin', view: admin },
|
||||||
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||||
@@ -36,6 +38,7 @@ const NAV_ITEMS = [
|
|||||||
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||||
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||||
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
||||||
|
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
|
||||||
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
||||||
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
||||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
||||||
@@ -58,7 +61,7 @@ function parseRoute(pathname) {
|
|||||||
if (t) return { section: 'admin', tab: t.tab };
|
if (t) return { section: 'admin', tab: t.tab };
|
||||||
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||||
if (tt) return { section: 'team', tab: tt.tab };
|
if (tt) return { section: 'team', tab: tt.tab };
|
||||||
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
|
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Statistics: how many incidents, how fast they are answered, and when and
|
||||||
|
// what the alerts are. The same figures the TUI's Stats tab shows, over a
|
||||||
|
// range picked with the chips. The server scopes them to the caller's teams.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, emptyState, spinner } from './ui.js';
|
||||||
|
import { duration } from './format.js';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// `days` counts back from today, inclusive; the server reads from/to as UTC
|
||||||
|
// dates, so these are too.
|
||||||
|
const RANGES = [
|
||||||
|
{ id: 'today', label: 'Today', days: 1 },
|
||||||
|
{ id: '7d', label: '7d', days: 7 },
|
||||||
|
{ id: '30d', label: '30d', days: 30 },
|
||||||
|
{ id: '90d', label: '90d', days: 90 },
|
||||||
|
{ id: 'all', label: 'All', days: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-stats');
|
||||||
|
|
||||||
|
let range = '30d';
|
||||||
|
let data = null;
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
function query(r) {
|
||||||
|
if (!r.days) return {};
|
||||||
|
const now = Date.now();
|
||||||
|
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
render();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
const requested = range;
|
||||||
|
const q = query(RANGES.find((x) => x.id === range));
|
||||||
|
try {
|
||||||
|
const [incidents, top, byHour, byDay] = await Promise.all([
|
||||||
|
api.statsIncidents(q),
|
||||||
|
api.statsTop({ ...q, limit: 10 }),
|
||||||
|
api.statsByHour(q),
|
||||||
|
api.statsByDay(q),
|
||||||
|
]);
|
||||||
|
if (requested !== range) return;
|
||||||
|
data = { incidents, top, byHour, byDay };
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
if (requested !== range) return;
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange(id) {
|
||||||
|
if (id === range) return;
|
||||||
|
range = id;
|
||||||
|
data = null;
|
||||||
|
render();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Time range' },
|
||||||
|
RANGES.map((r) => h('button', {
|
||||||
|
class: 'chip',
|
||||||
|
type: 'button',
|
||||||
|
role: 'tab',
|
||||||
|
'aria-selected': String(r.id === range),
|
||||||
|
onclick: () => setRange(r.id),
|
||||||
|
text: r.label,
|
||||||
|
})));
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
||||||
|
else if (!data) body = spinner();
|
||||||
|
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
|
||||||
|
body = emptyState('No data in this range', '', 'chart');
|
||||||
|
} else {
|
||||||
|
body = h('div', { class: 'stats' },
|
||||||
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
tiles(data.incidents),
|
||||||
|
data.top.length > 0 && card('Top alerts', topAlerts(data.top)),
|
||||||
|
card('Alerts by hour (UTC)', columns(
|
||||||
|
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
|
||||||
|
'Alerts by hour of day')),
|
||||||
|
card('Alerts by day', columns(
|
||||||
|
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
|
||||||
|
'Alerts by day of week')));
|
||||||
|
}
|
||||||
|
clear(view(), h('div', {}, chips, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing mean means nothing has been acknowledged or resolved yet.
|
||||||
|
const mean = (s) => (s == null ? '—' : duration(s * 1000));
|
||||||
|
|
||||||
|
function tiles(s) {
|
||||||
|
const tile = (label, value, cls = '') => h('div', { class: `stat-tile ${cls}` },
|
||||||
|
h('div', { class: 'stat-value', text: String(value) }),
|
||||||
|
h('div', { class: 'stat-label', text: label }));
|
||||||
|
return h('div', { class: 'stat-tiles' },
|
||||||
|
tile('Incidents', s.total),
|
||||||
|
tile('Triggered', s.triggered, 'st-triggered'),
|
||||||
|
tile('Acknowledged', s.acknowledged, 'st-acknowledged'),
|
||||||
|
tile('Resolved', s.resolved, 'st-resolved'),
|
||||||
|
tile('Mean time to acknowledge', mean(s.mtta_seconds)),
|
||||||
|
tile('Mean time to resolve', mean(s.mttr_seconds)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(title, content) {
|
||||||
|
return h('section', { class: 'chart-card card card-pad' },
|
||||||
|
h('h3', { class: 'chart-title', text: title }), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranked names with a bar scaled to the busiest one.
|
||||||
|
function topAlerts(items) {
|
||||||
|
const max = Math.max(...items.map((x) => x.count), 1);
|
||||||
|
return h('ol', { class: 'hbars' }, items.map((x) => {
|
||||||
|
const fill = h('span', { class: 'hbar-fill' });
|
||||||
|
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
|
||||||
|
return h('li', { class: 'hbar' },
|
||||||
|
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
|
||||||
|
h('span', { class: 'hbar-track' }, fill),
|
||||||
|
h('span', { class: 'hbar-count', text: String(x.count) }));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
function svg(tag, attrs = {}, text) {
|
||||||
|
const el = document.createElementNS(SVG_NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||||
|
if (text != null) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A column chart: one bar per item, the value in a tooltip, and a label under
|
||||||
|
// the items marked `tick`.
|
||||||
|
function columns(items, label) {
|
||||||
|
const W = 480;
|
||||||
|
const H = 140;
|
||||||
|
const base = H - 18;
|
||||||
|
const step = W / items.length;
|
||||||
|
const max = Math.max(...items.map((x) => x.value), 1);
|
||||||
|
const root = svg('svg', {
|
||||||
|
class: 'columns', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': label,
|
||||||
|
});
|
||||||
|
root.appendChild(svg('line', { class: 'axis', x1: 0, x2: W, y1: base, y2: base }));
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
const bh = it.value ? Math.max(2, (it.value / max) * (base - 6)) : 0;
|
||||||
|
const x = i * step + step * 0.15;
|
||||||
|
const g = svg('g', { class: 'col' });
|
||||||
|
g.appendChild(svg('title', {}, `${it.label}: ${it.value}`));
|
||||||
|
// A full-height transparent hit area, so a tiny bar is still hoverable.
|
||||||
|
g.appendChild(svg('rect', { class: 'col-hit', x: i * step, y: 0, width: step, height: base }));
|
||||||
|
if (bh) g.appendChild(svg('rect', { class: 'col-bar', x, y: base - bh, width: step * 0.7, height: bh, rx: 2 }));
|
||||||
|
root.appendChild(g);
|
||||||
|
if (it.tick) {
|
||||||
|
root.appendChild(svg('text', { class: 'col-label', x: i * step + step / 2, y: H - 4, 'text-anchor': 'middle' }, it.label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ const ICONS = {
|
|||||||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||||||
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
||||||
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
||||||
|
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
|
||||||
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
||||||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||||||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatsPageIsEmbedded(t *testing.T) {
|
||||||
|
sub, err := fs.Sub(files, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
index, err := fs.ReadFile(sub, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`id="view-stats"`, `data-section="stats"`} {
|
||||||
|
if !strings.Contains(string(index), want) {
|
||||||
|
t.Errorf("index.html lacks %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(sub, "js/stats.js"); err != nil {
|
||||||
|
t.Errorf("js/stats.js not embedded: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user