nxstats/cmd/root.go
2026-08-24 15:43:03 -04:00

311 lines
9.2 KiB
Go

package cmd
import (
"fmt"
"net"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/mr0xb/nxstats/internal/geo"
"github.com/mr0xb/nxstats/internal/parser"
"github.com/mr0xb/nxstats/internal/report"
"github.com/spf13/cobra"
)
var (
flagDir string
flagOutput string
flagAccessLog string
flagErrorLog string
flagNoGzip bool
flagGeoIP string
flagSplitByDay bool
flagTheme string
)
var rootCmd = &cobra.Command{
Use: "nxstats",
Short: "Nginx log parser and HTML report generator",
Long: `nxstats parses nginx access and error logs (including gzip-compressed
rotated logs) and generates a rich themeable HTML report with
charts, searchable tables, and optional GeoIP2 hit maps.`,
RunE: runE,
}
func init() {
rootCmd.Flags().StringVarP(&flagDir, "dir", "d", "/var/log/nginx", "Directory to scan for nginx logs")
rootCmd.Flags().StringVarP(&flagOutput, "output", "o", "report.html", "Output HTML file path")
rootCmd.Flags().StringVarP(&flagAccessLog, "access-log", "a", "", "Specific access log file (skips dir scan for access logs)")
rootCmd.Flags().StringVarP(&flagErrorLog, "error-log", "e", "", "Specific error log file (skips dir scan for error logs)")
rootCmd.Flags().BoolVar(&flagNoGzip, "no-gzip", false, "Skip .gz compressed rotated logs")
rootCmd.Flags().StringVar(&flagGeoIP, "geoip", "", "Path to MaxMind GeoLite2-City.mmdb (enables map)")
rootCmd.Flags().BoolVar(&flagSplitByDay, "split-by-day", false, "Write one HTML report per calendar day plus an index page")
rootCmd.Flags().StringVar(&flagTheme, "theme", report.DefaultTheme,
"Report visual theme ("+strings.Join(report.ThemeNames(), ", ")+")")
}
// validateTheme returns an error if name is not a recognized theme.
func validateTheme(name string) error {
valid := report.ThemeNames()
if slices.Contains(valid, name) {
return nil
}
return fmt.Errorf("invalid --theme %q: must be one of %s", name, strings.Join(valid, ", "))
}
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func runE(cmd *cobra.Command, args []string) error {
if err := validateTheme(flagTheme); err != nil {
return err
}
accessFiles, errorFiles, err := resolveLogFiles()
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "nxstats: reading %d access file(s), %d error file(s)\n",
len(accessFiles), len(errorFiles))
accessEntries, err := parser.ReadAccessEntries(accessFiles)
if err != nil {
return fmt.Errorf("read access logs: %w", err)
}
errorEntries, err := parser.ReadErrorEntries(errorFiles)
if err != nil {
return fmt.Errorf("read error logs: %w", err)
}
fmt.Fprintf(os.Stderr, "nxstats: %d access entries, %d error entries\n",
len(accessEntries), len(errorEntries))
gl, err := geo.NewGeoLookup(flagGeoIP)
if err != nil {
return fmt.Errorf("geoip: %w", err)
}
defer gl.Close()
// Build global IP→GeoPoint map (one lookup per unique IP, reused per day).
geoPoints := buildGeoPoints(gl, accessEntries)
if flagSplitByDay {
return runSplitByDay(accessEntries, errorEntries, geoPoints)
}
// ── Single-report path ─────────────────────────────────────────────────
data := report.ReportData{
GeneratedAt: time.Now(),
AccessEntries: accessEntries,
ErrorEntries: errorEntries,
Theme: flagTheme,
}
report.ComputeStats(&data)
if gl.IsEnabled() {
pts := geoPointsSlice(geoPoints)
data.GeoLocations = pts
fmt.Fprintf(os.Stderr, "nxstats: resolved %d geo locations\n", len(pts))
}
html, err := report.GenerateHTML(data)
if err != nil {
return fmt.Errorf("generate html: %w", err)
}
if err := os.WriteFile(flagOutput, []byte(html), 0644); err != nil {
return fmt.Errorf("write output %q: %w", flagOutput, err)
}
fmt.Fprintf(os.Stderr, "nxstats: report written to %s\n", flagOutput)
return nil
}
// resolveLogFiles returns the access and error file lists, honouring --access-log,
// --error-log, and --dir flags.
func resolveLogFiles() (accessFiles, errorFiles []string, err error) {
includeGzip := !flagNoGzip
if flagAccessLog != "" {
accessFiles = []string{flagAccessLog}
} else {
accessFiles, _, err = parser.ScanDirectory(flagDir, includeGzip)
if err != nil {
return nil, nil, fmt.Errorf("scan dir %q: %w", flagDir, err)
}
}
if flagErrorLog != "" {
errorFiles = []string{flagErrorLog}
} else {
_, errorFiles, err = parser.ScanDirectory(flagDir, includeGzip)
if err != nil {
return nil, nil, fmt.Errorf("scan dir %q: %w", flagDir, err)
}
}
return accessFiles, errorFiles, nil
}
// buildGeoPoints runs GeoIP lookups for all unique IPs in accessEntries and
// returns a map of ip string → report.GeoPoint. Returns an empty map when
// GeoIP is disabled.
func buildGeoPoints(gl *geo.GeoLookup, accessEntries []parser.AccessEntry) map[string]report.GeoPoint {
result := make(map[string]report.GeoPoint)
if !gl.IsEnabled() {
return result
}
for _, e := range accessEntries {
if _, seen := result[e.RemoteAddr]; seen {
continue
}
ip := net.ParseIP(e.RemoteAddr)
if ip == nil {
continue
}
pt := gl.Lookup(ip)
if pt == nil {
continue
}
result[e.RemoteAddr] = report.GeoPoint{
IP: pt.IP,
Lat: pt.Lat,
Lon: pt.Lon,
Country: pt.Country,
CountryCode: pt.CountryCode,
}
}
return result
}
// geoPointsSlice converts the geo map to a slice for a single-report run,
// tallying the request count for each IP from a pre-built count map.
func geoPointsSlice(geoPoints map[string]report.GeoPoint) []report.GeoPoint {
pts := make([]report.GeoPoint, 0, len(geoPoints))
for _, p := range geoPoints {
pts = append(pts, p)
}
return pts
}
// runSplitByDay groups entries by calendar day, writes one HTML file per day,
// then writes the index page to flagOutput.
func runSplitByDay(
accessEntries []parser.AccessEntry,
errorEntries []parser.ErrorEntry,
geoPoints map[string]report.GeoPoint,
) error {
indexBase := filepath.Base(flagOutput)
days := report.GroupByDay(flagOutput, accessEntries, errorEntries, geoPoints, flagTheme)
if len(days) == 0 {
fmt.Fprintln(os.Stderr, "nxstats: no entries found, writing empty index")
}
// Write per-day reports (oldest → newest)
for i := range days {
days[i].Data.IndexFile = indexBase
days[i].Data.DayTitle = days[i].Date.Format("2006-01-02")
// Per-day geo: set Count from this day's access entries
if len(geoPoints) > 0 {
countByIP := make(map[string]int)
for _, e := range days[i].Data.AccessEntries {
countByIP[e.RemoteAddr]++
}
updated := make([]report.GeoPoint, len(days[i].Data.GeoLocations))
for j, g := range days[i].Data.GeoLocations {
g.Count = countByIP[g.IP]
updated[j] = g
}
days[i].Data.GeoLocations = updated
}
html, err := report.GenerateHTML(days[i].Data)
if err != nil {
return fmt.Errorf("generate html for %s: %w", days[i].Filename, err)
}
dailyPath := filepath.Join(filepath.Dir(flagOutput), days[i].Filename)
if err := os.WriteFile(dailyPath, []byte(html), 0644); err != nil {
return fmt.Errorf("write %q: %w", dailyPath, err)
}
fmt.Fprintf(os.Stderr, "nxstats: wrote %s\n", dailyPath)
}
// Build IndexData (days newest first in the table)
idx := buildIndexData(accessEntries, days)
indexHTML, err := report.GenerateIndexHTML(idx)
if err != nil {
return fmt.Errorf("generate index html: %w", err)
}
if err := os.WriteFile(flagOutput, []byte(indexHTML), 0644); err != nil {
return fmt.Errorf("write index %q: %w", flagOutput, err)
}
fmt.Fprintf(os.Stderr, "nxstats: index written to %s (%d daily report(s))\n",
flagOutput, len(days))
return nil
}
// buildIndexData assembles the IndexData from all daily reports. Days are
// returned newest-first in the table. Unique IPs are counted across all
// access entries (not summed per day) to avoid double-counting.
func buildIndexData(allAccess []parser.AccessEntry, days []report.DayReport) report.IndexData {
// Reverse a copy for newest-first display
summaries := make([]report.DaySummary, len(days))
for i, d := range days {
summaries[len(days)-1-i] = report.DaySummary{
Date: d.Date,
Filename: d.Filename,
Requests: d.Data.TotalRequests,
UniqueIPs: d.Data.UniqueIPs,
TotalBytes: d.Data.TotalBytes,
ErrorRate: d.Data.ErrorRate,
}
}
// Sort summaries newest first (they were built from oldest-first days)
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].Date.After(summaries[j].Date)
})
var totalReqs int
var totalBytes int64
var totalErrors int
for _, d := range days {
totalReqs += d.Data.TotalRequests
totalBytes += d.Data.TotalBytes
for status, count := range d.Data.StatusCounts {
if status >= 500 {
totalErrors += count
}
}
}
// Unique IPs across all days (union, not sum)
ipSet := make(map[string]struct{}, len(allAccess))
for _, e := range allAccess {
ipSet[e.RemoteAddr] = struct{}{}
}
var errorRate float64
if totalReqs > 0 {
errorRate = float64(totalErrors) / float64(totalReqs) * 100.0
}
return report.IndexData{
GeneratedAt: time.Now(),
Days: summaries,
TotalRequests: totalReqs,
TotalUniqueIPs: len(ipSet),
TotalBytes: totalBytes,
OverallErrorRate: errorRate,
Theme: flagTheme,
}
}