initial commit

This commit is contained in:
mr0xb 2026-08-24 14:44:54 -04:00
commit 885e7d6e02
22 changed files with 3001 additions and 0 deletions

68
internal/geo/geo.go Normal file
View file

@ -0,0 +1,68 @@
package geo
import (
"fmt"
"net"
"github.com/oschwald/geoip2-golang"
)
// GeoPoint holds geographic information for an IP address.
type GeoPoint struct {
IP string
Lat float64
Lon float64
Country string
CountryCode string
Count int
}
// GeoLookup wraps the geoip2 database reader.
// If no database path is configured, all lookups return nil (no-op).
type GeoLookup struct {
db *geoip2.Reader
}
// NewGeoLookup creates a GeoLookup. If path is empty, returns a disabled (no-op) lookup.
// Returns an error if path is non-empty but the file cannot be opened.
func NewGeoLookup(path string) (*GeoLookup, error) {
if path == "" {
return &GeoLookup{}, nil
}
db, err := geoip2.Open(path)
if err != nil {
return nil, fmt.Errorf("geoip2 open %q: %w", path, err)
}
return &GeoLookup{db: db}, nil
}
// IsEnabled returns true if a GeoIP2 database is loaded.
func (g *GeoLookup) IsEnabled() bool {
return g.db != nil
}
// Close releases the database if open.
func (g *GeoLookup) Close() {
if g.db != nil {
g.db.Close()
}
}
// Lookup returns a GeoPoint for the given IP, or nil if the database is not loaded
// or the IP is nil/not found.
func (g *GeoLookup) Lookup(ip net.IP) *GeoPoint {
if g.db == nil || ip == nil {
return nil
}
record, err := g.db.City(ip)
if err != nil {
return nil
}
return &GeoPoint{
IP: ip.String(),
Lat: record.Location.Latitude,
Lon: record.Location.Longitude,
Country: record.Country.Names["en"],
CountryCode: record.Country.IsoCode,
}
}

59
internal/geo/geo_test.go Normal file
View file

@ -0,0 +1,59 @@
package geo
import (
"net"
"testing"
)
func TestNewGeoLookup_NilPath(t *testing.T) {
gl, err := NewGeoLookup("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gl == nil {
t.Fatal("expected non-nil GeoLookup")
}
if gl.IsEnabled() {
t.Error("expected IsEnabled() = false for empty path")
}
}
func TestGeoLookup_LookupWithNoDb(t *testing.T) {
gl, _ := NewGeoLookup("")
result := gl.Lookup(net.ParseIP("8.8.8.8"))
if result != nil {
t.Errorf("expected nil result when no db, got %+v", result)
}
}
func TestGeoLookup_LookupNilIP(t *testing.T) {
gl, _ := NewGeoLookup("")
result := gl.Lookup(nil)
if result != nil {
t.Errorf("expected nil result for nil IP, got %+v", result)
}
}
func TestNewGeoLookup_InvalidPath(t *testing.T) {
_, err := NewGeoLookup("/nonexistent/path/GeoLite2-City.mmdb")
if err == nil {
t.Error("expected error for nonexistent db file, got nil")
}
}
func TestGeoPoint_Fields(t *testing.T) {
gp := GeoPoint{
IP: "1.2.3.4",
Lat: 37.751,
Lon: -97.822,
Country: "United States",
CountryCode: "US",
Count: 42,
}
if gp.IP != "1.2.3.4" {
t.Errorf("IP = %q", gp.IP)
}
if gp.Count != 42 {
t.Errorf("Count = %d", gp.Count)
}
}

70
internal/parser/access.go Normal file
View file

@ -0,0 +1,70 @@
package parser
import (
"fmt"
"regexp"
"strconv"
"time"
)
// AccessEntry holds a parsed nginx combined-format access log entry.
type AccessEntry struct {
RemoteAddr string
RemoteUser string
Time time.Time
Method string
Path string
Protocol string
Status int
BytesSent int64
Referer string
UserAgent string
}
// accessLogRe matches nginx combined log format.
// Groups: remoteAddr, remoteUser, time, method, path, protocol, status, bytes, referer, userAgent
var accessLogRe = regexp.MustCompile(
`^(\S+) - (\S+) \[([^\]]+)\] "(\S+) (\S+) (\S+)" (\d+) (\d+|-) "([^"]*)" "([^"]*)"$`,
)
const accessTimeLayout = "02/Jan/2006:15:04:05 -0700"
// ParseAccessLine parses a single nginx access log line.
// Returns an error for malformed lines.
func ParseAccessLine(line string) (AccessEntry, error) {
m := accessLogRe.FindStringSubmatch(line)
if m == nil {
return AccessEntry{}, fmt.Errorf("access: no match: %q", line)
}
t, err := time.Parse(accessTimeLayout, m[3])
if err != nil {
return AccessEntry{}, fmt.Errorf("access: parse time %q: %w", m[3], err)
}
status, err := strconv.Atoi(m[7])
if err != nil {
return AccessEntry{}, fmt.Errorf("access: parse status %q: %w", m[7], err)
}
var bytes int64
if m[8] != "-" {
bytes, err = strconv.ParseInt(m[8], 10, 64)
if err != nil {
return AccessEntry{}, fmt.Errorf("access: parse bytes %q: %w", m[8], err)
}
}
return AccessEntry{
RemoteAddr: m[1],
RemoteUser: m[2],
Time: t,
Method: m[4],
Path: m[5],
Protocol: m[6],
Status: status,
BytesSent: bytes,
Referer: m[9],
UserAgent: m[10],
}, nil
}

View file

@ -0,0 +1,110 @@
package parser
import (
"testing"
"time"
)
func TestParseAccessLine_Valid(t *testing.T) {
line := `192.168.1.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "http://www.example.com/start.html" "Mozilla/4.08 [en] (Win98; I ;Nav)"`
entry, err := ParseAccessLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.RemoteAddr != "192.168.1.1" {
t.Errorf("RemoteAddr = %q, want %q", entry.RemoteAddr, "192.168.1.1")
}
if entry.RemoteUser != "frank" {
t.Errorf("RemoteUser = %q, want %q", entry.RemoteUser, "frank")
}
want := time.Date(2000, 10, 10, 13, 55, 36, 0, time.FixedZone("", -7*3600))
if !entry.Time.Equal(want) {
t.Errorf("Time = %v, want %v", entry.Time, want)
}
if entry.Method != "GET" {
t.Errorf("Method = %q, want %q", entry.Method, "GET")
}
if entry.Path != "/apache_pb.gif" {
t.Errorf("Path = %q, want %q", entry.Path, "/apache_pb.gif")
}
if entry.Protocol != "HTTP/1.0" {
t.Errorf("Protocol = %q, want %q", entry.Protocol, "HTTP/1.0")
}
if entry.Status != 200 {
t.Errorf("Status = %d, want %d", entry.Status, 200)
}
if entry.BytesSent != 2326 {
t.Errorf("BytesSent = %d, want %d", entry.BytesSent, 2326)
}
if entry.Referer != "http://www.example.com/start.html" {
t.Errorf("Referer = %q, want %q", entry.Referer, "http://www.example.com/start.html")
}
}
func TestParseAccessLine_DashFields(t *testing.T) {
line := `10.0.0.1 - - [15/Jan/2024:08:23:41 +0000] "POST /api/users HTTP/1.1" 201 512 "-" "curl/7.68.0"`
entry, err := ParseAccessLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.RemoteUser != "-" {
t.Errorf("RemoteUser = %q, want %q", entry.RemoteUser, "-")
}
if entry.Referer != "-" {
t.Errorf("Referer = %q, want %q", entry.Referer, "-")
}
if entry.BytesSent != 512 {
t.Errorf("BytesSent = %d, want %d", entry.BytesSent, 512)
}
}
func TestParseAccessLine_BytesDash(t *testing.T) {
// bytes sent as "-" should parse as 0
line := `127.0.0.1 - - [22/Feb/2024:10:00:00 +0000] "GET /health HTTP/1.1" 200 - "-" "kube-probe/1.27"`
entry, err := ParseAccessLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.BytesSent != 0 {
t.Errorf("BytesSent = %d, want 0", entry.BytesSent)
}
}
func TestParseAccessLine_IPv6(t *testing.T) {
line := `2001:db8::1 - - [20/Feb/2024:16:45:00 +0000] "GET /index.html HTTP/2.0" 200 4096 "-" "Googlebot/2.1"`
entry, err := ParseAccessLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.RemoteAddr != "2001:db8::1" {
t.Errorf("RemoteAddr = %q, want %q", entry.RemoteAddr, "2001:db8::1")
}
}
func TestParseAccessLine_Malformed(t *testing.T) {
cases := []string{
"this is a malformed line",
"",
"incomplete line without proper format",
}
for _, c := range cases {
_, err := ParseAccessLine(c)
if err == nil {
t.Errorf("expected error for malformed line %q, got nil", c)
}
}
}
func TestParseAccessLine_Status(t *testing.T) {
line := `172.16.0.50 - admin [01/Mar/2024:12:00:00 +0100] "DELETE /resource/42 HTTP/1.1" 404 0 "https://example.org/page" "Mozilla/5.0"`
entry, err := ParseAccessLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.Status != 404 {
t.Errorf("Status = %d, want 404", entry.Status)
}
if entry.Method != "DELETE" {
t.Errorf("Method = %q, want DELETE", entry.Method)
}
}

67
internal/parser/error.go Normal file
View file

@ -0,0 +1,67 @@
package parser
import (
"fmt"
"regexp"
"strconv"
"time"
)
// ErrorEntry holds a parsed nginx error log entry.
type ErrorEntry struct {
Time time.Time
Level string // debug/info/notice/warn/error/crit/alert/emerg
PID int
TID int
ConnID int // 0 if not present
Message string
}
// errorLogRe matches nginx error log format.
// Groups: time, level, pid, tid, connID (optional), message
var errorLogRe = regexp.MustCompile(
`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] (\d+)#(\d+): (?:\*(\d+) )?(.+)$`,
)
const errorTimeLayout = "2006/01/02 15:04:05"
// ParseErrorLine parses a single nginx error log line.
// Returns an error for malformed lines.
func ParseErrorLine(line string) (ErrorEntry, error) {
m := errorLogRe.FindStringSubmatch(line)
if m == nil {
return ErrorEntry{}, fmt.Errorf("error: no match: %q", line)
}
t, err := time.ParseInLocation(errorTimeLayout, m[1], time.UTC)
if err != nil {
return ErrorEntry{}, fmt.Errorf("error: parse time %q: %w", m[1], err)
}
pid, err := strconv.Atoi(m[3])
if err != nil {
return ErrorEntry{}, fmt.Errorf("error: parse pid %q: %w", m[3], err)
}
tid, err := strconv.Atoi(m[4])
if err != nil {
return ErrorEntry{}, fmt.Errorf("error: parse tid %q: %w", m[4], err)
}
var connID int
if m[5] != "" {
connID, err = strconv.Atoi(m[5])
if err != nil {
return ErrorEntry{}, fmt.Errorf("error: parse connid %q: %w", m[5], err)
}
}
return ErrorEntry{
Time: t,
Level: m[2],
PID: pid,
TID: tid,
ConnID: connID,
Message: m[6],
}, nil
}

View file

@ -0,0 +1,93 @@
package parser
import (
"testing"
"time"
)
func TestParseErrorLine_WithConnID(t *testing.T) {
line := `2024/02/20 16:45:01 [error] 1234#5678: *99 connect() failed (111: Connection refused) while connecting to upstream`
entry, err := ParseErrorLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := time.Date(2024, 2, 20, 16, 45, 1, 0, time.UTC)
if !entry.Time.Equal(want) {
t.Errorf("Time = %v, want %v", entry.Time, want)
}
if entry.Level != "error" {
t.Errorf("Level = %q, want %q", entry.Level, "error")
}
if entry.PID != 1234 {
t.Errorf("PID = %d, want 1234", entry.PID)
}
if entry.TID != 5678 {
t.Errorf("TID = %d, want 5678", entry.TID)
}
if entry.ConnID != 99 {
t.Errorf("ConnID = %d, want 99", entry.ConnID)
}
if entry.Message != "connect() failed (111: Connection refused) while connecting to upstream" {
t.Errorf("Message = %q", entry.Message)
}
}
func TestParseErrorLine_WithoutConnID(t *testing.T) {
line := `2024/02/20 17:00:00 [warn] 1234#5678: worker process 9999 exited on signal 15`
entry, err := ParseErrorLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.Level != "warn" {
t.Errorf("Level = %q, want %q", entry.Level, "warn")
}
if entry.ConnID != 0 {
t.Errorf("ConnID = %d, want 0", entry.ConnID)
}
if entry.Message != "worker process 9999 exited on signal 15" {
t.Errorf("Message = %q", entry.Message)
}
}
func TestParseErrorLine_AllLevels(t *testing.T) {
levels := []string{"debug", "info", "notice", "warn", "error", "crit", "alert", "emerg"}
for _, level := range levels {
line := "2024/01/01 00:00:00 [" + level + "] 100#200: test message"
entry, err := ParseErrorLine(line)
if err != nil {
t.Errorf("level %q: unexpected error: %v", level, err)
continue
}
if entry.Level != level {
t.Errorf("level %q: got Level = %q", level, entry.Level)
}
}
}
func TestParseErrorLine_Info(t *testing.T) {
line := `2024/02/21 08:30:00 [info] 1234#5678: start worker process 10001`
entry, err := ParseErrorLine(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.Level != "info" {
t.Errorf("Level = %q, want info", entry.Level)
}
if entry.PID != 1234 {
t.Errorf("PID = %d, want 1234", entry.PID)
}
}
func TestParseErrorLine_Malformed(t *testing.T) {
cases := []string{
"this is a malformed line",
"",
"2024/02/20 not-a-proper-format",
}
for _, c := range cases {
_, err := ParseErrorLine(c)
if err == nil {
t.Errorf("expected error for malformed line %q, got nil", c)
}
}
}

167
internal/parser/scanner.go Normal file
View file

@ -0,0 +1,167 @@
package parser
import (
"bufio"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// ScanDirectory scans dir for nginx access and error log files.
// It matches filenames like access*.log* and error*.log*.
// If includeGzip is false, .gz files are skipped.
// Returns (accessFiles, errorFiles, error) sorted newest-first.
func ScanDirectory(dir string, includeGzip bool) ([]string, []string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, fmt.Errorf("scandir %q: %w", dir, err)
}
var accessFiles, errorFiles []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !includeGzip && strings.HasSuffix(name, ".gz") {
continue
}
if matchesLogPattern(name, "access") {
accessFiles = append(accessFiles, filepath.Join(dir, name))
} else if matchesLogPattern(name, "error") {
errorFiles = append(errorFiles, filepath.Join(dir, name))
}
}
sortLogFiles(accessFiles)
sortLogFiles(errorFiles)
return accessFiles, errorFiles, nil
}
// matchesLogPattern returns true if filename starts with prefix and contains ".log".
func matchesLogPattern(name, prefix string) bool {
if !strings.HasPrefix(name, prefix) {
return false
}
return strings.Contains(name, ".log")
}
// logSortKey returns a sort key such that:
// - "access.log" → (0, 0) — most recent
// - "access.log.1" → (1, 0)
// - "access.log.1.gz" → (1, 1)
// - "access.log.2.gz" → (2, 1)
func logSortKey(path string) (int, int) {
name := filepath.Base(path)
// strip prefix up to and including ".log"
idx := strings.Index(name, ".log")
if idx < 0 {
return 999, 0
}
suffix := name[idx+4:] // e.g. "", ".1", ".1.gz", ".2.gz"
isGz := 0
if strings.HasSuffix(suffix, ".gz") {
isGz = 1
suffix = strings.TrimSuffix(suffix, ".gz")
}
suffix = strings.TrimPrefix(suffix, ".")
num := 0
if suffix != "" {
fmt.Sscanf(suffix, "%d", &num)
}
return num, isGz
}
func sortLogFiles(files []string) {
sort.SliceStable(files, func(i, j int) bool {
ni, gi := logSortKey(files[i])
nj, gj := logSortKey(files[j])
if ni != nj {
return ni < nj
}
return gi < gj
})
}
// openLogFile opens a log file for reading.
// If the path ends in ".gz", it transparently decompresses.
func openLogFile(path string) (io.ReadCloser, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
if strings.HasSuffix(path, ".gz") {
gz, err := gzip.NewReader(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("gzip %q: %w", path, err)
}
return &gzipReadCloser{gz: gz, f: f}, nil
}
return f, nil
}
// gzipReadCloser closes both the gzip reader and underlying file.
type gzipReadCloser struct {
gz *gzip.Reader
f *os.File
}
func (g *gzipReadCloser) Read(p []byte) (int, error) { return g.gz.Read(p) }
func (g *gzipReadCloser) Close() error {
err := g.gz.Close()
g.f.Close()
return err
}
// ReadAccessEntries reads all access log entries from the given files.
// Malformed lines are silently skipped.
func ReadAccessEntries(files []string) ([]AccessEntry, error) {
var entries []AccessEntry
for _, path := range files {
rc, err := openLogFile(path)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(rc)
for scanner.Scan() {
if e, err := ParseAccessLine(scanner.Text()); err == nil {
entries = append(entries, e)
}
}
rc.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %q: %w", path, err)
}
}
return entries, nil
}
// ReadErrorEntries reads all error log entries from the given files.
// Malformed lines are silently skipped.
func ReadErrorEntries(files []string) ([]ErrorEntry, error) {
var entries []ErrorEntry
for _, path := range files {
rc, err := openLogFile(path)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(rc)
for scanner.Scan() {
if e, err := ParseErrorLine(scanner.Text()); err == nil {
entries = append(entries, e)
}
}
rc.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %q: %w", path, err)
}
}
return entries, nil
}

View file

@ -0,0 +1,155 @@
package parser
import (
"bufio"
"os"
"path/filepath"
"testing"
)
func TestScanDirectory_FindsAccessAndErrorLogs(t *testing.T) {
dir := t.TempDir()
// Create test log files
files := []string{
"access.log",
"access.log.1",
"error.log",
"error.log.1",
"other.txt", // should be ignored
}
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f), []byte(""), 0644); err != nil {
t.Fatal(err)
}
}
accessFiles, errorFiles, err := ScanDirectory(dir, false)
if err != nil {
t.Fatalf("ScanDirectory error: %v", err)
}
if len(accessFiles) != 2 {
t.Errorf("expected 2 access files, got %d: %v", len(accessFiles), accessFiles)
}
if len(errorFiles) != 2 {
t.Errorf("expected 2 error files, got %d: %v", len(errorFiles), errorFiles)
}
}
func TestScanDirectory_GzipIncluded(t *testing.T) {
dir := t.TempDir()
files := []string{
"access.log",
"access.log.1.gz",
"error.log",
}
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f), []byte(""), 0644); err != nil {
t.Fatal(err)
}
}
accessFiles, _, err := ScanDirectory(dir, true)
if err != nil {
t.Fatalf("ScanDirectory error: %v", err)
}
if len(accessFiles) != 2 {
t.Errorf("expected 2 access files (including gz), got %d", len(accessFiles))
}
}
func TestScanDirectory_GzipExcluded(t *testing.T) {
dir := t.TempDir()
files := []string{
"access.log",
"access.log.1.gz",
}
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f), []byte(""), 0644); err != nil {
t.Fatal(err)
}
}
accessFiles, _, err := ScanDirectory(dir, false)
if err != nil {
t.Fatalf("ScanDirectory error: %v", err)
}
if len(accessFiles) != 1 {
t.Errorf("expected 1 access file (gz excluded), got %d", len(accessFiles))
}
}
func TestScanDirectory_NonexistentDir(t *testing.T) {
_, _, err := ScanDirectory("/nonexistent/path/xyz", true)
if err == nil {
t.Error("expected error for nonexistent directory, got nil")
}
}
func TestOpenLogFile_PlainText(t *testing.T) {
dir := t.TempDir()
content := "test line\n"
path := filepath.Join(dir, "test.log")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
rc, err := openLogFile(path)
if err != nil {
t.Fatalf("openLogFile error: %v", err)
}
defer rc.Close()
scanner := bufio.NewScanner(rc)
if !scanner.Scan() {
t.Fatal("expected to read a line")
}
if scanner.Text() != "test line" {
t.Errorf("got %q, want %q", scanner.Text(), "test line")
}
}
func TestOpenLogFile_Gzip(t *testing.T) {
// Use the pre-made fixture
rc, err := openLogFile("testdata/access.log.1.gz")
if err != nil {
t.Fatalf("openLogFile gz error: %v", err)
}
defer rc.Close()
scanner := bufio.NewScanner(rc)
if !scanner.Scan() {
t.Fatal("expected to read a line from gz file")
}
line := scanner.Text()
if line == "" {
t.Error("expected non-empty line from gz file")
}
}
func TestScanDirectory_SortOrder(t *testing.T) {
dir := t.TempDir()
files := []string{
"access.log.2.gz",
"access.log",
"access.log.1",
"access.log.1.gz",
}
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f), []byte(""), 0644); err != nil {
t.Fatal(err)
}
}
accessFiles, _, err := ScanDirectory(dir, true)
if err != nil {
t.Fatalf("ScanDirectory error: %v", err)
}
if len(accessFiles) != 4 {
t.Fatalf("expected 4 files, got %d", len(accessFiles))
}
// access.log should come first (most recent)
if filepath.Base(accessFiles[0]) != "access.log" {
t.Errorf("expected access.log first, got %q", filepath.Base(accessFiles[0]))
}
}

6
internal/parser/testdata/access.log vendored Normal file
View file

@ -0,0 +1,6 @@
192.168.1.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "http://www.example.com/start.html" "Mozilla/4.08 [en] (Win98; I ;Nav)"
10.0.0.1 - - [15/Jan/2024:08:23:41 +0000] "POST /api/users HTTP/1.1" 201 512 "-" "curl/7.68.0"
172.16.0.50 - admin [01/Mar/2024:12:00:00 +0100] "DELETE /resource/42 HTTP/1.1" 404 0 "https://example.org/page" "Mozilla/5.0"
2001:db8::1 - - [20/Feb/2024:16:45:00 +0000] "GET /index.html HTTP/2.0" 200 4096 "-" "Googlebot/2.1"
this is a malformed line that should be skipped
127.0.0.1 - - [22/Feb/2024:10:00:00 +0000] "GET /health HTTP/1.1" 200 - "-" "kube-probe/1.27"

BIN
internal/parser/testdata/access.log.1.gz vendored Normal file

Binary file not shown.

6
internal/parser/testdata/error.log vendored Normal file
View file

@ -0,0 +1,6 @@
2024/02/20 16:45:01 [error] 1234#5678: *99 connect() failed (111: Connection refused) while connecting to upstream
2024/02/20 17:00:00 [warn] 1234#5678: worker process 9999 exited on signal 15
2024/02/21 08:30:00 [info] 1234#5678: start worker process 10001
2024/02/21 09:00:00 [crit] 9999#0: *1 SSL_do_handshake() failed (SSL: error:14094412) while SSL handshaking
this is a malformed error line
2024/02/22 10:00:00 [notice] 1234#5678: signal process started

435
internal/report/report.go Normal file
View file

@ -0,0 +1,435 @@
package report
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"path/filepath"
"sort"
"strings"
"time"
"github.com/mr0xb/nxstats/internal/parser"
)
// IPCount holds a remote address and its request count.
type IPCount struct {
IP string
Count int
}
// PathCount holds a path, its request count, and the most frequently
// returned HTTP status code for that path.
type PathCount struct {
Path string
Count int
TopStatus int
}
// TimePoint holds a time bucket and request count.
type TimePoint struct {
Hour time.Time
Count int
}
// GeoPoint holds geographic information for an IP.
type GeoPoint struct {
IP string
Lat float64
Lon float64
Country string
CountryCode string
Count int
}
// ReportData aggregates all data needed to render the HTML report.
type ReportData struct {
GeneratedAt time.Time
AccessEntries []parser.AccessEntry
ErrorEntries []parser.ErrorEntry
TotalRequests int
TotalBytes int64
UniqueIPs int
ErrorRate float64
StatusCounts map[int]int
TopIPs []IPCount
TopPaths []PathCount
TimeSeriesData []TimePoint
GeoLocations []GeoPoint
// Set when this is a per-day report in split-by-day mode.
IndexFile string // non-empty → render "← Back to Index" nav link
DayTitle string // e.g. "2024-02-20", shown in the header
}
// DaySummary is one row in the index page's daily breakdown table.
type DaySummary struct {
Date time.Time
Filename string // bare filename, e.g. "report-2024-02-20.html"
Requests int
UniqueIPs int
TotalBytes int64
ErrorRate float64
}
// IndexData is the template data for the --split-by-day index page.
type IndexData struct {
GeneratedAt time.Time
Days []DaySummary // newest first
TotalRequests int
TotalUniqueIPs int
TotalBytes int64
OverallErrorRate float64
}
// DayReport bundles one calendar day's fully-computed ReportData with
// its output filename, ready to be written to disk.
type DayReport struct {
Date time.Time // UTC midnight for this day
Filename string // bare filename, e.g. "report-2024-02-20.html"
Data ReportData // ComputeStats already called
}
// DeriveDailyFilename builds a per-day output path from the index output path.
// E.g. ("report.html", "2024-02-20") → "report-2024-02-20.html".
func DeriveDailyFilename(outputPath, dateKey string) string {
ext := filepath.Ext(outputPath)
base := strings.TrimSuffix(filepath.Base(outputPath), ext)
dir := filepath.Dir(outputPath)
return filepath.Join(dir, base+"-"+dateKey+ext)
}
// GroupByDay partitions access and error entries by calendar day (UTC),
// runs ComputeStats on each day's ReportData, and filters geoPoints to only
// IPs present in that day's access entries. Returns DayReports sorted
// oldest-first. geoPoints may be nil (GeoIP disabled).
func GroupByDay(
outputPath string,
accessEntries []parser.AccessEntry,
errorEntries []parser.ErrorEntry,
geoPoints map[string]GeoPoint,
) []DayReport {
accessByDay := make(map[string][]parser.AccessEntry)
for _, e := range accessEntries {
key := e.Time.UTC().Format("2006-01-02")
accessByDay[key] = append(accessByDay[key], e)
}
errorByDay := make(map[string][]parser.ErrorEntry)
for _, e := range errorEntries {
key := e.Time.UTC().Format("2006-01-02")
errorByDay[key] = append(errorByDay[key], e)
}
// Union of all day keys
seen := make(map[string]struct{})
for k := range accessByDay {
seen[k] = struct{}{}
}
for k := range errorByDay {
seen[k] = struct{}{}
}
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys) // oldest first (YYYY-MM-DD sorts lexicographically)
days := make([]DayReport, 0, len(keys))
for _, key := range keys {
dayAccess := accessByDay[key]
dayErrors := errorByDay[key]
rd := ReportData{
GeneratedAt: time.Now(),
AccessEntries: dayAccess,
ErrorEntries: dayErrors,
}
ComputeStats(&rd)
// Filter geo locations to IPs seen on this day
if len(geoPoints) > 0 {
dayIPSet := make(map[string]struct{}, len(dayAccess))
for _, e := range dayAccess {
dayIPSet[e.RemoteAddr] = struct{}{}
}
var dayGeo []GeoPoint
for ip, pt := range geoPoints {
if _, ok := dayIPSet[ip]; ok {
dayGeo = append(dayGeo, pt)
}
}
rd.GeoLocations = dayGeo
}
t, _ := time.Parse("2006-01-02", key)
days = append(days, DayReport{
Date: t.UTC(),
Filename: filepath.Base(DeriveDailyFilename(outputPath, key)),
Data: rd,
})
}
return days
}
// GenerateIndexHTML renders the index page from IndexData.
func GenerateIndexHTML(idx IndexData) (string, error) {
funcMap := template.FuncMap{
"formatBytes": func(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
},
"formatFloat": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"errorClass": func(rate float64) string {
switch {
case rate >= 10:
return "err-high"
case rate >= 2:
return "err-med"
default:
return "err-low"
}
},
"fmtDate": func(t time.Time) string { return t.UTC().Format("2006-01-02") },
}
tmpl, err := template.New("index").Funcs(funcMap).Parse(indexTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, idx); err != nil {
return "", err
}
return buf.String(), nil
}
// ComputeStats fills the aggregate fields of data from AccessEntries and ErrorEntries.
func ComputeStats(data *ReportData) {
data.TotalRequests = len(data.AccessEntries)
ipMap := make(map[string]int)
pathMap := make(map[string]map[int]int) // path → {status → count}
hourMap := make(map[time.Time]int)
statusMap := make(map[int]int)
var totalBytes int64
errorCount := 0
for _, e := range data.AccessEntries {
totalBytes += e.BytesSent
ipMap[e.RemoteAddr]++
if pathMap[e.Path] == nil {
pathMap[e.Path] = make(map[int]int)
}
pathMap[e.Path][e.Status]++
statusMap[e.Status]++
if e.Status >= 500 {
errorCount++
}
hour := e.Time.Truncate(time.Hour)
hourMap[hour]++
}
data.TotalBytes = totalBytes
data.UniqueIPs = len(ipMap)
data.StatusCounts = statusMap
if data.TotalRequests > 0 {
data.ErrorRate = float64(errorCount) / float64(data.TotalRequests) * 100.0
}
// Top IPs (top 10)
ips := make([]IPCount, 0, len(ipMap))
for ip, cnt := range ipMap {
ips = append(ips, IPCount{IP: ip, Count: cnt})
}
sort.Slice(ips, func(i, j int) bool { return ips[i].Count > ips[j].Count })
if len(ips) > 10 {
ips = ips[:10]
}
data.TopIPs = ips
// Top Paths (top 10)
paths := make([]PathCount, 0, len(pathMap))
for path, statusCounts := range pathMap {
total := 0
topStatus, topCount := 0, 0
for status, count := range statusCounts {
total += count
// Pick the most frequent; break ties by lower status code
if count > topCount || (count == topCount && status < topStatus) {
topStatus = status
topCount = count
}
}
paths = append(paths, PathCount{
Path: path,
Count: total,
TopStatus: topStatus,
})
}
sort.Slice(paths, func(i, j int) bool { return paths[i].Count > paths[j].Count })
if len(paths) > 10 {
paths = paths[:10]
}
data.TopPaths = paths
// Time series
hours := make([]time.Time, 0, len(hourMap))
for h := range hourMap {
hours = append(hours, h)
}
sort.Slice(hours, func(i, j int) bool { return hours[i].Before(hours[j]) })
ts := make([]TimePoint, 0, len(hours))
for _, h := range hours {
ts = append(ts, TimePoint{Hour: h, Count: hourMap[h]})
}
data.TimeSeriesData = ts
}
// GenerateHTML renders the full HTML report from data.
func GenerateHTML(data ReportData) (string, error) {
funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"formatBytes": func(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
},
"formatFloat": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"statusBadge": func(status int) template.HTML {
cls := "s2xx"
switch {
case status >= 500:
cls = "s5xx"
case status >= 400:
cls = "s4xx"
case status >= 300:
cls = "s3xx"
}
return template.HTML(fmt.Sprintf(`<span class="status %s">%d</span>`, cls, status))
},
"levelBadge": func(level string) template.HTML {
cls := "l-" + level
return template.HTML(fmt.Sprintf(`<span class="level %s">%s</span>`, cls, level))
},
"statusJSON": func(m map[int]int) template.JS {
if m == nil {
return template.JS("{}")
}
b, _ := json.Marshal(m)
return template.JS(b)
},
"topPathsJSON": func(paths []PathCount) template.JS {
type item struct {
Path string `json:"path"`
Count int `json:"count"`
}
items := make([]item, len(paths))
for i, p := range paths {
items[i] = item{Path: p.Path, Count: p.Count}
}
b, _ := json.Marshal(items)
return template.JS(b)
},
"timeSeriesJSON": func(ts []TimePoint) template.JS {
type item struct {
Hour string `json:"hour"`
Count int `json:"count"`
}
items := make([]item, len(ts))
for i, t := range ts {
items[i] = item{Hour: t.Hour.Format("2006-01-02 15:04"), Count: t.Count}
}
b, _ := json.Marshal(items)
return template.JS(b)
},
"geoJSON": func(pts []GeoPoint) template.JS {
type item struct {
IP string `json:"ip"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Country string `json:"country"`
CC string `json:"cc"`
Count int `json:"count"`
}
items := make([]item, len(pts))
for i, p := range pts {
items[i] = item{IP: p.IP, Lat: p.Lat, Lon: p.Lon, Country: p.Country, CC: p.CountryCode, Count: p.Count}
}
b, _ := json.Marshal(items)
return template.JS(b)
},
"accessEntriesJSON": func(entries []parser.AccessEntry) template.JS {
type row struct {
Time string `json:"t"`
IP string `json:"ip"`
Method string `json:"m"`
Path string `json:"p"`
Status int `json:"s"`
Bytes int64 `json:"b"`
UA string `json:"ua"`
}
rows := make([]row, len(entries))
for i, e := range entries {
rows[i] = row{
Time: e.Time.Format("2006-01-02 15:04:05"),
IP: e.RemoteAddr,
Method: e.Method,
Path: e.Path,
Status: e.Status,
Bytes: e.BytesSent,
UA: e.UserAgent,
}
}
b, _ := json.Marshal(rows)
return template.JS(b)
},
"errorEntriesJSON": func(entries []parser.ErrorEntry) template.JS {
type row struct {
Time string `json:"t"`
Level string `json:"l"`
PID int `json:"pid"`
ConnID int `json:"cid"`
Msg string `json:"msg"`
}
rows := make([]row, len(entries))
for i, e := range entries {
rows[i] = row{
Time: e.Time.Format("2006-01-02 15:04:05"),
Level: e.Level,
PID: e.PID,
ConnID: e.ConnID,
Msg: e.Message,
}
}
b, _ := json.Marshal(rows)
return template.JS(b)
},
}
tmpl, err := template.New("report").Funcs(funcMap).Parse(reportTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}

View file

@ -0,0 +1,520 @@
package report
import (
"strings"
"testing"
"time"
"github.com/mr0xb/nxstats/internal/parser"
)
func makeTestData() ReportData {
t1 := time.Date(2024, 2, 20, 10, 0, 0, 0, time.UTC)
t2 := time.Date(2024, 2, 20, 11, 0, 0, 0, time.UTC)
t3 := time.Date(2024, 2, 20, 11, 30, 0, 0, time.UTC)
return ReportData{
GeneratedAt: time.Now(),
AccessEntries: []parser.AccessEntry{
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/", Status: 200, BytesSent: 1024, Time: t1, UserAgent: "TestAgent/1.0"},
{RemoteAddr: "2.2.2.2", Method: "POST", Path: "/api", Status: 201, BytesSent: 512, Time: t2, UserAgent: "curl/7.0"},
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/about", Status: 200, BytesSent: 2048, Time: t3, UserAgent: "TestAgent/1.0"},
{RemoteAddr: "3.3.3.3", Method: "GET", Path: "/", Status: 500, BytesSent: 128, Time: t3, UserAgent: "BadBot/1.0"},
},
ErrorEntries: []parser.ErrorEntry{
{Time: t1, Level: "error", PID: 100, TID: 200, Message: "connection refused"},
{Time: t2, Level: "warn", PID: 100, TID: 200, Message: "slow query"},
},
}
}
func TestGenerateHTML_ReturnsHTML(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(html), "<!DOCTYPE html>") {
t.Error("expected output to start with <!DOCTYPE html>")
}
}
func TestGenerateHTML_ContainsStatsCards(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
checks := []string{
"Total Requests",
"Unique IPs",
"Total Bytes",
"Error Rate",
}
for _, c := range checks {
if !strings.Contains(html, c) {
t.Errorf("expected %q in HTML output", c)
}
}
}
func TestGenerateHTML_ContainsSections(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
sections := []string{
"Access Log",
"Error Log",
"Top IPs",
"Top Paths",
"chart",
}
for _, s := range sections {
if !strings.Contains(html, s) {
t.Errorf("expected section %q in HTML output", s)
}
}
}
func TestGenerateHTML_ContainsIPData(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.Contains(html, "1.1.1.1") {
t.Error("expected top IP 1.1.1.1 in output")
}
}
func TestComputeStats_Counts(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if data.TotalRequests != 4 {
t.Errorf("TotalRequests = %d, want 4", data.TotalRequests)
}
if data.UniqueIPs != 3 {
t.Errorf("UniqueIPs = %d, want 3", data.UniqueIPs)
}
if data.TotalBytes != 3712 {
t.Errorf("TotalBytes = %d, want 3712", data.TotalBytes)
}
}
func TestComputeStats_ErrorRate(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
// 1 out of 4 requests is 5xx → 25%
if data.ErrorRate < 24.9 || data.ErrorRate > 25.1 {
t.Errorf("ErrorRate = %f, want ~25.0", data.ErrorRate)
}
}
func TestComputeStats_TopIPs(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if len(data.TopIPs) == 0 {
t.Fatal("expected TopIPs to be non-empty")
}
if data.TopIPs[0].IP != "1.1.1.1" {
t.Errorf("top IP = %q, want 1.1.1.1", data.TopIPs[0].IP)
}
if data.TopIPs[0].Count != 2 {
t.Errorf("top IP count = %d, want 2", data.TopIPs[0].Count)
}
}
func TestComputeStats_TopPaths(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if len(data.TopPaths) == 0 {
t.Fatal("expected TopPaths to be non-empty")
}
if data.TopPaths[0].Path != "/" {
t.Errorf("top path = %q, want /", data.TopPaths[0].Path)
}
}
func TestComputeStats_TopPathsTopStatus(t *testing.T) {
// "/" gets two requests: one 200 and one 500.
// TopStatus should be 200 (the most frequent), not an average (350).
data := makeTestData()
ComputeStats(&data)
var slashEntry *PathCount
for i := range data.TopPaths {
if data.TopPaths[i].Path == "/" {
slashEntry = &data.TopPaths[i]
break
}
}
if slashEntry == nil {
t.Fatal("expected / in TopPaths")
}
if slashEntry.TopStatus != 200 {
t.Errorf("TopStatus for / = %d, want 200 (most frequent, not an average)", slashEntry.TopStatus)
}
}
func TestComputeStats_StatusCounts(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if data.StatusCounts[200] != 2 {
t.Errorf("StatusCounts[200] = %d, want 2", data.StatusCounts[200])
}
if data.StatusCounts[500] != 1 {
t.Errorf("StatusCounts[500] = %d, want 1", data.StatusCounts[500])
}
}
func TestGenerateHTML_SearchInputs(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
// One search input per paginated table
if strings.Count(html, `type="text"`) < 2 {
t.Error("expected at least 2 search text inputs")
}
if !strings.Contains(html, "onkeyup") {
t.Error("expected onkeyup handler on search inputs")
}
}
func TestGenerateHTML_PaginationControls(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
// Prev/Next buttons for both tables
if strings.Count(html, "Prev") < 2 {
t.Error("expected Prev button for each paginated table")
}
if strings.Count(html, "Next") < 2 {
t.Error("expected Next button for each paginated table")
}
// Page-size selector
if strings.Count(html, `<select `) < 2 {
t.Error("expected page-size <select> for each paginated table")
}
// makePaginator JS factory must be present
if !strings.Contains(html, "makePaginator") {
t.Error("expected makePaginator JS function")
}
}
func TestGenerateHTML_AccessDataEmbeddedAsJSON(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
// The access tbody must be empty in the static HTML — rows are JS-rendered
if !strings.Contains(html, `<tbody id="accessTbody"></tbody>`) {
t.Error("accessTbody should be empty in static HTML (rows rendered by JS)")
}
// But the IP must still appear inside the embedded JSON data
if !strings.Contains(html, "1.1.1.1") {
t.Error("access entry IP must be present in embedded JSON")
}
}
func TestGenerateHTML_WithGeoLocations(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
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, "leaflet") {
t.Error("expected Leaflet.js reference when GeoLocations present")
}
// Flag helper must be present
if !strings.Contains(html, "countryFlag") {
t.Error("expected countryFlag JS function")
}
// Country code must be in the embedded geo JSON
if !strings.Contains(html, `"cc":"US"`) {
t.Error("expected CC field in geoJSON output")
}
// Map must appear before Top IPs (i.e. after Charts, not at the end)
mapIdx := strings.Index(html, `id="map"`)
topIPsIdx := strings.Index(html, "Top IPs")
if mapIdx == -1 || topIPsIdx == -1 {
t.Fatal("expected both map and Top IPs sections")
}
if mapIdx > topIPsIdx {
t.Error("map section should appear before Top IPs (i.e. after Charts)")
}
}
// ── multi-day fixture ──────────────────────────────────────────────────────
func makeMultiDayEntries() ([]parser.AccessEntry, []parser.ErrorEntry) {
day1 := time.Date(2024, 2, 20, 10, 0, 0, 0, time.UTC)
day2a := time.Date(2024, 2, 21, 14, 0, 0, 0, time.UTC)
day2b := time.Date(2024, 2, 21, 15, 0, 0, 0, time.UTC)
access := []parser.AccessEntry{
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/", Status: 200, BytesSent: 1024, Time: day1},
{RemoteAddr: "2.2.2.2", Method: "POST", Path: "/api", Status: 500, BytesSent: 256, Time: day2a},
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/", Status: 200, BytesSent: 512, Time: day2b},
}
errors := []parser.ErrorEntry{
{Time: day1, Level: "error", PID: 1, TID: 2, Message: "day1 error"},
{Time: day2a, Level: "warn", PID: 3, TID: 4, Message: "day2 warn"},
}
return access, errors
}
// ── DeriveDailyFilename ────────────────────────────────────────────────────
func TestDeriveDailyFilename(t *testing.T) {
cases := []struct{ output, date, want string }{
{"report.html", "2024-02-20", "report-2024-02-20.html"},
{"stats.html", "2024-03-01", "stats-2024-03-01.html"},
{"report", "2024-02-20", "report-2024-02-20"},
}
for _, c := range cases {
got := DeriveDailyFilename(c.output, c.date)
if got != c.want {
t.Errorf("DeriveDailyFilename(%q, %q) = %q, want %q", c.output, c.date, got, c.want)
}
}
}
// ── GroupByDay ─────────────────────────────────────────────────────────────
func TestGroupByDay_Partitioning(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if len(days) != 2 {
t.Fatalf("expected 2 days, got %d", len(days))
}
if days[0].Date.Format("2006-01-02") != "2024-02-20" {
t.Errorf("expected oldest day first, got %s", days[0].Date.Format("2006-01-02"))
}
if days[1].Date.Format("2006-01-02") != "2024-02-21" {
t.Errorf("expected newest day second, got %s", days[1].Date.Format("2006-01-02"))
}
}
func TestGroupByDay_RequestCounts(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if days[0].Data.TotalRequests != 1 {
t.Errorf("day1 requests = %d, want 1", days[0].Data.TotalRequests)
}
if days[1].Data.TotalRequests != 2 {
t.Errorf("day2 requests = %d, want 2", days[1].Data.TotalRequests)
}
}
func TestGroupByDay_ErrorPartitioning(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if len(days[0].Data.ErrorEntries) != 1 {
t.Errorf("day1 error entries = %d, want 1", len(days[0].Data.ErrorEntries))
}
if len(days[1].Data.ErrorEntries) != 1 {
t.Errorf("day2 error entries = %d, want 1", len(days[1].Data.ErrorEntries))
}
}
func TestGroupByDay_Filenames(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if days[0].Filename != "report-2024-02-20.html" {
t.Errorf("day1 filename = %q, want report-2024-02-20.html", days[0].Filename)
}
if days[1].Filename != "report-2024-02-21.html" {
t.Errorf("day2 filename = %q, want report-2024-02-21.html", days[1].Filename)
}
}
func TestGroupByDay_GeoFiltering(t *testing.T) {
access, errors := makeMultiDayEntries()
geoPoints := map[string]GeoPoint{
"1.1.1.1": {IP: "1.1.1.1", Lat: 37.7, Lon: -97.8, Country: "United States", CountryCode: "US"},
"2.2.2.2": {IP: "2.2.2.2", Lat: 51.5, Lon: -0.1, Country: "United Kingdom", CountryCode: "GB"},
}
days := GroupByDay("report.html", access, errors, geoPoints)
// day1 only has 1.1.1.1
if len(days[0].Data.GeoLocations) != 1 {
t.Errorf("day1 geo = %d, want 1", len(days[0].Data.GeoLocations))
}
if days[0].Data.GeoLocations[0].IP != "1.1.1.1" {
t.Errorf("day1 geo IP = %q, want 1.1.1.1", days[0].Data.GeoLocations[0].IP)
}
// day2 has both IPs
if len(days[1].Data.GeoLocations) != 2 {
t.Errorf("day2 geo = %d, want 2", len(days[1].Data.GeoLocations))
}
}
func TestGroupByDay_EmptyInput(t *testing.T) {
days := GroupByDay("report.html", nil, nil, nil)
if len(days) != 0 {
t.Errorf("expected 0 days, got %d", len(days))
}
}
func TestGroupByDay_StatsComputed(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
// day2 has one 500 → error rate 50%
if days[1].Data.ErrorRate < 49.9 || days[1].Data.ErrorRate > 50.1 {
t.Errorf("day2 error rate = %.2f, want ~50.0", days[1].Data.ErrorRate)
}
}
// ── GenerateIndexHTML ──────────────────────────────────────────────────────
func makeTestIndexData() IndexData {
return IndexData{
GeneratedAt: time.Date(2024, 2, 22, 12, 0, 0, 0, time.UTC),
TotalRequests: 300,
TotalUniqueIPs: 42,
TotalBytes: 1 << 20,
OverallErrorRate: 3.5,
Days: []DaySummary{
{
Date: time.Date(2024, 2, 21, 0, 0, 0, 0, time.UTC),
Filename: "report-2024-02-21.html",
Requests: 200, UniqueIPs: 30, TotalBytes: 700000, ErrorRate: 5.0,
},
{
Date: time.Date(2024, 2, 20, 0, 0, 0, 0, time.UTC),
Filename: "report-2024-02-20.html",
Requests: 100, UniqueIPs: 12, TotalBytes: 348000, ErrorRate: 1.0,
},
},
}
}
func TestGenerateIndexHTML_ReturnsHTML(t *testing.T) {
idx := makeTestIndexData()
html, err := GenerateIndexHTML(idx)
if err != nil {
t.Fatalf("GenerateIndexHTML error: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(html), "<!DOCTYPE html>") {
t.Error("expected output to start with <!DOCTYPE html>")
}
}
func TestGenerateIndexHTML_ContainsAggregateCards(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
for _, want := range []string{"Total Requests", "Unique IPs", "Total Bytes", "Overall Error Rate", "Days Covered"} {
if !strings.Contains(html, want) {
t.Errorf("missing card label %q", want)
}
}
}
func TestGenerateIndexHTML_ContainsDayLinks(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
if !strings.Contains(html, `href="report-2024-02-21.html"`) {
t.Error("expected link to report-2024-02-21.html")
}
if !strings.Contains(html, "2024-02-20") {
t.Error("expected date 2024-02-20 in table")
}
}
func TestGenerateIndexHTML_ErrorRateColouring(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
if !strings.Contains(html, "err-med") {
t.Error("expected err-med class for 5% error rate")
}
if !strings.Contains(html, "err-low") {
t.Error("expected err-low class for 1% error rate")
}
}
func TestGenerateIndexHTML_DaysNewestFirst(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
i21 := strings.Index(html, "2024-02-21")
i20 := strings.Index(html, "2024-02-20")
if i21 == -1 || i20 == -1 {
t.Fatal("expected both dates in index HTML")
}
if i21 > i20 {
t.Error("newest day (2024-02-21) should appear before older day in table")
}
}
// ── Back link in daily reports ─────────────────────────────────────────────
func TestGenerateHTML_BackLink(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
data.IndexFile = "report.html"
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.Contains(html, `href="report.html"`) {
t.Error("expected back link href when IndexFile is set")
}
if !strings.Contains(html, "Back to Index") {
t.Error("expected 'Back to Index' text in nav")
}
}
func TestGenerateHTML_NoBackLinkByDefault(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
// IndexFile is zero value ""
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if strings.Contains(html, "Back to Index") {
t.Error("expected no back link when IndexFile is empty")
}
}
func TestGenerateHTML_DayTitleInHeader(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
data.DayTitle = "2024-02-20"
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.Contains(html, "2024-02-20") {
t.Error("expected DayTitle in report HTML")
}
}

747
internal/report/template.go Normal file
View file

@ -0,0 +1,747 @@
package report
// cyberpunkCSS is the shared stylesheet used by both the daily report and
// the index page. It does not include map-specific rules or page-specific
// overrides (those live in reportExtraCSS / each template's own <style>).
const cyberpunkCSS = `
: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>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nxstats Nginx Report{{if .DayTitle}} · {{.DayTitle}}{{end}}</title>
{{if .GeoLocations}}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{{end}}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>` + cyberpunkCSS + reportExtraCSS + `</style>
</head>
<body>
<header>
<h1>// nxstats //</h1>
<div class="subtitle">nginx log analysis report{{if .DayTitle}} &mdash; {{.DayTitle}}{{end}} &mdash; generated {{.GeneratedAt.Format "2006-01-02 15:04:05 UTC"}}</div>
</header>
{{if .IndexFile}}
<nav class="nav-back">
<a href="{{.IndexFile}}">&larr; Back to Index</a>
</nav>
{{end}}
<div class="container">
<!-- Stats Cards -->
<div class="cards">
<div class="card">
<div class="label">Total Requests</div>
<div class="value">{{.TotalRequests}}</div>
</div>
<div class="card">
<div class="label">Unique IPs</div>
<div class="value">{{.UniqueIPs}}</div>
</div>
<div class="card">
<div class="label">Total Bytes</div>
<div class="value">{{formatBytes .TotalBytes}}</div>
</div>
<div class="card">
<div class="label">Error Rate</div>
<div class="value">{{formatFloat .ErrorRate}}%</div>
</div>
</div>
<!-- Charts Row -->
<div class="section">
<h2>// Charts</h2>
<div class="charts-row">
<div class="chart-card">
<h3>Status Code Distribution</h3>
<canvas id="chartStatus"></canvas>
</div>
<div class="chart-card">
<h3>Top 10 Paths</h3>
<canvas id="chartPaths"></canvas>
</div>
<div class="chart-card">
<h3>Requests per Hour</h3>
<canvas id="chartTimeline"></canvas>
</div>
</div>
</div>
{{if .GeoLocations}}
<!-- GeoIP Map -->
<div class="section">
<h2>// Geographic Distribution</h2>
<div id="map"></div>
</div>
{{end}}
<!-- Top IPs -->
<div class="section">
<h2>// Top IPs</h2>
<div class="table-wrap">
<table id="tableTopIPs">
<thead><tr>
<th>#</th><th>IP Address</th><th>Requests</th>
</tr></thead>
<tbody>
{{range $i, $ip := .TopIPs}}
<tr>
<td class="rank">{{add $i 1}}</td>
<td>{{$ip.IP}}</td>
<td>{{$ip.Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Top Paths -->
<div class="section">
<h2>// Top Paths</h2>
<div class="table-wrap">
<table id="tableTopPaths">
<thead><tr>
<th>#</th><th>Path</th><th>Requests</th><th>Top Status</th>
</tr></thead>
<tbody>
{{range $i, $p := .TopPaths}}
<tr>
<td class="rank">{{add $i 1}}</td>
<td>{{$p.Path}}</td>
<td>{{$p.Count}}</td>
<td>{{statusBadge $p.TopStatus}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Access Log -->
<div class="section">
<h2>// Access Log</h2>
<div class="table-controls">
<div class="search-bar">
<label for="searchAccess">Filter:</label>
<input type="text" id="searchAccess" placeholder="Search access log..." onkeyup="accessSearch(this.value)">
</div>
<div class="page-size-select">
<label for="accessPageSize">Rows:</label>
<select id="accessPageSize" onchange="accessSetPageSize(+this.value)">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</div>
<span id="accessPageInfo" class="page-info"></span>
<div class="page-btns">
<button id="accessPrev" onclick="accessChangePage(-1)" disabled>&#8592; Prev</button>
<button id="accessNext" onclick="accessChangePage(1)">Next &#8594;</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Time</th><th>IP</th><th>Method</th><th>Path</th><th>Status</th><th>Bytes</th><th>User Agent</th>
</tr></thead>
<tbody id="accessTbody"></tbody>
</table>
</div>
</div>
<!-- Error Log -->
<div class="section">
<h2>// Error Log</h2>
<div class="table-controls">
<div class="search-bar">
<label for="searchError">Filter:</label>
<input type="text" id="searchError" placeholder="Search error log..." onkeyup="errorSearch(this.value)">
</div>
<div class="page-size-select">
<label for="errorPageSize">Rows:</label>
<select id="errorPageSize" onchange="errorSetPageSize(+this.value)">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</div>
<span id="errorPageInfo" class="page-info"></span>
<div class="page-btns">
<button id="errorPrev" onclick="errorChangePage(-1)" disabled>&#8592; Prev</button>
<button id="errorNext" onclick="errorChangePage(1)">Next &#8594;</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Time</th><th>Level</th><th>PID</th><th>Conn</th><th>Message</th>
</tr></thead>
<tbody id="errorTbody"></tbody>
</table>
</div>
</div>
</div><!-- /container -->
<footer>nxstats &mdash; nginx log parser &amp; report generator</footer>
<script>
// ── Chart.js defaults ──────────────────────────────────────────────────────
Chart.defaults.color = '#c0c0e0';
Chart.defaults.borderColor = '#1a1a3a';
Chart.defaults.font.family = "'Courier New', monospace";
const neonGreen = '#00ff9f';
const neonCyan = '#00e5ff';
const neonMag = '#ff00c8';
// ── Status Codes Chart ─────────────────────────────────────────────────────
(function(){
const rawStatuses = {{statusJSON .StatusCounts}};
if (!rawStatuses || Object.keys(rawStatuses).length === 0) return;
const labels = Object.keys(rawStatuses).sort();
const values = labels.map(k => rawStatuses[k]);
const colors = labels.map(k => {
const n = parseInt(k);
if (n < 300) return neonGreen;
if (n < 400) return neonCyan;
if (n < 500) return '#ffcc00';
return '#ff4444';
});
new Chart(document.getElementById('chartStatus'), {
type: 'bar',
data: { labels, datasets: [{ data: values, backgroundColor: colors, borderWidth: 0 }] },
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
})();
// ── Top Paths Chart ────────────────────────────────────────────────────────
(function(){
const paths = {{topPathsJSON .TopPaths}};
if (!paths || paths.length === 0) return;
const labels = paths.map(p => p.path);
const values = paths.map(p => p.count);
new Chart(document.getElementById('chartPaths'), {
type: 'bar',
data: { labels, datasets: [{ data: values, backgroundColor: neonMag, borderWidth: 0 }] },
options: {
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true } }
}
});
})();
// ── Timeline Chart ─────────────────────────────────────────────────────────
(function(){
const ts = {{timeSeriesJSON .TimeSeriesData}};
if (!ts || ts.length === 0) return;
const labels = ts.map(p => p.hour);
const values = ts.map(p => p.count);
new Chart(document.getElementById('chartTimeline'), {
type: 'line',
data: { labels, datasets: [{
data: values,
borderColor: neonCyan,
backgroundColor: 'rgba(0,229,255,0.08)',
tension: 0.3,
fill: true,
pointRadius: 3
}]},
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
})();
// ── HTML escape helper ─────────────────────────────────────────────────────
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Status / level badge helpers ───────────────────────────────────────────
function statusBadge(s) {
const cls = s >= 500 ? 's5xx' : s >= 400 ? 's4xx' : s >= 300 ? 's3xx' : 's2xx';
return '<span class="status ' + cls + '">' + s + '</span>';
}
function levelBadge(l) {
return '<span class="level l-' + esc(l) + '">' + esc(l) + '</span>';
}
// ── Generic paginator factory ──────────────────────────────────────────────
function makePaginator(cfg) {
let filtered = cfg.data;
let page = 0;
let pageSize = 100;
function render() {
const start = page * pageSize;
const slice = filtered.slice(start, start + pageSize);
document.getElementById(cfg.tbodyId).innerHTML = slice.map(cfg.rowFn).join('');
const total = filtered.length;
const end = Math.min(start + pageSize, total);
document.getElementById(cfg.infoId).textContent =
total === 0 ? 'No results' : (start + 1) + '' + end + ' of ' + total;
document.getElementById(cfg.prevId).disabled = page === 0;
document.getElementById(cfg.nextId).disabled = end >= total;
}
render();
return {
search: function(q) {
q = q.toLowerCase();
filtered = q
? cfg.data.filter(function(r) {
return Object.values(r).some(function(v) {
return String(v).toLowerCase().indexOf(q) !== -1;
});
})
: cfg.data;
page = 0;
render();
},
changePage: function(delta) {
const maxPage = Math.ceil(filtered.length / pageSize) - 1;
page = Math.max(0, Math.min(page + delta, maxPage));
render();
},
setPageSize: function(n) {
pageSize = n;
page = 0;
render();
}
};
}
// ── Access log paginator ───────────────────────────────────────────────────
const accessPager = makePaginator({
data: {{accessEntriesJSON .AccessEntries}},
tbodyId: 'accessTbody',
infoId: 'accessPageInfo',
prevId: 'accessPrev',
nextId: 'accessNext',
rowFn: function(r) {
return '<tr>' +
'<td>' + esc(r.t) + '</td>' +
'<td>' + esc(r.ip) + '</td>' +
'<td>' + esc(r.m) + '</td>' +
'<td>' + esc(r.p) + '</td>' +
'<td>' + statusBadge(r.s) + '</td>' +
'<td>' + r.b + '</td>' +
'<td>' + esc(r.ua) + '</td>' +
'</tr>';
}
});
function accessSearch(q) { accessPager.search(q); }
function accessChangePage(d) { accessPager.changePage(d); }
function accessSetPageSize(n) { accessPager.setPageSize(n); }
// ── Error log paginator ────────────────────────────────────────────────────
const errorPager = makePaginator({
data: {{errorEntriesJSON .ErrorEntries}},
tbodyId: 'errorTbody',
infoId: 'errorPageInfo',
prevId: 'errorPrev',
nextId: 'errorNext',
rowFn: function(r) {
return '<tr>' +
'<td>' + esc(r.t) + '</td>' +
'<td>' + levelBadge(r.l) + '</td>' +
'<td>' + r.pid + '</td>' +
'<td>' + (r.cid || '-') + '</td>' +
'<td>' + esc(r.msg) + '</td>' +
'</tr>';
}
});
function errorSearch(q) { errorPager.search(q); }
function errorChangePage(d) { errorPager.changePage(d); }
function errorSetPageSize(n) { errorPager.setPageSize(n); }
{{if .GeoLocations}}
// ── Leaflet Map ────────────────────────────────────────────────────────────
(function(){
function countryFlag(cc) {
if (!cc || cc.length !== 2) return '';
return Array.from(cc.toUpperCase()).map(function(c) {
return String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65);
}).join('');
}
const map = L.map('map').setView([20, 0], 2);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 18
}).addTo(map);
const geoData = {{geoJSON .GeoLocations}};
geoData.forEach(function(pt) {
const flag = countryFlag(pt.cc);
const label = flag ? flag + '\u00a0' + pt.cc : (pt.cc || '??');
const marker = L.circleMarker([pt.lat, pt.lon], {
radius: Math.min(4 + Math.log(pt.count + 1) * 3, 20),
fillColor: '#ff00c8',
color: '#ff00c8',
weight: 1,
opacity: 0.9,
fillOpacity: 0.5
});
marker.bindTooltip(label, {
permanent: true,
direction: 'top',
offset: [0, -4],
className: 'map-label'
});
marker.bindPopup(
'<b>' + esc(pt.ip) + '</b><br>' +
flag + ' ' + esc(pt.country) + '<br>' +
pt.count + ' request' + (pt.count === 1 ? '' : 's')
);
marker.addTo(map);
});
})();
{{end}}
</script>
</body>
</html>
`
// indexTemplate is the cyberpunk-themed HTML template for the index page
// generated by --split-by-day. It lists all daily reports with aggregate stats.
const indexTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nxstats Daily Index</title>
<style>` + cyberpunkCSS + `
/* 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>
<body>
<header>
<h1>// nxstats //</h1>
<div class="subtitle">daily index &mdash; generated {{.GeneratedAt.Format "2006-01-02 15:04:05 UTC"}}</div>
</header>
<div class="container">
<!-- Aggregate Stats Cards -->
<div class="cards">
<div class="card">
<div class="label">Total Requests</div>
<div class="value">{{.TotalRequests}}</div>
</div>
<div class="card">
<div class="label">Unique IPs</div>
<div class="value">{{.TotalUniqueIPs}}</div>
</div>
<div class="card">
<div class="label">Total Bytes</div>
<div class="value">{{formatBytes .TotalBytes}}</div>
</div>
<div class="card">
<div class="label">Overall Error Rate</div>
<div class="value {{errorClass .OverallErrorRate}}">{{formatFloat .OverallErrorRate}}%</div>
</div>
<div class="card">
<div class="label">Days Covered</div>
<div class="value">{{len .Days}}</div>
</div>
</div>
<!-- Daily Breakdown Table -->
<div class="section">
<h2>// Daily Breakdown</h2>
<div class="table-wrap">
<table>
<thead><tr>
<th>Date</th>
<th>Requests</th>
<th>Unique IPs</th>
<th>Bytes</th>
<th>Error Rate</th>
</tr></thead>
<tbody>
{{range .Days}}
<tr>
<td><a class="day-link" href="{{.Filename}}">{{fmtDate .Date}}</a></td>
<td>{{.Requests}}</td>
<td>{{.UniqueIPs}}</td>
<td>{{formatBytes .TotalBytes}}</td>
<td class="{{errorClass .ErrorRate}}">{{formatFloat .ErrorRate}}%</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div><!-- /container -->
<footer>nxstats &mdash; nginx log parser &amp; report generator</footer>
</body>
</html>
`