nxstats/internal/report/template.go
2026-08-24 14:44:54 -04:00

747 lines
24 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
// cyberpunkCSS is the shared stylesheet used by both the daily report and
// the index page. It does not include map-specific rules or page-specific
// overrides (those live in reportExtraCSS / each template's own <style>).
const cyberpunkCSS = `
:root {
--bg: #050510;
--bg2: #0a0a20;
--green: #00ff9f;
--cyan: #00e5ff;
--magenta: #ff00c8;
--text: #c0c0e0;
--border: #1a1a3a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Courier New', monospace;
font-size: 14px;
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
body::after {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.08) 2px,
rgba(0,0,0,0.08) 4px
);
pointer-events: none;
z-index: 9999;
}
header {
background: var(--bg2);
border-bottom: 2px solid var(--magenta);
padding: 24px 32px;
text-align: center;
}
header h1 {
font-size: 2.4rem;
color: var(--cyan);
text-shadow: 0 0 20px var(--cyan), 0 0 40px rgba(0,229,255,0.5);
letter-spacing: 6px;
text-transform: uppercase;
}
header .subtitle {
color: var(--green);
text-shadow: 0 0 10px var(--green);
margin-top: 6px;
font-size: 0.85rem;
letter-spacing: 3px;
}
.nav-back {
padding: 10px 32px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
}
.nav-back a {
color: var(--magenta);
text-decoration: none;
font-size: 0.85rem;
letter-spacing: 2px;
text-shadow: 0 0 8px var(--magenta);
}
.nav-back a:hover { text-shadow: 0 0 16px var(--magenta); }
.container { max-width: 1400px; margin: 0 auto; padding: 24px 16px; }
h2 {
color: var(--cyan);
text-shadow: 0 0 12px var(--cyan);
font-size: 1.2rem;
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.section { margin-bottom: 40px; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.card {
background: var(--bg2);
border: 1px solid var(--magenta);
border-radius: 4px;
padding: 20px;
box-shadow: 0 0 16px rgba(255,0,200,0.15), inset 0 0 20px rgba(0,0,0,0.3);
text-align: center;
}
.card .label {
color: var(--green);
text-shadow: 0 0 8px var(--green);
font-size: 0.75rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 10px;
}
.card .value {
color: var(--cyan);
text-shadow: 0 0 16px var(--cyan);
font-size: 1.8rem;
font-weight: bold;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.chart-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
box-shadow: 0 0 12px rgba(0,229,255,0.08);
}
.chart-card h3 {
color: var(--green);
text-shadow: 0 0 8px var(--green);
font-size: 0.8rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 14px;
}
canvas { max-width: 100%; }
.table-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.search-bar { display: flex; align-items: center; gap: 10px; }
.search-bar input {
background: var(--bg2);
border: 1px solid var(--green);
color: var(--green);
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 6px 12px;
width: 280px;
outline: none;
border-radius: 2px;
box-shadow: 0 0 8px rgba(0,255,159,0.15);
}
.search-bar input::placeholder { color: rgba(0,255,159,0.4); }
.search-bar label {
color: var(--green);
font-size: 0.8rem;
letter-spacing: 1px;
text-transform: uppercase;
}
.page-info { color: rgba(0,255,159,0.6); font-size: 0.8rem; letter-spacing: 1px; min-width: 140px; }
.page-size-select { display: flex; align-items: center; gap: 6px; }
.page-size-select label { color: var(--green); font-size: 0.8rem; letter-spacing: 1px; text-transform: uppercase; }
.page-size-select select {
background: var(--bg2);
border: 1px solid var(--green);
color: var(--green);
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 4px 8px;
outline: none;
border-radius: 2px;
cursor: pointer;
}
.page-btns { display: flex; gap: 8px; margin-left: auto; }
.page-btns button {
background: transparent;
border: 1px solid var(--cyan);
color: var(--cyan);
font-family: 'Courier New', monospace;
font-size: 12px;
padding: 5px 14px;
cursor: pointer;
border-radius: 2px;
letter-spacing: 1px;
transition: background 0.15s, box-shadow 0.15s;
}
.page-btns button:hover:not(:disabled) { background: rgba(0,229,255,0.1); box-shadow: 0 0 8px rgba(0,229,255,0.3); }
.page-btns button:disabled { opacity: 0.3; cursor: default; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: rgba(0,229,255,0.05);
color: var(--cyan);
text-shadow: 0 0 6px var(--cyan);
font-size: 0.75rem;
letter-spacing: 2px;
text-transform: uppercase;
padding: 10px 14px;
border-bottom: 1px solid var(--cyan);
text-align: left;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; }
tbody tr:hover { background: rgba(0,255,159,0.04); }
tbody td { padding: 8px 14px; color: var(--text); vertical-align: top; word-break: break-all; }
tbody td:first-child { color: var(--green); font-weight: bold; white-space: nowrap; }
.status { display: inline-block; padding: 1px 6px; border-radius: 3px; font-weight: bold; font-size: 12px; }
.s2xx { color: #00ff9f; border: 1px solid #00ff9f; text-shadow: 0 0 6px #00ff9f; }
.s3xx { color: #00e5ff; border: 1px solid #00e5ff; }
.s4xx { color: #ffcc00; border: 1px solid #ffcc00; }
.s5xx { color: #ff4444; border: 1px solid #ff4444; text-shadow: 0 0 8px #ff4444; }
.level { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
.l-error, .l-crit, .l-alert, .l-emerg { color: #ff4444; border: 1px solid #ff4444; }
.l-warn { color: #ffcc00; border: 1px solid #ffcc00; }
.l-info, .l-notice { color: #00e5ff; border: 1px solid #00e5ff; }
.l-debug { color: #888; border: 1px solid #444; }
.rank { color: var(--magenta); text-shadow: 0 0 6px var(--magenta); }
footer {
text-align: center;
padding: 24px;
color: rgba(192,192,224,0.4);
font-size: 0.75rem;
border-top: 1px solid var(--border);
letter-spacing: 2px;
}
`
// reportExtraCSS holds CSS only used in daily/single reports (map, map labels).
const reportExtraCSS = `
#map { height: 480px; border: 1px solid var(--magenta); border-radius: 4px; background: #0a0a20; }
.map-label {
background: rgba(5,5,16,0.85) !important;
border: 1px solid var(--magenta) !important;
border-radius: 3px !important;
color: #fff !important;
font-family: 'Courier New', monospace !important;
font-size: 11px !important;
padding: 2px 5px !important;
white-space: nowrap !important;
box-shadow: 0 0 6px rgba(255,0,200,0.4) !important;
pointer-events: none !important;
}
.map-label::before { display: none !important; }
`
// reportTemplate is the cyberpunk-themed HTML template for a single report
// (either the only report, or one day's report in --split-by-day mode).
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>` + cyberpunkCSS + reportExtraCSS + `</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 = '#c0c0e0';
Chart.defaults.borderColor = '#1a1a3a';
Chart.defaults.font.family = "'Courier New', monospace";
const neonGreen = '#00ff9f';
const neonCyan = '#00e5ff';
const neonMag = '#ff00c8';
// ── 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 neonGreen;
if (n < 400) return neonCyan;
if (n < 500) return '#ffcc00';
return '#ff4444';
});
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: neonMag, 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: neonCyan,
backgroundColor: 'rgba(0,229,255,0.08)',
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('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a>',
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: '#ff00c8',
color: '#ff00c8',
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 cyberpunk-themed HTML template for the index page
// generated by --split-by-day. It lists all daily reports with aggregate stats.
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>` + cyberpunkCSS + `
/* Index-specific */
.day-link { color: var(--cyan); text-decoration: none; }
.day-link:hover { text-shadow: 0 0 10px var(--cyan); }
.err-low { color: var(--green); text-shadow: 0 0 6px var(--green); }
.err-med { color: #ffcc00; }
.err-high { color: #ff4444; text-shadow: 0 0 8px #ff4444; }
</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>
`