nxstats/internal/report/template.go
2026-08-24 15:43:03 -04:00

502 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package report
// reportTemplate is the HTML template for a single report (either the only
// report, or one day's report in --split-by-day mode). Its stylesheet and
// chart/map colors are supplied at render time via the active theme
// (see themes.go and GenerateHTML).
const reportTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nxstats — Nginx Report{{if .DayTitle}} · {{.DayTitle}}{{end}}</title>
{{if .GeoLocations}}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{{end}}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>{{.ThemeCSS}}{{.ThemeExtraCSS}}</style>
</head>
<body>
<header>
<h1>// nxstats //</h1>
<div class="subtitle">nginx log analysis report{{if .DayTitle}} &mdash; {{.DayTitle}}{{end}} &mdash; generated {{.GeneratedAt.Format "2006-01-02 15:04:05 UTC"}}</div>
</header>
{{if .IndexFile}}
<nav class="nav-back">
<a href="{{.IndexFile}}">&larr; Back to Index</a>
</nav>
{{end}}
<div class="container">
<!-- Stats Cards -->
<div class="cards">
<div class="card">
<div class="label">Total Requests</div>
<div class="value">{{.TotalRequests}}</div>
</div>
<div class="card">
<div class="label">Unique IPs</div>
<div class="value">{{.UniqueIPs}}</div>
</div>
<div class="card">
<div class="label">Total Bytes</div>
<div class="value">{{formatBytes .TotalBytes}}</div>
</div>
<div class="card">
<div class="label">Error Rate</div>
<div class="value">{{formatFloat .ErrorRate}}%</div>
</div>
</div>
<!-- Charts Row -->
<div class="section">
<h2>// Charts</h2>
<div class="charts-row">
<div class="chart-card">
<h3>Status Code Distribution</h3>
<canvas id="chartStatus"></canvas>
</div>
<div class="chart-card">
<h3>Top 10 Paths</h3>
<canvas id="chartPaths"></canvas>
</div>
<div class="chart-card">
<h3>Requests per Hour</h3>
<canvas id="chartTimeline"></canvas>
</div>
</div>
</div>
{{if .GeoLocations}}
<!-- GeoIP Map -->
<div class="section">
<h2>// Geographic Distribution</h2>
<div id="map"></div>
</div>
{{end}}
<!-- Top IPs -->
<div class="section">
<h2>// Top IPs</h2>
<div class="table-wrap">
<table id="tableTopIPs">
<thead><tr>
<th>#</th><th>IP Address</th><th>Requests</th>
</tr></thead>
<tbody>
{{range $i, $ip := .TopIPs}}
<tr>
<td class="rank">{{add $i 1}}</td>
<td>{{$ip.IP}}</td>
<td>{{$ip.Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Top Paths -->
<div class="section">
<h2>// Top Paths</h2>
<div class="table-wrap">
<table id="tableTopPaths">
<thead><tr>
<th>#</th><th>Path</th><th>Requests</th><th>Top Status</th>
</tr></thead>
<tbody>
{{range $i, $p := .TopPaths}}
<tr>
<td class="rank">{{add $i 1}}</td>
<td>{{$p.Path}}</td>
<td>{{$p.Count}}</td>
<td>{{statusBadge $p.TopStatus}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Access Log -->
<div class="section">
<h2>// Access Log</h2>
<div class="table-controls">
<div class="search-bar">
<label for="searchAccess">Filter:</label>
<input type="text" id="searchAccess" placeholder="Search access log..." onkeyup="accessSearch(this.value)">
</div>
<div class="page-size-select">
<label for="accessPageSize">Rows:</label>
<select id="accessPageSize" onchange="accessSetPageSize(+this.value)">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</div>
<span id="accessPageInfo" class="page-info"></span>
<div class="page-btns">
<button id="accessPrev" onclick="accessChangePage(-1)" disabled>&#8592; Prev</button>
<button id="accessNext" onclick="accessChangePage(1)">Next &#8594;</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Time</th><th>IP</th><th>Method</th><th>Path</th><th>Status</th><th>Bytes</th><th>User Agent</th>
</tr></thead>
<tbody id="accessTbody"></tbody>
</table>
</div>
</div>
<!-- Error Log -->
<div class="section">
<h2>// Error Log</h2>
<div class="table-controls">
<div class="search-bar">
<label for="searchError">Filter:</label>
<input type="text" id="searchError" placeholder="Search error log..." onkeyup="errorSearch(this.value)">
</div>
<div class="page-size-select">
<label for="errorPageSize">Rows:</label>
<select id="errorPageSize" onchange="errorSetPageSize(+this.value)">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</div>
<span id="errorPageInfo" class="page-info"></span>
<div class="page-btns">
<button id="errorPrev" onclick="errorChangePage(-1)" disabled>&#8592; Prev</button>
<button id="errorNext" onclick="errorChangePage(1)">Next &#8594;</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Time</th><th>Level</th><th>PID</th><th>Conn</th><th>Message</th>
</tr></thead>
<tbody id="errorTbody"></tbody>
</table>
</div>
</div>
</div><!-- /container -->
<footer>nxstats &mdash; nginx log parser &amp; report generator</footer>
<script>
// ── Chart.js defaults ──────────────────────────────────────────────────────
Chart.defaults.color = '{{.ChartText}}';
Chart.defaults.borderColor = '{{.ChartGrid}}';
Chart.defaults.font.family = "{{.ChartFont}}";
const chartC1 = '{{.Chart1}}';
const chartC2 = '{{.Chart2}}';
const chartC3 = '{{.Chart3}}';
const chartWarn = '{{.ChartWarn}}';
const chartErr = '{{.ChartErr}}';
// ── Status Codes Chart ─────────────────────────────────────────────────────
(function(){
const rawStatuses = {{statusJSON .StatusCounts}};
if (!rawStatuses || Object.keys(rawStatuses).length === 0) return;
const labels = Object.keys(rawStatuses).sort();
const values = labels.map(k => rawStatuses[k]);
const colors = labels.map(k => {
const n = parseInt(k);
if (n < 300) return chartC1;
if (n < 400) return chartC2;
if (n < 500) return chartWarn;
return chartErr;
});
new Chart(document.getElementById('chartStatus'), {
type: 'bar',
data: { labels, datasets: [{ data: values, backgroundColor: colors, borderWidth: 0 }] },
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
})();
// ── Top Paths Chart ────────────────────────────────────────────────────────
(function(){
const paths = {{topPathsJSON .TopPaths}};
if (!paths || paths.length === 0) return;
const labels = paths.map(p => p.path);
const values = paths.map(p => p.count);
new Chart(document.getElementById('chartPaths'), {
type: 'bar',
data: { labels, datasets: [{ data: values, backgroundColor: chartC3, borderWidth: 0 }] },
options: {
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true } }
}
});
})();
// ── Timeline Chart ─────────────────────────────────────────────────────────
(function(){
const ts = {{timeSeriesJSON .TimeSeriesData}};
if (!ts || ts.length === 0) return;
const labels = ts.map(p => p.hour);
const values = ts.map(p => p.count);
new Chart(document.getElementById('chartTimeline'), {
type: 'line',
data: { labels, datasets: [{
data: values,
borderColor: chartC2,
backgroundColor: '{{.Chart2Fill}}',
tension: 0.3,
fill: true,
pointRadius: 3
}]},
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
})();
// ── HTML escape helper ─────────────────────────────────────────────────────
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Status / level badge helpers ───────────────────────────────────────────
function statusBadge(s) {
const cls = s >= 500 ? 's5xx' : s >= 400 ? 's4xx' : s >= 300 ? 's3xx' : 's2xx';
return '<span class="status ' + cls + '">' + s + '</span>';
}
function levelBadge(l) {
return '<span class="level l-' + esc(l) + '">' + esc(l) + '</span>';
}
// ── Generic paginator factory ──────────────────────────────────────────────
function makePaginator(cfg) {
let filtered = cfg.data;
let page = 0;
let pageSize = 100;
function render() {
const start = page * pageSize;
const slice = filtered.slice(start, start + pageSize);
document.getElementById(cfg.tbodyId).innerHTML = slice.map(cfg.rowFn).join('');
const total = filtered.length;
const end = Math.min(start + pageSize, total);
document.getElementById(cfg.infoId).textContent =
total === 0 ? 'No results' : (start + 1) + '' + end + ' of ' + total;
document.getElementById(cfg.prevId).disabled = page === 0;
document.getElementById(cfg.nextId).disabled = end >= total;
}
render();
return {
search: function(q) {
q = q.toLowerCase();
filtered = q
? cfg.data.filter(function(r) {
return Object.values(r).some(function(v) {
return String(v).toLowerCase().indexOf(q) !== -1;
});
})
: cfg.data;
page = 0;
render();
},
changePage: function(delta) {
const maxPage = Math.ceil(filtered.length / pageSize) - 1;
page = Math.max(0, Math.min(page + delta, maxPage));
render();
},
setPageSize: function(n) {
pageSize = n;
page = 0;
render();
}
};
}
// ── Access log paginator ───────────────────────────────────────────────────
const accessPager = makePaginator({
data: {{accessEntriesJSON .AccessEntries}},
tbodyId: 'accessTbody',
infoId: 'accessPageInfo',
prevId: 'accessPrev',
nextId: 'accessNext',
rowFn: function(r) {
return '<tr>' +
'<td>' + esc(r.t) + '</td>' +
'<td>' + esc(r.ip) + '</td>' +
'<td>' + esc(r.m) + '</td>' +
'<td>' + esc(r.p) + '</td>' +
'<td>' + statusBadge(r.s) + '</td>' +
'<td>' + r.b + '</td>' +
'<td>' + esc(r.ua) + '</td>' +
'</tr>';
}
});
function accessSearch(q) { accessPager.search(q); }
function accessChangePage(d) { accessPager.changePage(d); }
function accessSetPageSize(n) { accessPager.setPageSize(n); }
// ── Error log paginator ────────────────────────────────────────────────────
const errorPager = makePaginator({
data: {{errorEntriesJSON .ErrorEntries}},
tbodyId: 'errorTbody',
infoId: 'errorPageInfo',
prevId: 'errorPrev',
nextId: 'errorNext',
rowFn: function(r) {
return '<tr>' +
'<td>' + esc(r.t) + '</td>' +
'<td>' + levelBadge(r.l) + '</td>' +
'<td>' + r.pid + '</td>' +
'<td>' + (r.cid || '-') + '</td>' +
'<td>' + esc(r.msg) + '</td>' +
'</tr>';
}
});
function errorSearch(q) { errorPager.search(q); }
function errorChangePage(d) { errorPager.changePage(d); }
function errorSetPageSize(n) { errorPager.setPageSize(n); }
{{if .GeoLocations}}
// ── Leaflet Map ────────────────────────────────────────────────────────────
(function(){
function countryFlag(cc) {
if (!cc || cc.length !== 2) return '';
return Array.from(cc.toUpperCase()).map(function(c) {
return String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65);
}).join('');
}
const map = L.map('map').setView([20, 0], 2);
L.tileLayer('{{.MapTileURL}}', {
attribution: '{{.MapAttribution}}',
maxZoom: 18
}).addTo(map);
const geoData = {{geoJSON .GeoLocations}};
geoData.forEach(function(pt) {
const flag = countryFlag(pt.cc);
const label = flag ? flag + '\u00a0' + pt.cc : (pt.cc || '??');
const marker = L.circleMarker([pt.lat, pt.lon], {
radius: Math.min(4 + Math.log(pt.count + 1) * 3, 20),
fillColor: '{{.MarkerColor}}',
color: '{{.MarkerColor}}',
weight: 1,
opacity: 0.9,
fillOpacity: 0.5
});
marker.bindTooltip(label, {
permanent: true,
direction: 'top',
offset: [0, -4],
className: 'map-label'
});
marker.bindPopup(
'<b>' + esc(pt.ip) + '</b><br>' +
flag + ' ' + esc(pt.country) + '<br>' +
pt.count + ' request' + (pt.count === 1 ? '' : 's')
);
marker.addTo(map);
});
})();
{{end}}
</script>
</body>
</html>
`
// indexTemplate is the HTML template for the index page generated by
// --split-by-day. It lists all daily reports with aggregate stats. Its
// stylesheet is supplied at render time via the active theme (see
// themes.go and GenerateIndexHTML); .day-link and .err-* rules live in
// each theme's shared CSS block.
const indexTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nxstats — Daily Index</title>
<style>{{.ThemeCSS}}</style>
</head>
<body>
<header>
<h1>// nxstats //</h1>
<div class="subtitle">daily index &mdash; generated {{.GeneratedAt.Format "2006-01-02 15:04:05 UTC"}}</div>
</header>
<div class="container">
<!-- Aggregate Stats Cards -->
<div class="cards">
<div class="card">
<div class="label">Total Requests</div>
<div class="value">{{.TotalRequests}}</div>
</div>
<div class="card">
<div class="label">Unique IPs</div>
<div class="value">{{.TotalUniqueIPs}}</div>
</div>
<div class="card">
<div class="label">Total Bytes</div>
<div class="value">{{formatBytes .TotalBytes}}</div>
</div>
<div class="card">
<div class="label">Overall Error Rate</div>
<div class="value {{errorClass .OverallErrorRate}}">{{formatFloat .OverallErrorRate}}%</div>
</div>
<div class="card">
<div class="label">Days Covered</div>
<div class="value">{{len .Days}}</div>
</div>
</div>
<!-- Daily Breakdown Table -->
<div class="section">
<h2>// Daily Breakdown</h2>
<div class="table-wrap">
<table>
<thead><tr>
<th>Date</th>
<th>Requests</th>
<th>Unique IPs</th>
<th>Bytes</th>
<th>Error Rate</th>
</tr></thead>
<tbody>
{{range .Days}}
<tr>
<td><a class="day-link" href="{{.Filename}}">{{fmtDate .Date}}</a></td>
<td>{{.Requests}}</td>
<td>{{.UniqueIPs}}</td>
<td>{{formatBytes .TotalBytes}}</td>
<td class="{{errorClass .ErrorRate}}">{{formatFloat .ErrorRate}}%</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div><!-- /container -->
<footer>nxstats &mdash; nginx log parser &amp; report generator</footer>
</body>
</html>
`