From 8639799f6d7b446c1cc67350f2bfadc49e9d847a Mon Sep 17 00:00:00 2001 From: mr0xb <47467008+mr0xb@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:43:03 -0400 Subject: [PATCH] add theming --- README.md | 9 +- cmd/root.go | 24 ++- cmd/root_test.go | 17 ++ internal/report/report.go | 66 +++++- internal/report/report_test.go | 120 ++++++++++- internal/report/template.go | 305 +++------------------------- internal/report/theme_cactus.go | 247 ++++++++++++++++++++++ internal/report/theme_cyberpunk.go | 264 ++++++++++++++++++++++++ internal/report/theme_purplerain.go | 249 +++++++++++++++++++++++ internal/report/themes.go | 55 +++++ 10 files changed, 1069 insertions(+), 287 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 internal/report/theme_cactus.go create mode 100644 internal/report/theme_cyberpunk.go create mode 100644 internal/report/theme_purplerain.go create mode 100644 internal/report/themes.go diff --git a/README.md b/README.md index b4f9ae5..561b5cd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # // NXSTATS // -A fast nginx log parser that generates cyberpunk-themed HTML reports with charts, searchable tables, and optional GeoIP2 hit maps. +A fast nginx log parser that generates themeable HTML reports with charts, searchable tables, and optional GeoIP2 hit maps. ![Dashboard overview](screenshot1.png) @@ -19,6 +19,7 @@ A fast nginx log parser that generates cyberpunk-themed HTML reports with charts - Searchable, filterable **access log table** with status badges - Optional **GeoIP2 world map** (Leaflet.js) when a MaxMind `.mmdb` is supplied - **Split-by-day** mode writes one report per calendar day plus a summary index page +- Three built-in **themes** — `cyberpunk` (neon dark), `purplerain` (modern purple dark), `cactus` (pastel green light) - Zero runtime dependencies — the binary is statically compiled Go ## Installation @@ -52,6 +53,7 @@ nxstats [flags] | `--geoip` | | | Path to MaxMind `GeoLite2-City.mmdb` (enables map) | | `--no-gzip` | | `false` | Skip `.gz` compressed rotated logs | | `--split-by-day` | | `false` | Write one HTML report per calendar day plus index | +| `--theme` | | `cyberpunk` | Report visual theme: `cyberpunk`, `purplerain`, `cactus` | ### Examples @@ -76,6 +78,11 @@ nxstats --dir /var/log/nginx --split-by-day -o index.html # Writes index.html + 2024-01-15.html, 2024-01-16.html, etc. ``` +**With a different theme:** +```bash +nxstats --dir /var/log/nginx --theme purplerain -o report.html +``` + ## GeoIP2 Setup The geographic distribution map requires a free MaxMind GeoLite2-City database. diff --git a/cmd/root.go b/cmd/root.go index 98b275f..1867ab6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,9 @@ import ( "net" "os" "path/filepath" + "slices" "sort" + "strings" "time" "github.com/mr0xb/nxstats/internal/geo" @@ -22,13 +24,14 @@ var ( 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 cyberpunk-themed HTML report with +rotated logs) and generates a rich themeable HTML report with charts, searchable tables, and optional GeoIP2 hit maps.`, RunE: runE, } @@ -41,6 +44,17 @@ func init() { 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. @@ -51,6 +65,10 @@ func Execute() { } 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 @@ -89,6 +107,7 @@ func runE(cmd *cobra.Command, args []string) error { GeneratedAt: time.Now(), AccessEntries: accessEntries, ErrorEntries: errorEntries, + Theme: flagTheme, } report.ComputeStats(&data) @@ -184,7 +203,7 @@ func runSplitByDay( ) error { indexBase := filepath.Base(flagOutput) - days := report.GroupByDay(flagOutput, accessEntries, errorEntries, geoPoints) + days := report.GroupByDay(flagOutput, accessEntries, errorEntries, geoPoints, flagTheme) if len(days) == 0 { fmt.Fprintln(os.Stderr, "nxstats: no entries found, writing empty index") } @@ -287,5 +306,6 @@ func buildIndexData(allAccess []parser.AccessEntry, days []report.DayReport) rep TotalUniqueIPs: len(ipSet), TotalBytes: totalBytes, OverallErrorRate: errorRate, + Theme: flagTheme, } } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..776f92a --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,17 @@ +package cmd + +import "testing" + +func TestValidateTheme_Valid(t *testing.T) { + for _, name := range []string{"cyberpunk", "purplerain", "cactus"} { + if err := validateTheme(name); err != nil { + t.Errorf("validateTheme(%q) unexpected error: %v", name, err) + } + } +} + +func TestValidateTheme_Invalid(t *testing.T) { + if err := validateTheme("not-a-theme"); err == nil { + t.Error("expected error for unrecognized theme name") + } +} diff --git a/internal/report/report.go b/internal/report/report.go index 70daade..12218be 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -62,6 +62,10 @@ type ReportData struct { // 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. @@ -82,6 +86,10 @@ type IndexData struct { 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 @@ -110,6 +118,7 @@ func GroupByDay( accessEntries []parser.AccessEntry, errorEntries []parser.ErrorEntry, geoPoints map[string]GeoPoint, + themeName string, ) []DayReport { accessByDay := make(map[string][]parser.AccessEntry) for _, e := range accessEntries { @@ -145,6 +154,7 @@ func GroupByDay( GeneratedAt: time.Now(), AccessEntries: dayAccess, ErrorEntries: dayErrors, + Theme: themeName, } ComputeStats(&rd) @@ -173,8 +183,21 @@ func GroupByDay( 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 { @@ -206,7 +229,7 @@ func GenerateIndexHTML(idx IndexData) (string, error) { return "", err } var buf bytes.Buffer - if err := tmpl.Execute(&buf, idx); err != nil { + if err := tmpl.Execute(&buf, view); err != nil { return "", err } return buf.String(), nil @@ -295,8 +318,47 @@ func ComputeStats(data *ReportData) { 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 { @@ -428,7 +490,7 @@ func GenerateHTML(data ReportData) (string, error) { return "", err } var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { + if err := tmpl.Execute(&buf, view); err != nil { return "", err } return buf.String(), nil diff --git a/internal/report/report_test.go b/internal/report/report_test.go index cb5c432..c0f2589 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -313,7 +313,7 @@ func TestDeriveDailyFilename(t *testing.T) { func TestGroupByDay_Partitioning(t *testing.T) { access, errors := makeMultiDayEntries() - days := GroupByDay("report.html", access, errors, nil) + days := GroupByDay("report.html", access, errors, nil, "") if len(days) != 2 { t.Fatalf("expected 2 days, got %d", len(days)) } @@ -327,7 +327,7 @@ func TestGroupByDay_Partitioning(t *testing.T) { func TestGroupByDay_RequestCounts(t *testing.T) { access, errors := makeMultiDayEntries() - days := GroupByDay("report.html", access, errors, nil) + days := GroupByDay("report.html", access, errors, nil, "") if days[0].Data.TotalRequests != 1 { t.Errorf("day1 requests = %d, want 1", days[0].Data.TotalRequests) } @@ -338,7 +338,7 @@ func TestGroupByDay_RequestCounts(t *testing.T) { func TestGroupByDay_ErrorPartitioning(t *testing.T) { access, errors := makeMultiDayEntries() - days := GroupByDay("report.html", access, errors, nil) + days := GroupByDay("report.html", access, errors, nil, "") if len(days[0].Data.ErrorEntries) != 1 { t.Errorf("day1 error entries = %d, want 1", len(days[0].Data.ErrorEntries)) } @@ -349,7 +349,7 @@ func TestGroupByDay_ErrorPartitioning(t *testing.T) { func TestGroupByDay_Filenames(t *testing.T) { access, errors := makeMultiDayEntries() - days := GroupByDay("report.html", access, errors, nil) + days := GroupByDay("report.html", access, errors, nil, "") if days[0].Filename != "report-2024-02-20.html" { t.Errorf("day1 filename = %q, want report-2024-02-20.html", days[0].Filename) } @@ -364,7 +364,7 @@ func TestGroupByDay_GeoFiltering(t *testing.T) { "1.1.1.1": {IP: "1.1.1.1", Lat: 37.7, Lon: -97.8, Country: "United States", CountryCode: "US"}, "2.2.2.2": {IP: "2.2.2.2", Lat: 51.5, Lon: -0.1, Country: "United Kingdom", CountryCode: "GB"}, } - days := GroupByDay("report.html", access, errors, geoPoints) + days := GroupByDay("report.html", access, errors, geoPoints, "") // day1 only has 1.1.1.1 if len(days[0].Data.GeoLocations) != 1 { t.Errorf("day1 geo = %d, want 1", len(days[0].Data.GeoLocations)) @@ -379,7 +379,7 @@ func TestGroupByDay_GeoFiltering(t *testing.T) { } func TestGroupByDay_EmptyInput(t *testing.T) { - days := GroupByDay("report.html", nil, nil, nil) + days := GroupByDay("report.html", nil, nil, nil, "") if len(days) != 0 { t.Errorf("expected 0 days, got %d", len(days)) } @@ -387,7 +387,7 @@ func TestGroupByDay_EmptyInput(t *testing.T) { func TestGroupByDay_StatsComputed(t *testing.T) { access, errors := makeMultiDayEntries() - days := GroupByDay("report.html", access, errors, nil) + days := GroupByDay("report.html", access, errors, nil, "") // day2 has one 500 → error rate 50% if days[1].Data.ErrorRate < 49.9 || days[1].Data.ErrorRate > 50.1 { t.Errorf("day2 error rate = %.2f, want ~50.0", days[1].Data.ErrorRate) @@ -506,6 +506,112 @@ func TestGenerateHTML_NoBackLinkByDefault(t *testing.T) { } } +// ── Theme selection ──────────────────────────────────────────────────────── + +func TestThemeNames_ContainsAllThemes(t *testing.T) { + names := ThemeNames() + for _, want := range []string{"cyberpunk", "purplerain", "cactus"} { + found := false + for _, n := range names { + if n == want { + found = true + break + } + } + if !found { + t.Errorf("ThemeNames() = %v, missing %q", names, want) + } + } +} + +func TestGenerateHTML_DefaultThemeWhenEmpty(t *testing.T) { + data := makeTestData() + ComputeStats(&data) + // Theme left as zero value "". + + html, err := GenerateHTML(data) + if err != nil { + t.Fatalf("GenerateHTML error: %v", err) + } + if !strings.Contains(html, cyberpunkTheme.Chart1) { + t.Error("expected cyberpunk theme colors when Theme is empty") + } +} + +func TestGenerateHTML_UnknownThemeFallsBackToDefault(t *testing.T) { + data := makeTestData() + ComputeStats(&data) + data.Theme = "does-not-exist" + + html, err := GenerateHTML(data) + if err != nil { + t.Fatalf("GenerateHTML error: %v", err) + } + if !strings.Contains(html, cyberpunkTheme.Chart1) { + t.Error("expected fallback to cyberpunk theme colors for unknown theme name") + } +} + +func TestGenerateHTML_PurplerainTheme(t *testing.T) { + data := makeTestData() + ComputeStats(&data) + data.Theme = "purplerain" + + html, err := GenerateHTML(data) + if err != nil { + t.Fatalf("GenerateHTML error: %v", err) + } + if !strings.Contains(html, purplerainTheme.Chart1) { + t.Error("expected purplerain theme colors in output") + } + if strings.Contains(html, cyberpunkTheme.Chart1) { + t.Error("did not expect cyberpunk theme colors when purplerain is selected") + } +} + +func TestGenerateHTML_CactusTheme(t *testing.T) { + data := makeTestData() + ComputeStats(&data) + data.Theme = "cactus" + data.GeoLocations = []GeoPoint{ + {IP: "1.1.1.1", Lat: 37.751, Lon: -97.822, Country: "United States", CountryCode: "US", Count: 2}, + } + + html, err := GenerateHTML(data) + if err != nil { + t.Fatalf("GenerateHTML error: %v", err) + } + if !strings.Contains(html, cactusTheme.Chart1) { + t.Error("expected cactus theme colors in output") + } + if !strings.Contains(html, "light_all") { + t.Error("expected cactus theme to use a light map basemap") + } +} + +func TestGenerateIndexHTML_ThemeApplied(t *testing.T) { + idx := makeTestIndexData() + idx.Theme = "purplerain" + + html, err := GenerateIndexHTML(idx) + if err != nil { + t.Fatalf("GenerateIndexHTML error: %v", err) + } + if !strings.Contains(html, purplerainTheme.Chart1) { + t.Error("expected purplerain theme colors in index output") + } +} + +func TestGroupByDay_PropagatesTheme(t *testing.T) { + access, errors := makeMultiDayEntries() + days := GroupByDay("report.html", access, errors, nil, "cactus") + for _, d := range days { + if d.Data.Theme != "cactus" { + t.Errorf("day %s Theme = %q, want cactus", d.Filename, d.Data.Theme) + } + } +} + func TestGenerateHTML_DayTitleInHeader(t *testing.T) { data := makeTestData() ComputeStats(&data) diff --git a/internal/report/template.go b/internal/report/template.go index ded9a3d..30ca457 100644 --- a/internal/report/template.go +++ b/internal/report/template.go @@ -1,252 +1,9 @@ package report -// cyberpunkCSS is the shared stylesheet used by both the daily report and -// the index page. It does not include map-specific rules or page-specific -// overrides (those live in reportExtraCSS / each template's own + @@ -436,13 +193,15 @@ const reportTemplate = `