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:
2026-02-16 20:27:15 +01:00
parent 8a631af0d2
commit 7c90c9ed4a
13 changed files with 1480 additions and 41 deletions

View File

@@ -395,6 +395,135 @@ func TestDashboardExecCommands(t *testing.T) {
}
}
func TestAPIAttemptsOverTime(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/charts/attempts-over-time", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var resp apiAttemptsOverTimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
// Seeded data inserted today -> at least 1 point.
if len(resp.Points) == 0 {
t.Error("expected at least one data point")
}
}
func TestAPIHourlyPattern(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/charts/hourly-pattern", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp apiHourlyPatternResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.Hours) == 0 {
t.Error("expected at least one hourly data point")
}
}
func TestAPICountryStats(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", "CN"); err != nil {
t.Fatalf("seeding: %v", err)
}
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2", "RU"); err != nil {
t.Fatalf("seeding: %v", err)
}
srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/charts/country-stats", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp apiCountryStatsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.Countries) != 2 {
t.Fatalf("len = %d, want 2", len(resp.Countries))
}
}
func TestFragmentDashboardContent(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/fragments/dashboard-content", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "<!DOCTYPE html>") {
t.Error("dashboard content fragment should not contain full HTML document")
}
if !strings.Contains(body, "Top Usernames") {
t.Error("dashboard content fragment should contain 'Top Usernames'")
}
}
func TestFragmentDashboardContentWithFilter(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
for range 5 {
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", "CN"); err != nil {
t.Fatalf("seeding: %v", err)
}
}
for range 3 {
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2", "RU"); err != nil {
t.Fatalf("seeding: %v", err)
}
}
srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/fragments/dashboard-content?country=CN", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// When filtered by CN, should show root but not admin.
if !strings.Contains(body, "root") {
t.Error("response should contain 'root' when filtered by CN")
}
}
func TestStaticAssets(t *testing.T) {
srv := newTestServer(t)
@@ -404,6 +533,9 @@ func TestStaticAssets(t *testing.T) {
}{
{"/static/pico.min.css", "text/css"},
{"/static/htmx.min.js", "text/javascript"},
{"/static/chart.min.js", "text/javascript"},
{"/static/dashboard.js", "text/javascript"},
{"/static/world.svg", "image/svg+xml"},
}
for _, tt := range tests {