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(`%d`, cls, status)) }, "levelBadge": func(level string) template.HTML { cls := "l-" + level return template.HTML(fmt.Sprintf(`%s`, 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 }