initial commit
This commit is contained in:
commit
885e7d6e02
22 changed files with 3001 additions and 0 deletions
435
internal/report/report.go
Normal file
435
internal/report/report.go
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
) []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,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// GenerateIndexHTML renders the index page from IndexData.
|
||||
func GenerateIndexHTML(idx IndexData) (string, error) {
|
||||
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, idx); 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
|
||||
}
|
||||
|
||||
// GenerateHTML renders the full HTML report from data.
|
||||
func GenerateHTML(data ReportData) (string, error) {
|
||||
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, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
Loading…
Reference in a new issue