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