package cmd import ( "fmt" "net" "os" "path/filepath" "sort" "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 ) 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 cyberpunk-themed 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") } // Execute runs the root command. func Execute() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } func runE(cmd *cobra.Command, args []string) error { 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, } 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) 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, } }