initial commit
This commit is contained in:
commit
885e7d6e02
22 changed files with 3001 additions and 0 deletions
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