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

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -454,6 +455,265 @@ func (s *SQLiteStore) CloseActiveSessions(ctx context.Context, disconnectedAt ti
return res.RowsAffected()
}
func (s *SQLiteStore) GetAttemptsOverTime(ctx context.Context, days int, since, until *time.Time) ([]TimeSeriesPoint, error) {
query := `SELECT DATE(last_seen) AS d, SUM(count) FROM login_attempts WHERE 1=1`
var args []any
if since != nil {
query += ` AND last_seen >= ?`
args = append(args, since.UTC().Format(time.RFC3339))
} else {
query += ` AND last_seen >= ?`
args = append(args, time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02"))
}
if until != nil {
query += ` AND last_seen <= ?`
args = append(args, until.UTC().Format(time.RFC3339))
}
query += ` GROUP BY d ORDER BY d`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying attempts over time: %w", err)
}
defer func() { _ = rows.Close() }()
var points []TimeSeriesPoint
for rows.Next() {
var dateStr string
var p TimeSeriesPoint
if err := rows.Scan(&dateStr, &p.Count); err != nil {
return nil, fmt.Errorf("scanning time series point: %w", err)
}
p.Timestamp, _ = time.Parse("2006-01-02", dateStr)
points = append(points, p)
}
return points, rows.Err()
}
func (s *SQLiteStore) GetHourlyPattern(ctx context.Context, since, until *time.Time) ([]HourlyCount, error) {
query := `SELECT CAST(STRFTIME('%H', last_seen) AS INTEGER) AS h, SUM(count) FROM login_attempts WHERE 1=1`
var args []any
if since != nil {
query += ` AND last_seen >= ?`
args = append(args, since.UTC().Format(time.RFC3339))
}
if until != nil {
query += ` AND last_seen <= ?`
args = append(args, until.UTC().Format(time.RFC3339))
}
query += ` GROUP BY h ORDER BY h`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying hourly pattern: %w", err)
}
defer func() { _ = rows.Close() }()
var counts []HourlyCount
for rows.Next() {
var c HourlyCount
if err := rows.Scan(&c.Hour, &c.Count); err != nil {
return nil, fmt.Errorf("scanning hourly count: %w", err)
}
counts = append(counts, c)
}
return counts, rows.Err()
}
func (s *SQLiteStore) GetCountryStats(ctx context.Context) ([]CountryCount, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT country, SUM(count) AS total
FROM login_attempts
WHERE country != ''
GROUP BY country
ORDER BY total DESC`)
if err != nil {
return nil, fmt.Errorf("querying country stats: %w", err)
}
defer func() { _ = rows.Close() }()
var counts []CountryCount
for rows.Next() {
var c CountryCount
if err := rows.Scan(&c.Country, &c.Count); err != nil {
return nil, fmt.Errorf("scanning country count: %w", err)
}
counts = append(counts, c)
}
return counts, rows.Err()
}
// buildAttemptWhereClause builds a dynamic WHERE clause for login_attempts filtering.
func buildAttemptWhereClause(f DashboardFilter) (string, []any) {
var clauses []string
var args []any
if f.Since != nil {
clauses = append(clauses, "last_seen >= ?")
args = append(args, f.Since.UTC().Format(time.RFC3339))
}
if f.Until != nil {
clauses = append(clauses, "last_seen <= ?")
args = append(args, f.Until.UTC().Format(time.RFC3339))
}
if f.IP != "" {
clauses = append(clauses, "ip = ?")
args = append(args, f.IP)
}
if f.Country != "" {
clauses = append(clauses, "country = ?")
args = append(args, f.Country)
}
if f.Username != "" {
clauses = append(clauses, "username = ?")
args = append(args, f.Username)
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
func (s *SQLiteStore) GetFilteredDashboardStats(ctx context.Context, f DashboardFilter) (*DashboardStats, error) {
where, args := buildAttemptWhereClause(f)
stats := &DashboardStats{}
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(count), 0), COUNT(DISTINCT ip) FROM login_attempts`+where, args...).
Scan(&stats.TotalAttempts, &stats.UniqueIPs)
if err != nil {
return nil, fmt.Errorf("querying filtered attempt stats: %w", err)
}
// Sessions don't have username/password, so only filter by time, IP, country.
sessQuery := `SELECT COUNT(*) FROM sessions WHERE 1=1`
var sessArgs []any
if f.Since != nil {
sessQuery += ` AND connected_at >= ?`
sessArgs = append(sessArgs, f.Since.UTC().Format(time.RFC3339))
}
if f.Until != nil {
sessQuery += ` AND connected_at <= ?`
sessArgs = append(sessArgs, f.Until.UTC().Format(time.RFC3339))
}
if f.IP != "" {
sessQuery += ` AND ip = ?`
sessArgs = append(sessArgs, f.IP)
}
if f.Country != "" {
sessQuery += ` AND country = ?`
sessArgs = append(sessArgs, f.Country)
}
err = s.db.QueryRowContext(ctx, sessQuery, sessArgs...).Scan(&stats.TotalSessions)
if err != nil {
return nil, fmt.Errorf("querying filtered total sessions: %w", err)
}
err = s.db.QueryRowContext(ctx, sessQuery+` AND disconnected_at IS NULL`, sessArgs...).Scan(&stats.ActiveSessions)
if err != nil {
return nil, fmt.Errorf("querying filtered active sessions: %w", err)
}
return stats, nil
}
func (s *SQLiteStore) GetFilteredTopUsernames(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
return s.queryFilteredTopN(ctx, "username", limit, f)
}
func (s *SQLiteStore) GetFilteredTopPasswords(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
return s.queryFilteredTopN(ctx, "password", limit, f)
}
func (s *SQLiteStore) GetFilteredTopIPs(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
where, args := buildAttemptWhereClause(f)
args = append(args, limit)
//nolint:gosec // where clause built from trusted constants, not user input
query := `SELECT ip, country, SUM(count) AS total FROM login_attempts` + where + ` GROUP BY ip ORDER BY total DESC LIMIT ?`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying filtered top IPs: %w", err)
}
defer func() { _ = rows.Close() }()
var entries []TopEntry
for rows.Next() {
var e TopEntry
if err := rows.Scan(&e.Value, &e.Country, &e.Count); err != nil {
return nil, fmt.Errorf("scanning filtered top IPs: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func (s *SQLiteStore) GetFilteredTopCountries(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
where, args := buildAttemptWhereClause(f)
countryClause := "country != ''"
if where == "" {
where = " WHERE " + countryClause
} else {
where += " AND " + countryClause
}
args = append(args, limit)
//nolint:gosec // where clause built from trusted constants, not user input
query := `SELECT country, SUM(count) AS total FROM login_attempts` + where + ` GROUP BY country ORDER BY total DESC LIMIT ?`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying filtered top countries: %w", err)
}
defer func() { _ = rows.Close() }()
var entries []TopEntry
for rows.Next() {
var e TopEntry
if err := rows.Scan(&e.Value, &e.Count); err != nil {
return nil, fmt.Errorf("scanning filtered top countries: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func (s *SQLiteStore) queryFilteredTopN(ctx context.Context, column string, limit int, f DashboardFilter) ([]TopEntry, error) {
switch column {
case "username", "password":
// valid columns
default:
return nil, fmt.Errorf("invalid column: %s", column)
}
where, args := buildAttemptWhereClause(f)
args = append(args, limit)
query := fmt.Sprintf(`
SELECT %s, SUM(count) AS total
FROM login_attempts%s
GROUP BY %s
ORDER BY total DESC
LIMIT ?`, column, where, column)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying filtered top %s: %w", column, err)
}
defer func() { _ = rows.Close() }()
var entries []TopEntry
for rows.Next() {
var e TopEntry
if err := rows.Scan(&e.Value, &e.Count); err != nil {
return nil, fmt.Errorf("scanning filtered top %s: %w", column, err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func (s *SQLiteStore) Close() error {
return s.db.Close()
}