feat: add charts, world map, and filters to web dashboard
Add Chart.js line/bar charts for attack trends (attempts over time, hourly pattern), an SVG world map choropleth colored by attack origin country, and a collapsible filter form (date range, IP, country, username) that narrows both charts and top-N tables. New store methods: GetAttemptsOverTime, GetHourlyPattern, GetCountryStats, and filtered variants of dashboard stats/top-N queries. New JSON API endpoints at /api/charts/* and an htmx fragment at /fragments/dashboard-content for filtered table updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -424,6 +424,226 @@ func TestSetExecCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func seedChartData(t *testing.T, store Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// Record attempts with country data from different IPs.
|
||||
for range 5 {
|
||||
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", "CN"); err != nil {
|
||||
t.Fatalf("seeding attempt: %v", err)
|
||||
}
|
||||
}
|
||||
for range 3 {
|
||||
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2", "RU"); err != nil {
|
||||
t.Fatalf("seeding attempt: %v", err)
|
||||
}
|
||||
}
|
||||
for range 2 {
|
||||
if err := store.RecordLoginAttempt(ctx, "root", "123456", "10.0.0.3", "CN"); err != nil {
|
||||
t.Fatalf("seeding attempt: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAttemptsOverTime(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
points, err := store.GetAttemptsOverTime(context.Background(), 30, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAttemptsOverTime: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Errorf("expected empty, got %v", points)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with data", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
points, err := store.GetAttemptsOverTime(context.Background(), 30, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAttemptsOverTime: %v", err)
|
||||
}
|
||||
// All data was inserted today, so should be one point.
|
||||
if len(points) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(points))
|
||||
}
|
||||
// 5 + 3 + 2 = 10 total.
|
||||
if points[0].Count != 10 {
|
||||
t.Errorf("count = %d, want 10", points[0].Count)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHourlyPattern(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
counts, err := store.GetHourlyPattern(context.Background(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHourlyPattern: %v", err)
|
||||
}
|
||||
if len(counts) != 0 {
|
||||
t.Errorf("expected empty, got %v", counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with data", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
counts, err := store.GetHourlyPattern(context.Background(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHourlyPattern: %v", err)
|
||||
}
|
||||
// All data was inserted at the same hour.
|
||||
if len(counts) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(counts))
|
||||
}
|
||||
if counts[0].Count != 10 {
|
||||
t.Errorf("count = %d, want 10", counts[0].Count)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetCountryStats(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
counts, err := store.GetCountryStats(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetCountryStats: %v", err)
|
||||
}
|
||||
if len(counts) != 0 {
|
||||
t.Errorf("expected empty, got %v", counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with data", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
counts, err := store.GetCountryStats(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetCountryStats: %v", err)
|
||||
}
|
||||
if len(counts) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(counts))
|
||||
}
|
||||
// CN: 5 + 2 = 7, RU: 3 - ordered by count DESC.
|
||||
if counts[0].Country != "CN" || counts[0].Count != 7 {
|
||||
t.Errorf("counts[0] = %+v, want CN/7", counts[0])
|
||||
}
|
||||
if counts[1].Country != "RU" || counts[1].Count != 3 {
|
||||
t.Errorf("counts[1] = %+v, want RU/3", counts[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("excludes empty country", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.RecordLoginAttempt(ctx, "test", "test", "10.0.0.1", ""); err != nil {
|
||||
t.Fatalf("seeding: %v", err)
|
||||
}
|
||||
if err := store.RecordLoginAttempt(ctx, "test", "test", "10.0.0.2", "US"); err != nil {
|
||||
t.Fatalf("seeding: %v", err)
|
||||
}
|
||||
|
||||
counts, err := store.GetCountryStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCountryStats: %v", err)
|
||||
}
|
||||
if len(counts) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(counts))
|
||||
}
|
||||
if counts[0].Country != "US" {
|
||||
t.Errorf("country = %q, want US", counts[0].Country)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFilteredDashboardStats(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("no filter", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
stats, err := store.GetFilteredDashboardStats(context.Background(), DashboardFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilteredDashboardStats: %v", err)
|
||||
}
|
||||
if stats.TotalAttempts != 10 {
|
||||
t.Errorf("TotalAttempts = %d, want 10", stats.TotalAttempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter by country", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
stats, err := store.GetFilteredDashboardStats(context.Background(), DashboardFilter{Country: "CN"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilteredDashboardStats: %v", err)
|
||||
}
|
||||
// CN: 5 + 2 = 7
|
||||
if stats.TotalAttempts != 7 {
|
||||
t.Errorf("TotalAttempts = %d, want 7", stats.TotalAttempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter by IP", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
stats, err := store.GetFilteredDashboardStats(context.Background(), DashboardFilter{IP: "10.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilteredDashboardStats: %v", err)
|
||||
}
|
||||
if stats.TotalAttempts != 5 {
|
||||
t.Errorf("TotalAttempts = %d, want 5", stats.TotalAttempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter by username", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
stats, err := store.GetFilteredDashboardStats(context.Background(), DashboardFilter{Username: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilteredDashboardStats: %v", err)
|
||||
}
|
||||
if stats.TotalAttempts != 3 {
|
||||
t.Errorf("TotalAttempts = %d, want 3", stats.TotalAttempts)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFilteredTopUsernames(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
store := newStore(t)
|
||||
seedChartData(t, store)
|
||||
|
||||
// Filter by country CN should only show root.
|
||||
entries, err := store.GetFilteredTopUsernames(context.Background(), 10, DashboardFilter{Country: "CN"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilteredTopUsernames: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(entries))
|
||||
}
|
||||
if entries[0].Value != "root" || entries[0].Count != 7 {
|
||||
t.Errorf("entries[0] = %+v, want root/7", entries[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRecentSessions(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user