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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user