516 lines
14 KiB
Go
516 lines
14 KiB
Go
package report
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/url"
|
|
"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
|
|
|
|
// MapAPIKey is the basemap provider API key. When non-empty it is appended
|
|
// to the theme's tile URL as a "key" query parameter (CARTO's format).
|
|
MapAPIKey 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
|
|
}
|
|
|
|
// tileURLWithKey appends the basemap provider API key to tileURL as a "key"
|
|
// query parameter, e.g. ".../{z}/{x}/{y}.png?key=XYZ". The URL is returned
|
|
// unchanged when no key is set.
|
|
func tileURLWithKey(tileURL, key string) string {
|
|
if key == "" || tileURL == "" {
|
|
return tileURL
|
|
}
|
|
sep := "?"
|
|
if strings.Contains(tileURL, "?") {
|
|
sep = "&"
|
|
}
|
|
return tileURL + sep + "key=" + url.QueryEscape(key)
|
|
}
|
|
|
|
// 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: tileURLWithKey(th.MapTileURL, data.MapAPIKey),
|
|
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
|
|
}
|