Merge pull request #1 from mr0xb/feat/theming

add theming
This commit is contained in:
Steve 2026-08-24 15:43:33 -04:00 committed by GitHub
commit 65a25b3916
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1069 additions and 287 deletions

View file

@ -4,7 +4,7 @@
# // NXSTATS // # // 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) ![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 - Searchable, filterable **access log table** with status badges
- Optional **GeoIP2 world map** (Leaflet.js) when a MaxMind `.mmdb` is supplied - 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 - **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 - Zero runtime dependencies — the binary is statically compiled Go
## Installation ## Installation
@ -52,6 +53,7 @@ nxstats [flags]
| `--geoip` | | | Path to MaxMind `GeoLite2-City.mmdb` (enables map) | | `--geoip` | | | Path to MaxMind `GeoLite2-City.mmdb` (enables map) |
| `--no-gzip` | | `false` | Skip `.gz` compressed rotated logs | | `--no-gzip` | | `false` | Skip `.gz` compressed rotated logs |
| `--split-by-day` | | `false` | Write one HTML report per calendar day plus index | | `--split-by-day` | | `false` | Write one HTML report per calendar day plus index |
| `--theme` | | `cyberpunk` | Report visual theme: `cyberpunk`, `purplerain`, `cactus` |
### Examples ### 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. # 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 ## GeoIP2 Setup
The geographic distribution map requires a free MaxMind GeoLite2-City database. The geographic distribution map requires a free MaxMind GeoLite2-City database.

View file

