311 lines
9.2 KiB
Go
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,
|
|
}
|
|
}
|