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

497 lines
13 KiB
Go

package report
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"path/filepath"
"sort"
"strings"
"time"
"github.com/mr0xb/nxstats/internal/parser"
)
// IPCount holds a remote address and its request count.
type IPCount struct {
IP string
Count int
}
// PathCount holds a path, its request count, and the most frequently
// returned HTTP status code for that path.
type PathCount struct {
Path string
Count int
TopStatus int
}
// TimePoint holds a time bucket and request count.
type TimePoint struct {
Hour time.Time
Count int
}
// GeoPoint holds geographic information for an IP.
type GeoPoint struct {
IP string
Lat float64
Lon float64
Country string
CountryCode string
Count int
}
// ReportData aggregates all data needed to render the HTML report.
type ReportData struct {
GeneratedAt time.Time
AccessEntries []parser.AccessEntry
ErrorEntries []parser.ErrorEntry
TotalRequests int
TotalBytes int64
UniqueIPs int
ErrorRate float64
StatusCounts map[int]int
TopIPs []IPCount
TopPaths []PathCount
TimeSeriesData []TimePoint
GeoLocations []GeoPoint
// Set when this is a per-day report in split-by-day mode.
IndexFile string // non-empty → render "← Back to Index" nav link
DayTitle string // e.g. "2024-02-20", shown in the header
// Theme selects the visual theme (see themes.go). Empty or unrecognized
// falls back to DefaultTheme.
Theme string
}
// DaySummary is one row in the index page's daily breakdown table.
type DaySummary struct {
Date time.Time
Filename string // bare filename, e.g. "report-2024-02-20.html"
Requests int
UniqueIPs int
TotalBytes int64
ErrorRate float64
}
// IndexData is the template data for the --split-by-day index page.
type IndexData struct {
GeneratedAt time.Time
Days []DaySummary // newest first
TotalRequests int
TotalUniqueIPs int
TotalBytes int64
OverallErrorRate float64
// Theme selects the visual theme (see themes.go). Empty or unrecognized
// falls back to DefaultTheme.
Theme string
}
// DayReport bundles one calendar day's fully-computed ReportData with
// its output filename, ready to be written to disk.
type DayReport struct {
Date time.Time // UTC midnight for this day
Filename string // bare filename, e.g. "report-2024-02-20.html"
Data ReportData // ComputeStats already called
}
// DeriveDailyFilename builds a per-day output path from the index output path.
// E.g. ("report.html", "2024-02-20") → "report-2024-02-20.html".
func DeriveDailyFilename(outputPath, dateKey string) string {
ext := filepath.Ext(outputPath)
base := strings.TrimSuffix(filepath.Base(outputPath), ext)
dir := filepath.Dir(outputPath)
return filepath.Join(dir, base+"-"+dateKey+ext)
}
// GroupByDay partitions access and error entries by calendar day (UTC),
// runs ComputeStats on each day's ReportData, and filters geoPoints to only
// IPs present in that day's access entries. Returns DayReports sorted
// oldest-first. geoPoints may be nil (GeoIP disabled).
func GroupByDay(
outputPath string,
accessEntries []parser.AccessEntry,
errorEntries []parser.ErrorEntry,
geoPoints map[string]GeoPoint,
themeName string,
) []DayReport {
accessByDay := make(map[string][]parser.AccessEntry)
for _, e := range accessEntries {
key := e.Time.UTC().Format("2006-01-02")
accessByDay[key] = append(accessByDay[key], e)
}
errorByDay := make(map[string][]parser.ErrorEntry)
for _, e := range errorEntries {
key := e.Time.UTC().Format("2006-01-02")
errorByDay[key] = append(errorByDay[key], e)
}
// Union of all day keys
seen := make(map[string]struct{})
for k := range accessByDay {
seen[k] = struct{}{}
}
for k := range errorByDay {
seen[k] = struct{}{}
}
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys) // oldest first (YYYY-MM-DD sorts lexicographically)
days := make([]DayReport, 0, len(keys))
for _, key := range keys {
dayAccess := accessByDay[key]
dayErrors := errorByDay[key]
rd := ReportData{
GeneratedAt: time.Now(),
AccessEntries: dayAccess,
ErrorEntries: dayErrors,
Theme: themeName,
}
ComputeStats(&rd)
// Filter geo locations to IPs seen on this day
if len(geoPoints) > 0 {
dayIPSet := make(map[string]struct{}, len(dayAccess))
for _, e := range dayAccess {
dayIPSet[e.RemoteAddr] = struct{}{}
}
var dayGeo []GeoPoint
for ip, pt := range geoPoints {
if _, ok := dayIPSet[ip]; ok {
dayGeo = append(dayGeo, pt)
}
}
rd.GeoLocations = dayGeo
}
t, _ := time.Parse("2006-01-02", key)
days = append(days, DayReport{
Date: t.UTC(),
Filename: filepath.Base(DeriveDailyFilename(outputPath, key)),
Data: rd,
})
}
return days
}
// indexRenderData wraps IndexData with the resolved theme's stylesheet for
// template execution.
type indexRenderData struct {
IndexData
ThemeCSS template.CSS
}
// GenerateIndexHTML renders the index page from IndexData.
func GenerateIndexHTML(idx IndexData) (string, error) {
th := themeFor(idx.Theme)
view := indexRenderData{
IndexData: idx,
ThemeCSS: template.CSS(th.CSS),
}
funcMap := template.FuncMap{
"formatBytes": func(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
},
"formatFloat": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"errorClass": func(rate float64) string {
switch {
case rate >= 10:
return "err-high"
case rate >= 2:
return "err-med"
default:
return "err-low"
}
},
"fmtDate": func(t time.Time) string { return t.UTC().Format("2006-01-02") },
}
tmpl, err := template.New("index").Funcs(funcMap).Parse(indexTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// ComputeStats fills the aggregate fields of data from AccessEntries and ErrorEntries.
func ComputeStats(data *ReportData) {
data.TotalRequests = len(data.AccessEntries)
ipMap := make(map[string]int)
pathMap := make(map[string]map[int]int) // path → {status → count}
hourMap := make(map[time.Time]int)
statusMap := make(map[int]int)
var totalBytes int64
errorCount := 0
for _, e := range data.AccessEntries {
totalBytes += e.BytesSent
ipMap[e.RemoteAddr]++
if pathMap[e.Path] == nil {
pathMap[e.Path] = make(map[int]int)
}
pathMap[e.Path][e.Status]++
statusMap[e.Status]++
if e.Status >= 500 {
errorCount++
}
hour := e.Time.Truncate(time.Hour)
hourMap[hour]++
}
data.TotalBytes = totalBytes
data.UniqueIPs = len(ipMap)
data.StatusCounts = statusMap
if data.TotalRequests > 0 {
data.ErrorRate = float64(errorCount) / float64(data.TotalRequests) * 100.0
}
// Top IPs (top 10)
ips := make([]IPCount, 0, len(ipMap))
for ip, cnt := range ipMap {
ips = append(ips, IPCount{IP: ip, Count: cnt})
}
sort.Slice(ips, func(i, j int) bool { return ips[i].Count > ips[j].Count })
if len(ips) > 10 {
ips = ips[:10]
}
data.TopIPs = ips
// Top Paths (top 10)
paths := make([]PathCount, 0, len(pathMap))
for path, statusCounts := range pathMap {
total := 0
topStatus, topCount := 0, 0
for status, count := range statusCounts {
total += count
// Pick the most frequent; break ties by lower status code
if count > topCount || (count == topCount && status < topStatus) {
topStatus = status
topCount = count
}
}
paths = append(paths, PathCount{
Path: path,
Count: total,
TopStatus: topStatus,
})
}
sort.Slice(paths, func(i, j int) bool { return paths[i].Count > paths[j].Count })
if len(paths) > 10 {
paths = paths[:10]
}
data.TopPaths = paths
// Time series
hours := make([]time.Time, 0, len(hourMap))
for h := range hourMap {
hours = append(hours, h)
}
sort.Slice(hours, func(i, j int) bool { return hours[i].Before(hours[j]) })
ts := make([]TimePoint, 0, len(hours))
for _, h := range hours {
ts = append(ts, TimePoint{Hour: h, Count: hourMap[h]})
}
data.TimeSeriesData = ts
}
// reportRenderData wraps ReportData with the resolved theme's stylesheet and
// chart/map color values for template execution.
type reportRenderData struct {
ReportData
ThemeCSS template.CSS
ThemeExtraCSS template.CSS
ChartFont string
ChartText string
ChartGrid string
Chart1 string
Chart2 string
Chart2Fill string
Chart3 string
ChartWarn string
ChartErr string
MapTileURL string
MapAttribution string
MarkerColor string
}
// GenerateHTML renders the full HTML report from data.
func GenerateHTML(data ReportData) (string, error) {
th := themeFor(data.Theme)
view := reportRenderData{
ReportData: data,
ThemeCSS: template.CSS(th.CSS),
ThemeExtraCSS: template.CSS(th.ExtraCSS),
ChartFont: th.ChartFont,
ChartText: th.ChartText,
ChartGrid: th.ChartGrid,
Chart1: th.Chart1,
Chart2: th.Chart2,
Chart2Fill: th.Chart2Fill,
Chart3: th.Chart3,
ChartWarn: th.ChartWarn,
ChartErr: th.ChartErr,
MapTileURL: th.MapTileURL,
MapAttribution: th.MapAttribution,
MarkerColor: th.MarkerColor,
}
funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"formatBytes": func(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
},
"formatFloat": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"statusBadge": func(status int) template.HTML {
cls := "s2xx"
switch {
case status >= 500:
cls = "s5xx"
case status >= 400:
cls = "s4xx"
case status >= 300:
cls = "s3xx"
}
return template.HTML(fmt.Sprintf(`<span class="status %s">%d</span>`, cls, status))
},
"levelBadge": func(level string) template.HTML {
cls := "l-" + level
return template.HTML(fmt.Sprintf(`<span class="level %s">%s</span>`, cls, level))
},
"statusJSON": func(m map[int]int) template.JS {
if m == nil {
return template.JS("{}")
}
b, _ := json.Marshal(m)
return template.JS(b)
},
"topPathsJSON": func(paths []PathCount) template.JS {
type item struct {
Path string `json:"path"`
Count int `json:"count"`
}
items := make([]item, len(paths))
for i, p := range paths {
items[i] = item{Path: p.Path, Count: p.Count}
}
b, _ := json.Marshal(items)
return template.JS(b)
},
"timeSeriesJSON": func(ts []TimePoint) template.JS {
type item struct {
Hour string `json:"hour"`
Count int `json:"count"`
}
items := make([]item, len(ts))
for i, t := range ts {
items[i] = item{Hour: t.Hour.Format("2006-01-02 15:04"), Count: t.Count}
}
b, _ := json.Marshal(items)
return template.JS(b)
},
"geoJSON": func(pts []GeoPoint) template.JS {
type item struct {
IP string `json:"ip"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Country string `json:"country"`
CC string `json:"cc"`
Count int `json:"count"`
}
items := make([]item, len(pts))
for i, p := range pts {
items[i] = item{IP: p.IP, Lat: p.Lat, Lon: p.Lon, Country: p.Country, CC: p.CountryCode, Count: p.Count}
}
b, _ := json.Marshal(items)
return template.JS(b)
},
"accessEntriesJSON": func(entries []parser.AccessEntry) template.JS {
type row struct {
Time string `json:"t"`
IP string `json:"ip"`
Method string `json:"m"`
Path string `json:"p"`
Status int `json:"s"`
Bytes int64 `json:"b"`
UA string `json:"ua"`
}
rows := make([]row, len(entries))
for i, e := range entries {
rows[i] = row{
Time: e.Time.Format("2006-01-02 15:04:05"),
IP: e.RemoteAddr,
Method: e.Method,
Path: e.Path,
Status: e.Status,
Bytes: e.BytesSent,
UA: e.UserAgent,
}
}
b, _ := json.Marshal(rows)
return template.JS(b)
},
"errorEntriesJSON": func(entries []parser.ErrorEntry) template.JS {
type row struct {
Time string `json:"t"`
Level string `json:"l"`
PID int `json:"pid"`
ConnID int `json:"cid"`
Msg string `json:"msg"`
}
rows := make([]row, len(entries))
for i, e := range entries {
rows[i] = row{
Time: e.Time.Format("2006-01-02 15:04:05"),
Level: e.Level,
PID: e.PID,
ConnID: e.ConnID,
Msg: e.Message,
}
}
b, _ := json.Marshal(rows)
return template.JS(b)
},
}
tmpl, err := template.New("report").Funcs(funcMap).Parse(reportTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, view); err != nil {
return "", err
}
return buf.String(), nil
}