@ -5,7 +5,9 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strings"
"time" "time"
"github.com/mr0xb/nxstats/internal/geo" "github.com/mr0xb/nxstats/internal/geo"
@ -22,13 +24,14 @@ var (
flagNoGzip bool flagNoGzip bool
flagGeoIP string flagGeoIP string
flagSplitByDay bool flagSplitByDay bool
flagTheme string
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "nxstats", Use: "nxstats",
Short: "Nginx log parser and HTML report generator", Short: "Nginx log parser and HTML report generator",
Long: `nxstats parses nginx access and error logs (including gzip-compressed 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.`, charts, searchable tables, and optional GeoIP2 hit maps.`,
RunE: runE, RunE: runE,
} }
@ -41,6 +44,17 @@ func init() {
rootCmd.Flags().BoolVar(&flagNoGzip, "no-gzip", false, "Skip .gz compressed rotated 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().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().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. // Execute runs the root command.
@ -51,6 +65,10 @@ func Execute() {
} }
func runE(cmd *cobra.Command, args []string) error { func runE(cmd *cobra.Command, args []string) error {
if err := validateTheme(flagTheme); err != nil {
return err
}
accessFiles, errorFiles, err := resolveLogFiles() accessFiles, errorFiles, err := resolveLogFiles()
if err != nil { if err != nil {
return err return err
@ -89,6 +107,7 @@ func runE(cmd *cobra.Command, args []string) error {
GeneratedAt: time.Now(), GeneratedAt: time.Now(),
AccessEntries: accessEntries, AccessEntries: accessEntries,
ErrorEntries: errorEntries, ErrorEntries: errorEntries,
Theme: flagTheme,
} }
report.ComputeStats(&data) report.ComputeStats(&data)
@ -184,7 +203,7 @@ func runSplitByDay(
) error { ) error {
indexBase := filepath.Base(flagOutput) indexBase := filepath.Base(flagOutput)
days := report.GroupByDay(flagOutput, accessEntries, errorEntries, geoPoints) days := report.GroupByDay(flagOutput, accessEntries, errorEntries, geoPoints, flagTheme)
if len(days) == 0 { if len(days) == 0 {
fmt.Fprintln(os.Stderr, "nxstats: no entries found, writing empty index") 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), TotalUniqueIPs: len(ipSet),
TotalBytes: totalBytes, TotalBytes: totalBytes,
OverallErrorRate: errorRate, OverallErrorRate: errorRate,
Theme: flagTheme,
} }
} }

17
cmd/root_test.go Normal file
View file

@ -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")
}
}

View file

@ -62,6 +62,10 @@ type ReportData struct {
// Set when this is a per-day report in split-by-day mode. // Set when this is a per-day report in split-by-day mode.
IndexFile string // non-empty → render "← Back to Index" nav link IndexFile string // non-empty → render "← Back to Index" nav link
DayTitle string // e.g. "2024-02-20", shown in the header 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. // DaySummary is one row in the index page's daily breakdown table.
@ -82,6 +86,10 @@ type IndexData struct {
TotalUniqueIPs int TotalUniqueIPs int
TotalBytes int64 TotalBytes int64
OverallErrorRate float64 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 // DayReport bundles one calendar day's fully-computed ReportData with
@ -110,6 +118,7 @@ func GroupByDay(
accessEntries []parser.AccessEntry, accessEntries []parser.AccessEntry,
errorEntries []parser.ErrorEntry, errorEntries []parser.ErrorEntry,
geoPoints map[string]GeoPoint, geoPoints map[string]GeoPoint,
themeName string,
) []DayReport { ) []DayReport {
accessByDay := make(map[string][]parser.AccessEntry) accessByDay := make(map[string][]parser.AccessEntry)
for _, e := range accessEntries { for _, e := range accessEntries {
@ -145,6 +154,7 @@ func GroupByDay(
GeneratedAt: time.Now(), GeneratedAt: time.Now(),
AccessEntries: dayAccess, AccessEntries: dayAccess,
ErrorEntries: dayErrors, ErrorEntries: dayErrors,
Theme: themeName,
} }
ComputeStats(&rd) ComputeStats(&rd)
@ -173,8 +183,21 @@ func GroupByDay(
return days 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. // GenerateIndexHTML renders the index page from IndexData.
func GenerateIndexHTML(idx IndexData) (string, error) { func GenerateIndexHTML(idx IndexData) (string, error) {
th := themeFor(idx.Theme)
view := indexRenderData{
IndexData: idx,
ThemeCSS: template.CSS(th.CSS),
}
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"formatBytes": func(b int64) string { "formatBytes": func(b int64) string {
switch { switch {
@ -206,7 +229,7 @@ func GenerateIndexHTML(idx IndexData) (string, error) {
return "", err return "", err
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.Execute(&buf, idx); err != nil { if err := tmpl.Execute(&buf, view); err != nil {
return "", err return "", err
} }
return buf.String(), nil return buf.String(), nil
@ -295,8 +318,47 @@ func ComputeStats(data *ReportData) {
data.TimeSeriesData = ts 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. // GenerateHTML renders the full HTML report from data.
func GenerateHTML(data ReportData) (string, error) { 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{ funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
"formatBytes": func(b int64) string { "formatBytes": func(b int64) string {
@ -428,7 +490,7 @@ func GenerateHTML(data ReportData) (string, error) {
return "", err return "", err
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil { if err := tmpl.Execute(&buf, view); err != nil {
return "", err return "", err
} }
return buf.String(), nil return buf.String(), nil

View file

@ -313,7 +313,7 @@ func TestDeriveDailyFilename(t *testing.T) {
func TestGroupByDay_Partitioning(t *testing.T) { func TestGroupByDay_Partitioning(t *testing.T) {
access, errors := makeMultiDayEntries() access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil) days := GroupByDay("report.html", access, errors, nil, "")
if len(days) != 2 { if len(days) != 2 {
t.Fatalf("expected 2 days, got %d", len(days)) 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) { func TestGroupByDay_RequestCounts(t *testing.T) {
access, errors := makeMultiDayEntries() access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil) days := GroupByDay("report.html", access, errors, nil, "")
if days[0].Data.TotalRequests != 1 { if days[0].Data.TotalRequests != 1 {
t.Errorf("day1 requests = %d, want 1", days[0].Data.TotalRequests) 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) { func TestGroupByDay_ErrorPartitioning(t *testing.T) {
access, errors := makeMultiDayEntries() access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil) days := GroupByDay("report.html", access, errors, nil, "")
if len(days[0].Data.ErrorEntries) != 1 { if len(days[0].Data.ErrorEntries) != 1 {
t.Errorf("day1 error entries = %d, want 1", len(days[0].Data.ErrorEntries)) 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) { func TestGroupByDay_Filenames(t *testing.T) {
access, errors := makeMultiDayEntries() 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" { if days[0].Filename != "report-2024-02-20.html" {
t.Errorf("day1 filename = %q, want report-2024-02-20.html", days[0].Filename) 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"}, "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"}, "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 // day1 only has 1.1.1.1
if len(days[0].Data.GeoLocations) != 1 { if len(days[0].Data.GeoLocations) != 1 {
t.Errorf("day1 geo = %d, want 1", len(days[0].Data.GeoLocations)) 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) { func TestGroupByDay_EmptyInput(t *testing.T) {
days := GroupByDay("report.html", nil, nil, nil) days := GroupByDay("report.html", nil, nil, nil, "")
if len(days) != 0 { if len(days) != 0 {
t.Errorf("expected 0 days, got %d", len(days)) 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) { func TestGroupByDay_StatsComputed(t *testing.T) {
access, errors := makeMultiDayEntries() access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil) days := GroupByDay("report.html", access, errors, nil, "")
// day2 has one 500 → error rate 50% // day2 has one 500 → error rate 50%
if days[1].Data.ErrorRate < 49.9 || days[1].Data.ErrorRate > 50.1 { 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) 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) { func TestGenerateHTML_DayTitleInHeader(t *testing.T) {
data := makeTestData() data := makeTestData()
ComputeStats(&data) ComputeStats(&data)

View file

@ -1,252 +1,9 @@
package report package report
// cyberpunkCSS is the shared stylesheet used by both the daily report and // reportTemplate is the HTML template for a single report (either the only
// the index page. It does not include map-specific rules or page-specific // report, or one day's report in --split-by-day mode). Its stylesheet and
// overrides (those live in reportExtraCSS / each template's own <style>). // chart/map colors are supplied at render time via the active theme
const cyberpunkCSS = ` // (see themes.go and GenerateHTML).
:root {
--bg: #050510;
--bg2: #0a0a20;
--green: #00ff9f;
--cyan: #00e5ff;
--magenta: #ff00c8;
--text: #c0c0e0;
--border: #1a1a3a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Courier New', monospace;
font-size: 14px;
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
body::after {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.08) 2px,
rgba(0,0,0,0.08) 4px
);
pointer-events: none;
z-index: 9999;
}
header {
background: var(--bg2);
border-bottom: 2px solid var(--magenta);
padding: 24px 32px;
text-align: center;
}
header h1 {
font-size: 2.4rem;
color: var(--cyan);
text-shadow: 0 0 20px var(--cyan), 0 0 40px rgba(0,229,255,0.5);
letter-spacing: 6px;
text-transform: uppercase;
}
header .subtitle {
color: var(--green);
text-shadow: 0 0 10px var(--green);
margin-top: 6px;
font-size: 0.85rem;
letter-spacing: 3px;
}
.nav-back {
padding: 10px 32px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
}
.nav-back a {
color: var(--magenta);
text-decoration: none;
font-size: 0.85rem;
letter-spacing: 2px;
text-shadow: 0 0 8px var(--magenta);
}
.nav-back a:hover { text-shadow: 0 0 16px var(--magenta); }
.container { max-width: 1400px; margin: 0 auto; padding: 24px 16px; }
h2 {
color: var(--cyan);
text-shadow: 0 0 12px var(--cyan);
font-size: 1.2rem;
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.section { margin-bottom: 40px; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.card {
background: var(--bg2);
border: 1px solid var(--magenta);
border-radius: 4px;
padding: 20px;
box-shadow: 0 0 16px rgba(255,0,200,0.15), inset 0 0 20px rgba(0,0,0,0.3);
text-align: center;
}
.card .label {
color: var(--green);
text-shadow: 0 0 8px var(--green);
font-size: 0.75rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 10px;
}
.card .value {
color: var(--cyan);
text-shadow: 0 0 16px var(--cyan);
font-size: 1.8rem;
font-weight: bold;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.chart-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
box-shadow: 0 0 12px rgba(0,229,255,0.08);
}
.chart-card h3 {
color: var(--green);
text-shadow: 0 0 8px var(--green);
font-size: 0.8rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 14px;
}
canvas { max-width: 100%; }
.table-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.search-bar { display: flex; align-items: center; gap: 10px; }
.search-bar input {
background: var(--bg2);
border: 1px solid var(--green);
color: var(--green);
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 6px 12px;
width: 280px;
outline: none;
border-radius: 2px;
box-shadow: 0 0 8px rgba(0,255,159,0.15);
}
.search-bar input::placeholder { color: rgba(0,255,159,0.4); }
.search-bar label {
color: var(--green);
font-size: 0.8rem;
letter-spacing: 1px;
text-transform: uppercase;
}
.page-info { color: rgba(0,255,159,0.6); font-size: 0.8rem; letter-spacing: 1px; min-width: 140px; }
.page-size-select { display: flex; align-items: center; gap: 6px; }
.page-size-select label { color: var(--green); font-size: 0.8rem; letter-spacing: 1px; text-transform: uppercase; }
.page-size-select select {
background: var(--bg2);
border: 1px solid var(--green);
color: var(--green);
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 4px 8px;
outline: none;
border-radius: 2px;
cursor: pointer;
}
.page-btns { display: flex; gap: 8px; margin-left: auto; }
.page-btns button {
background: transparent;
border: 1px solid var(--cyan);
color: var(--cyan);
font-family: 'Courier New', monospace;
font-size: 12px;
padding: 5px 14px;
cursor: pointer;
border-radius: 2px;
letter-spacing: 1px;
transition: background 0.15s, box-shadow 0.15s;
}
.page-btns button:hover:not(:disabled) { background: rgba(0,229,255,0.1); box-shadow: 0 0 8px rgba(0,229,255,0.3); }
.page-btns button:disabled { opacity: 0.3; cursor: default; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: rgba(0,229,255,0.05);
color: var(--cyan);
text-shadow: 0 0 6px var(--cyan);
font-size: 0.75rem;
letter-spacing: 2px;
text-transform: uppercase;
padding: 10px 14px;
border-bottom: 1px solid var(--cyan);
text-align: left;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; }
tbody tr:hover { background: rgba(0,255,159,0.04); }
tbody td { padding: 8px 14px; color: var(--text); vertical-align: top; word-break: break-all; }
tbody td:first-child { color: var(--green); font-weight: bold; white-space: nowrap; }
.status { display: inline-block; padding: 1px 6px; border-radius: 3px; font-weight: bold; font-size: 12px; }
.s2xx { color: #00ff9f; border: 1px solid #00ff9f; text-shadow: 0 0 6px #00ff9f; }
.s3xx { color: #00e5ff; border: 1px solid #00e5ff; }
.s4xx { color: #ffcc00; border: 1px solid #ffcc00; }
.s5xx { color: #ff4444; border: 1px solid #ff4444; text-shadow: 0 0 8px #ff4444; }
.level { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
.l-error, .l-crit, .l-alert, .l-emerg { color: #ff4444; border: 1px solid #ff4444; }
.l-warn { color: #ffcc00; border: 1px solid #ffcc00; }
.l-info, .l-notice { color: #00e5ff; border: 1px solid #00e5ff; }
.l-debug { color: #888; border: 1px solid #444; }
.rank { color: var(--magenta); text-shadow: 0 0 6px var(--magenta); }
footer {
text-align: center;
padding: 24px;
color: rgba(192,192,224,0.4);
font-size: 0.75rem;
border-top: 1px solid var(--border);
letter-spacing: 2px;
}
`
// reportExtraCSS holds CSS only used in daily/single reports (map, map labels).
const reportExtraCSS = `
#map { height: 480px; border: 1px solid var(--magenta); border-radius: 4px; background: #0a0a20; }
.map-label {
background: rgba(5,5,16,0.85) !important;
border: 1px solid var(--magenta) !important;
border-radius: 3px !important;
color: #fff !important;
font-family: 'Courier New', monospace !important;
font-size: 11px !important;
padding: 2px 5px !important;
white-space: nowrap !important;
box-shadow: 0 0 6px rgba(255,0,200,0.4) !important;
pointer-events: none !important;
}
.map-label::before { display: none !important; }
`
// reportTemplate is the cyberpunk-themed HTML template for a single report
// (either the only report, or one day's report in --split-by-day mode).
const reportTemplate = `<!DOCTYPE html> const reportTemplate = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -258,7 +15,7 @@ const reportTemplate = `<!DOCTYPE html>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{{end}} {{end}}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>` + cyberpunkCSS + reportExtraCSS + `</style> <style>{{.ThemeCSS}}{{.ThemeExtraCSS}}</style>
</head> </head>
<body> <body>
@ -436,13 +193,15 @@ const reportTemplate = `<!DOCTYPE html>
<script> <script>
// ── Chart.js defaults ────────────────────────────────────────────────────── // ── Chart.js defaults ──────────────────────────────────────────────────────
Chart.defaults.color = '#c0c0e0'; Chart.defaults.color = '{{.ChartText}}';
Chart.defaults.borderColor = '#1a1a3a'; Chart.defaults.borderColor = '{{.ChartGrid}}';
Chart.defaults.font.family = "'Courier New', monospace"; Chart.defaults.font.family = "{{.ChartFont}}";
const neonGreen = '#00ff9f'; const chartC1 = '{{.Chart1}}';
const neonCyan = '#00e5ff'; const chartC2 = '{{.Chart2}}';
const neonMag = '#ff00c8'; const chartC3 = '{{.Chart3}}';
const chartWarn = '{{.ChartWarn}}';
const chartErr = '{{.ChartErr}}';
// ── Status Codes Chart ───────────────────────────────────────────────────── // ── Status Codes Chart ─────────────────────────────────────────────────────
(function(){ (function(){
@ -452,10 +211,10 @@ const neonMag = '#ff00c8';
const values = labels.map(k => rawStatuses[k]); const values = labels.map(k => rawStatuses[k]);
const colors = labels.map(k => { const colors = labels.map(k => {
const n = parseInt(k); const n = parseInt(k);
if (n < 300) return neonGreen; if (n < 300) return chartC1;
if (n < 400) return neonCyan; if (n < 400) return chartC2;
if (n < 500) return '#ffcc00'; if (n < 500) return chartWarn;
return '#ff4444'; return chartErr;
}); });
new Chart(document.getElementById('chartStatus'), { new Chart(document.getElementById('chartStatus'), {
type: 'bar', type: 'bar',
@ -472,7 +231,7 @@ const neonMag = '#ff00c8';
const values = paths.map(p => p.count); const values = paths.map(p => p.count);
new Chart(document.getElementById('chartPaths'), { new Chart(document.getElementById('chartPaths'), {
type: 'bar', type: 'bar',
data: { labels, datasets: [{ data: values, backgroundColor: neonMag, borderWidth: 0 }] }, data: { labels, datasets: [{ data: values, backgroundColor: chartC3, borderWidth: 0 }] },
options: { options: {
indexAxis: 'y', indexAxis: 'y',
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
@ -491,8 +250,8 @@ const neonMag = '#ff00c8';
type: 'line', type: 'line',
data: { labels, datasets: [{ data: { labels, datasets: [{
data: values, data: values,
borderColor: neonCyan, borderColor: chartC2,
backgroundColor: 'rgba(0,229,255,0.08)', backgroundColor: '{{.Chart2Fill}}',
tension: 0.3, tension: 0.3,
fill: true, fill: true,
pointRadius: 3 pointRadius: 3
@ -620,8 +379,8 @@ function errorSetPageSize(n) { errorPager.setPageSize(n); }
} }
const map = L.map('map').setView([20, 0], 2); const map = L.map('map').setView([20, 0], 2);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { L.tileLayer('{{.MapTileURL}}', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a>', attribution: '{{.MapAttribution}}',
maxZoom: 18 maxZoom: 18
}).addTo(map); }).addTo(map);
@ -632,8 +391,8 @@ function errorSetPageSize(n) { errorPager.setPageSize(n); }
const marker = L.circleMarker([pt.lat, pt.lon], { const marker = L.circleMarker([pt.lat, pt.lon], {
radius: Math.min(4 + Math.log(pt.count + 1) * 3, 20), radius: Math.min(4 + Math.log(pt.count + 1) * 3, 20),
fillColor: '#ff00c8', fillColor: '{{.MarkerColor}}',
color: '#ff00c8', color: '{{.MarkerColor}}',
weight: 1, weight: 1,
opacity: 0.9, opacity: 0.9,
fillOpacity: 0.5 fillOpacity: 0.5
@ -661,22 +420,18 @@ function errorSetPageSize(n) { errorPager.setPageSize(n); }
</html> </html>
` `
// indexTemplate is the cyberpunk-themed HTML template for the index page // indexTemplate is the HTML template for the index page generated by
// generated by --split-by-day. It lists all daily reports with aggregate stats. // --split-by-day. It lists all daily reports with aggregate stats. Its
// stylesheet is supplied at render time via the active theme (see
// themes.go and GenerateIndexHTML); .day-link and .err-* rules live in
// each theme's shared CSS block.
const indexTemplate = `<!DOCTYPE html> const indexTemplate = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nxstats Daily Index</title> <title>nxstats Daily Index</title>
<style>` + cyberpunkCSS + ` <style>{{.ThemeCSS}}</style>
/* Index-specific */
.day-link { color: var(--cyan); text-decoration: none; }
.day-link:hover { text-shadow: 0 0 10px var(--cyan); }
.err-low { color: var(--green); text-shadow: 0 0 6px var(--green); }
.err-med { color: #ffcc00; }
.err-high { color: #ff4444; text-shadow: 0 0 8px #ff4444; }
</style>
</head> </head>
<body> <body>

View file

@ -0,0 +1,247 @@
package report
// cactusTheme is a light-mode pastel theme: sage green and terracotta on a
// near-white background, system sans-serif font, flat soft shadows, no
// glow effects, and pill-shaped badges.
var cactusTheme = themeAssets{
CSS: `
:root {
--bg: #f6f9f1;
--bg2: #eaf2e1;
--c1: #6b9b52;
--c2: #5b8fa3;
--c3: #e0956b;
--warn: #d99a3d;
--err: #c96a5b;
--text: #33402d;
--border: #d3e0c8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
min-height: 100vh;
}
header {
background: var(--bg2);
border-bottom: 1px solid var(--border);
padding: 28px 32px;
text-align: center;
}
header h1 {
font-size: 2.2rem;
font-weight: 700;
color: var(--c1);
letter-spacing: 3px;
}
header .subtitle {
color: var(--text);
margin-top: 8px;
font-size: 0.85rem;
letter-spacing: 1px;
opacity: 0.7;
}
.nav-back {
padding: 10px 32px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
}
.nav-back a {
color: var(--c1);
text-decoration: none;
font-size: 0.85rem;
font-weight: 600;
}
.nav-back a:hover { text-decoration: underline; }
.container { max-width: 1400px; margin: 0 auto; padding: 24px 16px; }
h2 {
color: var(--c1);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 16px;
border-bottom: 2px solid var(--border);
padding-bottom: 8px;
}
.section { margin-bottom: 40px; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 14px;
padding: 22px;
box-shadow: 0 2px 10px rgba(60,80,50,0.08);
text-align: center;
}
.card .label {
color: var(--c2);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 10px;
}
.card .value {
color: var(--c1);
font-size: 1.8rem;
font-weight: 700;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.chart-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
box-shadow: 0 2px 10px rgba(60,80,50,0.08);
}
.chart-card h3 {
color: var(--c2);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 14px;
}
canvas { max-width: 100%; }
.table-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.search-bar { display: flex; align-items: center; gap: 10px; }
.search-bar input {
background: #fff;
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
padding: 8px 14px;
width: 280px;
outline: none;
border-radius: 8px;
}
.search-bar input:focus { border-color: var(--c1); }
.search-bar input::placeholder { color: #93a389; }
.search-bar label {
color: var(--c2);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
}
.page-info { color: #6b7b62; font-size: 0.8rem; letter-spacing: 1px; min-width: 140px; }
.page-size-select { display: flex; align-items: center; gap: 6px; }
.page-size-select label { color: var(--c2); font-size: 0.8rem; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; }
.page-size-select select {
background: #fff;
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
padding: 6px 10px;
outline: none;
border-radius: 8px;
cursor: pointer;
}
.page-btns { display: flex; gap: 8px; margin-left: auto; }
.page-btns button {
background: #fff;
border: 1px solid var(--c1);
color: var(--c1);
font-family: inherit;
font-weight: 600;
font-size: 12px;
padding: 6px 16px;
cursor: pointer;
border-radius: 8px;
letter-spacing: 0.5px;
transition: background 0.15s;
}
.page-btns button:hover:not(:disabled) { background: #eef5e8; }
.page-btns button:disabled { opacity: 0.35; cursor: default; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: var(--bg2);
color: var(--c2);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
padding: 10px 14px;
border-bottom: 2px solid var(--border);
text-align: left;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; }
tbody tr:hover { background: #f0f6ea; }
tbody td { padding: 9px 14px; color: var(--text); vertical-align: top; word-break: break-all; }
tbody td:first-child { color: var(--c1); font-weight: 700; white-space: nowrap; }
.status { display: inline-block; padding: 2px 9px; border-radius: 999px; font-weight: 700; font-size: 12px; }
.s2xx { color: #2f6a26; background: #dff3d8; }
.s3xx { color: #2c5f73; background: #dcebf2; }
.s4xx { color: #8a5a13; background: #fbe9d0; }
.s5xx { color: #93372a; background: #fadbd8; }
.level { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.l-error, .l-crit, .l-alert, .l-emerg { color: #93372a; background: #fadbd8; }
.l-warn { color: #8a5a13; background: #fbe9d0; }
.l-info, .l-notice { color: #2c5f73; background: #dcebf2; }
.l-debug { color: #6b7b62; background: #e9efe3; }
.rank { color: var(--c3); font-weight: 700; }
footer {
text-align: center;
padding: 24px;
color: #8a9880;
font-size: 0.75rem;
border-top: 1px solid var(--border);
letter-spacing: 1px;
}
.day-link { color: var(--c1); text-decoration: none; font-weight: 600; }
.day-link:hover { text-decoration: underline; }
.err-low { color: var(--c1); font-weight: 600; }
.err-med { color: var(--warn); font-weight: 600; }
.err-high { color: var(--err); font-weight: 600; }
`,
ExtraCSS: `
#map { height: 480px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg2); }
.map-label {
background: #ffffff !important;
border: 1px solid var(--c3) !important;
border-radius: 8px !important;
color: var(--text) !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 11px !important;
padding: 3px 8px !important;
white-space: nowrap !important;
box-shadow: 0 2px 8px rgba(60,80,50,0.15) !important;
pointer-events: none !important;
}
.map-label::before { display: none !important; }
`,
ChartFont: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
ChartText: "#33402d",
ChartGrid: "#d3e0c8",
Chart1: "#6b9b52",
Chart2: "#5b8fa3",
Chart2Fill: "rgba(91,143,163,0.15)",
Chart3: "#e0956b",
ChartWarn: "#d99a3d",
ChartErr: "#c96a5b",
MapTileURL: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
MapAttribution: `&copy; <a href="https://carto.com/">CARTO</a>`,
MarkerColor: "#e0956b",
}

View file

@ -0,0 +1,264 @@
package report
// cyberpunkTheme is the original neon/CRT dark theme: monospace font,
// scanline overlay, heavy glow effects, bordered badges.
var cyberpunkTheme = themeAssets{
CSS: `
:root {
--bg: #050510;
--bg2: #0a0a20;
--c1: #00ff9f;
--c2: #00e5ff;
--c3: #ff00c8;
--warn: #ffcc00;
--err: #ff4444;
--text: #c0c0e0;
--border: #1a1a3a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Courier New', monospace;
font-size: 14px;
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
body::after {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.08) 2px,
rgba(0,0,0,0.08) 4px
);
pointer-events: none;
z-index: 9999;
}
header {
background: var(--bg2);
border-bottom: 2px solid var(--c3);
padding: 24px 32px;
text-align: center;
}
header h1 {
font-size: 2.4rem;
color: var(--c2);
text-shadow: 0 0 20px var(--c2), 0 0 40px rgba(0,229,255,0.5);
letter-spacing: 6px;
text-transform: uppercase;
}
header .subtitle {
color: var(--c1);
text-shadow: 0 0 10px var(--c1);
margin-top: 6px;
font-size: 0.85rem;
letter-spacing: 3px;
}
.nav-back {
padding: 10px 32px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
}
.nav-back a {
color: var(--c3);
text-decoration: none;
font-size: 0.85rem;
letter-spacing: 2px;
text-shadow: 0 0 8px var(--c3);
}
.nav-back a:hover { text-shadow: 0 0 16px var(--c3); }
.container { max-width: 1400px; margin: 0 auto; padding: 24px 16px; }
h2 {
color: var(--c2);
text-shadow: 0 0 12px var(--c2);
font-size: 1.2rem;
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.section { margin-bottom: 40px; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.card {
background: var(--bg2);
border: 1px solid var(--c3);
border-radius: 4px;
padding: 20px;
box-shadow: 0 0 16px rgba(255,0,200,0.15), inset 0 0 20px rgba(0,0,0,0.3);
text-align: center;
}
.card .label {
color: var(--c1);
text-shadow: 0 0 8px var(--c1);
font-size: 0.75rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 10px;
}
.card .value {
color: var(--c2);
text-shadow: 0 0 16px var(--c2);
font-size: 1.8rem;
font-weight: bold;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.chart-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
box-shadow: 0 0 12px rgba(0,229,255,0.08);
}
.chart-card h3 {
color: var(--c1);
text-shadow: 0 0 8px var(--c1);
font-size: 0.8rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 14px;
}
canvas { max-width: 100%; }
.table-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.search-bar { display: flex; align-items: center; gap: 10px; }
.search-bar input {
background: var(--bg2);
border: 1px solid var(--c1);
color: var(--c1);
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 6px 12px;
width: 280px;
outline: none;
border-radius: 2px;
box-shadow: 0 0 8px rgba(0,255,159,0.15);
}
.search-bar input::placeholder { color: rgba(0,255,159,0.4); }
.search-bar label {
color: var(--c1);
font-size: 0.8rem;
letter-spacing: 1px;
text-transform: uppercase;
}
.page-info { color: rgba(0,255,159,0.6); font-size: 0.8rem; letter-spacing: 1px; min-width: 140px; }
.page-size-select { display: flex; align-items: center; gap: 6px; }
.page-size-select label { color: var(--c1); font-size: 0.8rem; letter-spacing: 1px; text-transform: uppercase; }
.page-size-select select {
background: var(--bg2);
border: 1px solid var(--c1);
color: var(--c1);
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 4px 8px;
outline: none;
border-radius: 2px;
cursor: pointer;
}
.page-btns { display: flex; gap: 8px; margin-left: auto; }
.page-btns button {
background: transparent;
border: 1px solid var(--c2);
color: var(--c2);
font-family: 'Courier New', monospace;
font-size: 12px;
padding: 5px 14px;
cursor: pointer;
border-radius: 2px;
letter-spacing: 1px;
transition: background 0.15s, box-shadow 0.15s;
}
.page-btns button:hover:not(:disabled) { background: rgba(0,229,255,0.1); box-shadow: 0 0 8px rgba(0,229,255,0.3); }
.page-btns button:disabled { opacity: 0.3; cursor: default; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: rgba(0,229,255,0.05);
color: var(--c2);
text-shadow: 0 0 6px var(--c2);
font-size: 0.75rem;
letter-spacing: 2px;
text-transform: uppercase;
padding: 10px 14px;
border-bottom: 1px solid var(--c2);
text-align: left;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; }
tbody tr:hover { background: rgba(0,255,159,0.04); }
tbody td { padding: 8px 14px; color: var(--text); vertical-align: top; word-break: break-all; }
tbody td:first-child { color: var(--c1); font-weight: bold; white-space: nowrap; }
.status { display: inline-block; padding: 1px 6px; border-radius: 3px; font-weight: bold; font-size: 12px; }
.s2xx { color: var(--c1); border: 1px solid var(--c1); text-shadow: 0 0 6px var(--c1); }
.s3xx { color: var(--c2); border: 1px solid var(--c2); }
.s4xx { color: var(--warn); border: 1px solid var(--warn); }
.s5xx { color: var(--err); border: 1px solid var(--err); text-shadow: 0 0 8px var(--err); }
.level { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
.l-error, .l-crit, .l-alert, .l-emerg { color: var(--err); border: 1px solid var(--err); }
.l-warn { color: var(--warn); border: 1px solid var(--warn); }
.l-info, .l-notice { color: var(--c2); border: 1px solid var(--c2); }
.l-debug { color: #888; border: 1px solid #444; }
.rank { color: var(--c3); text-shadow: 0 0 6px var(--c3); }
footer {
text-align: center;
padding: 24px;
color: rgba(192,192,224,0.4);
font-size: 0.75rem;
border-top: 1px solid var(--border);
letter-spacing: 2px;
}
.day-link { color: var(--c2); text-decoration: none; }
.day-link:hover { text-shadow: 0 0 10px var(--c2); }
.err-low { color: var(--c1); text-shadow: 0 0 6px var(--c1); }
.err-med { color: var(--warn); }
.err-high { color: var(--err); text-shadow: 0 0 8px var(--err); }
`,
ExtraCSS: `
#map { height: 480px; border: 1px solid var(--c3); border-radius: 4px; background: var(--bg2); }
.map-label {
background: rgba(5,5,16,0.85) !important;
border: 1px solid var(--c3) !important;
border-radius: 3px !important;
color: #fff !important;
font-family: 'Courier New', monospace !important;
font-size: 11px !important;
padding: 2px 5px !important;
white-space: nowrap !important;
box-shadow: 0 0 6px rgba(255,0,200,0.4) !important;
pointer-events: none !important;
}
.map-label::before { display: none !important; }
`,
ChartFont: "'Courier New', monospace",
ChartText: "#c0c0e0",
ChartGrid: "#1a1a3a",
Chart1: "#00ff9f",
Chart2: "#00e5ff",
Chart2Fill: "rgba(0,229,255,0.08)",
Chart3: "#ff00c8",
ChartWarn: "#ffcc00",
ChartErr: "#ff4444",
MapTileURL: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
MapAttribution: `&copy; <a href="https://carto.com/">CARTO</a>`,
MarkerColor: "#ff00c8",
}

View file

@ -0,0 +1,249 @@
package report
// purplerainTheme is a modern dark-mode theme: purple/indigo/fuchsia
// palette, system sans-serif font, soft restrained glow (no scanline),
// rounded cards, and pill-shaped badges.
var purplerainTheme = themeAssets{
CSS: `
:root {
--bg: #120c22;
--bg2: #1e1638;
--c1: #a78bfa;
--c2: #818cf8;
--c3: #e879f9;
--warn: #fbbf24;
--err: #fb7185;
--text: #e5defa;
--border: #332a5c;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
min-height: 100vh;
}
header {
background: linear-gradient(180deg, var(--bg2), var(--bg));
border-bottom: 1px solid var(--border);
padding: 28px 32px;
text-align: center;
}
header h1 {
font-size: 2.2rem;
font-weight: 700;
color: var(--c1);
text-shadow: 0 0 18px rgba(167,139,250,0.45);
letter-spacing: 4px;
text-transform: uppercase;
}
header .subtitle {
color: var(--c2);
margin-top: 8px;
font-size: 0.85rem;
letter-spacing: 2px;
opacity: 0.9;
}
.nav-back {
padding: 10px 32px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
}
.nav-back a {
color: var(--c3);
text-decoration: none;
font-size: 0.85rem;
letter-spacing: 1px;
}
.nav-back a:hover { text-decoration: underline; }
.container { max-width: 1400px; margin: 0 auto; padding: 24px 16px; }
h2 {
color: var(--c1);
font-size: 1.15rem;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.section { margin-bottom: 40px; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px;
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
text-align: center;
}
.card .label {
color: var(--c2);
font-size: 0.75rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 10px;
opacity: 0.85;
}
.card .value {
color: var(--c1);
font-size: 1.8rem;
font-weight: 700;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.chart-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
box-shadow: 0 6px 18px rgba(0,0,0,0.3);
}
.chart-card h3 {
color: var(--c2);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 14px;
opacity: 0.9;
}
canvas { max-width: 100%; }
.table-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.search-bar { display: flex; align-items: center; gap: 10px; }
.search-bar input {
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
padding: 8px 14px;
width: 280px;
outline: none;
border-radius: 8px;
}
.search-bar input:focus { border-color: var(--c1); }
.search-bar input::placeholder { color: rgba(229,222,250,0.35); }
.search-bar label {
color: var(--c2);
font-size: 0.8rem;
letter-spacing: 1px;
text-transform: uppercase;
}
.page-info { color: rgba(229,222,250,0.55); font-size: 0.8rem; letter-spacing: 1px; min-width: 140px; }
.page-size-select { display: flex; align-items: center; gap: 6px; }
.page-size-select label { color: var(--c2); font-size: 0.8rem; letter-spacing: 1px; text-transform: uppercase; }
.page-size-select select {
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
padding: 6px 10px;
outline: none;
border-radius: 8px;
cursor: pointer;
}
.page-btns { display: flex; gap: 8px; margin-left: auto; }
.page-btns button {
background: transparent;
border: 1px solid var(--c1);
color: var(--c1);
font-family: inherit;
font-size: 12px;
padding: 6px 16px;
cursor: pointer;
border-radius: 8px;
letter-spacing: 1px;
transition: background 0.15s;
}
.page-btns button:hover:not(:disabled) { background: rgba(167,139,250,0.12); }
.page-btns button:disabled { opacity: 0.3; cursor: default; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: rgba(129,140,248,0.08);
color: var(--c2);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
text-align: left;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; }
tbody tr:hover { background: rgba(167,139,250,0.06); }
tbody td { padding: 9px 14px; color: var(--text); vertical-align: top; word-break: break-all; }
tbody td:first-child { color: var(--c1); font-weight: 600; white-space: nowrap; }
.status { display: inline-block; padding: 2px 9px; border-radius: 999px; font-weight: 600; font-size: 12px; }
.s2xx { color: #2e1065; background: var(--c1); }
.s3xx { color: #1e1b4b; background: var(--c2); }
.s4xx { color: #451a03; background: var(--warn); }
.s5xx { color: #4c0519; background: var(--err); }
.level { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
.l-error, .l-crit, .l-alert, .l-emerg { color: #4c0519; background: var(--err); }
.l-warn { color: #451a03; background: var(--warn); }
.l-info, .l-notice { color: #1e1b4b; background: var(--c2); }
.l-debug { color: #b8b0da; background: rgba(255,255,255,0.06); }
.rank { color: var(--c3); }
footer {
text-align: center;
padding: 24px;
color: rgba(229,222,250,0.35);
font-size: 0.75rem;
border-top: 1px solid var(--border);
letter-spacing: 2px;
}
.day-link { color: var(--c1); text-decoration: none; }
.day-link:hover { text-decoration: underline; }
.err-low { color: var(--c1); }
.err-med { color: var(--warn); }
.err-high { color: var(--err); }
`,
ExtraCSS: `
#map { height: 480px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg2); }
.map-label {
background: rgba(24,18,48,0.92) !important;
border: 1px solid var(--c1) !important;
border-radius: 8px !important;
color: var(--text) !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 11px !important;
padding: 3px 8px !important;
white-space: nowrap !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.35) !important;
pointer-events: none !important;
}
.map-label::before { display: none !important; }
`,
ChartFont: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
ChartText: "#e5defa",
ChartGrid: "#332a5c",
Chart1: "#a78bfa",
Chart2: "#818cf8",
Chart2Fill: "rgba(129,140,248,0.12)",
Chart3: "#e879f9",
ChartWarn: "#fbbf24",
ChartErr: "#fb7185",
MapTileURL: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
MapAttribution: `&copy; <a href="https://carto.com/">CARTO</a>`,
MarkerColor: "#a78bfa",
}

55
internal/report/themes.go Normal file
View file

@ -0,0 +1,55 @@
package report
import "sort"
// themeAssets bundles everything a report or index page needs to render in
// one visual theme: the shared stylesheet (cards, tables, nav, badges, ...),
// report-only CSS (map + map label), and the color/URL values referenced by
// the inline Chart.js / Leaflet JS.
type themeAssets struct {
CSS string // shared :root + component stylesheet
ExtraCSS string // report-only overrides (map, map label)
ChartFont string // Chart.js font-family
ChartText string // Chart.js default text color
ChartGrid string // Chart.js default grid/border color
Chart1 string // 2xx / success series color
Chart2 string // 3xx / timeline series color
Chart2Fill string // timeline area fill (rgba)
Chart3 string // top-paths bar color / decorative accent
ChartWarn string // 4xx series color
ChartErr string // 5xx series color
MapTileURL string
MapAttribution string
MarkerColor string
}
// DefaultTheme is used when no theme is specified or an unknown name is given.
const DefaultTheme = "cyberpunk"
var themeRegistry = map[string]themeAssets{
"cyberpunk": cyberpunkTheme,
"purplerain": purplerainTheme,
"cactus": cactusTheme,
}
// ThemeNames returns the sorted list of valid --theme values.
func ThemeNames() []string {
names := make([]string, 0, len(themeRegistry))
for name := range themeRegistry {
names = append(names, name)
}
sort.Strings(names)
return names
}
// themeFor looks up a theme by name, falling back to DefaultTheme for an
// empty or unrecognized name.
func themeFor(name string) themeAssets {
if t, ok := themeRegistry[name]; ok {
return t
}
return themeRegistry[DefaultTheme]
}