commit
65a25b3916
10 changed files with 1069 additions and 287 deletions
|
|
@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
24
cmd/root.go
24
cmd/root.go
|
|
@ -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
17
cmd/root_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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: '© <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>
|
||||||
|
|
||||||
|
|
|
||||||
247
internal/report/theme_cactus.go
Normal file
247
internal/report/theme_cactus.go
Normal 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: `© <a href="https://carto.com/">CARTO</a>`,
|
||||||
|
MarkerColor: "#e0956b",
|
||||||
|
}
|
||||||
264
internal/report/theme_cyberpunk.go
Normal file
264
internal/report/theme_cyberpunk.go
Normal 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: `© <a href="https://carto.com/">CARTO</a>`,
|
||||||
|
MarkerColor: "#ff00c8",
|
||||||
|
}
|
||||||
249
internal/report/theme_purplerain.go
Normal file
249
internal/report/theme_purplerain.go
Normal 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: `© <a href="https://carto.com/">CARTO</a>`,
|
||||||
|
MarkerColor: "#a78bfa",
|
||||||
|
}
|
||||||
55
internal/report/themes.go
Normal file
55
internal/report/themes.go
Normal 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]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue