initial commit
This commit is contained in:
commit
885e7d6e02
22 changed files with 3001 additions and 0 deletions
68
internal/geo/geo.go
Normal file
68
internal/geo/geo.go
Normal 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
59
internal/geo/geo_test.go
Normal 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
70
internal/parser/access.go
Normal 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
|
||||
}
|
||||
110
internal/parser/access_test.go
Normal file
110
internal/parser/access_test.go
Normal 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
67
internal/parser/error.go
Normal 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
|
||||
}
|
||||
93
internal/parser/error_test.go
Normal file
93
internal/parser/error_test.go
Normal 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
167
internal/parser/scanner.go
Normal 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
|
||||
}
|
||||
155
internal/parser/scanner_test.go
Normal file
155
internal/parser/scanner_test.go
Normal 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
6
internal/parser/testdata/access.log
vendored
Normal 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
BIN
internal/parser/testdata/access.log.1.gz
vendored
Normal file
Binary file not shown.
6
internal/parser/testdata/error.log
vendored
Normal file
6
internal/parser/testdata/error.log
vendored
Normal 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
435
internal/report/report.go
Normal 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
|
||||
}
|
||||
520
internal/report/report_test.go
Normal file
520
internal/report/report_test.go
Normal 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
747
internal/report/template.go
Normal 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}} — {{.DayTitle}}{{end}} — generated {{.GeneratedAt.Format "2006-01-02 15:04:05 UTC"}}</div>
|
||||
</header>
|
||||
{{if .IndexFile}}
|
||||
<nav class="nav-back">
|
||||
<a href="{{.IndexFile}}">← 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>← Prev</button>
|
||||
<button id="accessNext" onclick="accessChangePage(1)">Next →</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>← Prev</button>
|
||||
<button id="errorNext" onclick="errorChangePage(1)">Next →</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 — nginx log parser & 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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: '© <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 — 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 — nginx log parser & report generator</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
Loading…
Reference in a new issue