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:
@@ -399,6 +399,258 @@ func (m *MemoryStore) CloseActiveSessions(_ context.Context, disconnectedAt time
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetAttemptsOverTime(_ context.Context, days int, since, until *time.Time) ([]TimeSeriesPoint, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var cutoff time.Time
|
||||
if since != nil {
|
||||
cutoff = *since
|
||||
} else {
|
||||
cutoff = time.Now().UTC().AddDate(0, 0, -days)
|
||||
}
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, a := range m.LoginAttempts {
|
||||
if a.LastSeen.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
if until != nil && a.LastSeen.After(*until) {
|
||||
continue
|
||||
}
|
||||
day := a.LastSeen.Format("2006-01-02")
|
||||
counts[day] += int64(a.Count)
|
||||
}
|
||||
|
||||
points := make([]TimeSeriesPoint, 0, len(counts))
|
||||
for day, count := range counts {
|
||||
t, _ := time.Parse("2006-01-02", day)
|
||||
points = append(points, TimeSeriesPoint{Timestamp: t, Count: count})
|
||||
}
|
||||
sort.Slice(points, func(i, j int) bool {
|
||||
return points[i].Timestamp.Before(points[j].Timestamp)
|
||||
})
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetHourlyPattern(_ context.Context, since, until *time.Time) ([]HourlyCount, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
hourCounts := make(map[int]int64)
|
||||
for _, a := range m.LoginAttempts {
|
||||
if since != nil && a.LastSeen.Before(*since) {
|
||||
continue
|
||||
}
|
||||
if until != nil && a.LastSeen.After(*until) {
|
||||
continue
|
||||
}
|
||||
hour := a.LastSeen.Hour()
|
||||
hourCounts[hour] += int64(a.Count)
|
||||
}
|
||||
|
||||
counts := make([]HourlyCount, 0, len(hourCounts))
|
||||
for h, c := range hourCounts {
|
||||
counts = append(counts, HourlyCount{Hour: h, Count: c})
|
||||
}
|
||||
sort.Slice(counts, func(i, j int) bool {
|
||||
return counts[i].Hour < counts[j].Hour
|
||||
})
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetCountryStats(_ context.Context) ([]CountryCount, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, a := range m.LoginAttempts {
|
||||
if a.Country == "" {
|
||||
continue
|
||||
}
|
||||
counts[a.Country] += int64(a.Count)
|
||||
}
|
||||
|
||||
result := make([]CountryCount, 0, len(counts))
|
||||
for country, count := range counts {
|
||||
result = append(result, CountryCount{Country: country, Count: count})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Count > result[j].Count
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// matchesFilter returns true if the login attempt matches the given filter. Must be called with m.mu held.
|
||||
func matchesFilter(a *LoginAttempt, f DashboardFilter) bool {
|
||||
if f.Since != nil && a.LastSeen.Before(*f.Since) {
|
||||
return false
|
||||
}
|
||||
if f.Until != nil && a.LastSeen.After(*f.Until) {
|
||||
return false
|
||||
}
|
||||
if f.IP != "" && a.IP != f.IP {
|
||||
return false
|
||||
}
|
||||
if f.Country != "" && a.Country != f.Country {
|
||||
return false
|
||||
}
|
||||
if f.Username != "" && a.Username != f.Username {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetFilteredDashboardStats(_ context.Context, f DashboardFilter) (*DashboardStats, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
stats := &DashboardStats{}
|
||||
ips := make(map[string]struct{})
|
||||
for i := range m.LoginAttempts {
|
||||
a := &m.LoginAttempts[i]
|
||||
if !matchesFilter(a, f) {
|
||||
continue
|
||||
}
|
||||
stats.TotalAttempts += int64(a.Count)
|
||||
ips[a.IP] = struct{}{}
|
||||
}
|
||||
stats.UniqueIPs = int64(len(ips))
|
||||
|
||||
for _, s := range m.Sessions {
|
||||
if f.Since != nil && s.ConnectedAt.Before(*f.Since) {
|
||||
continue
|
||||
}
|
||||
if f.Until != nil && s.ConnectedAt.After(*f.Until) {
|
||||
continue
|
||||
}
|
||||
if f.IP != "" && s.IP != f.IP {
|
||||
continue
|
||||
}
|
||||
if f.Country != "" && s.Country != f.Country {
|
||||
continue
|
||||
}
|
||||
stats.TotalSessions++
|
||||
if s.DisconnectedAt == nil {
|
||||
stats.ActiveSessions++
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetFilteredTopUsernames(_ context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.filteredTopN("username", limit, f), nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetFilteredTopPasswords(_ context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.filteredTopN("password", limit, f), nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetFilteredTopIPs(_ context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
type ipInfo struct {
|
||||
count int64
|
||||
country string
|
||||
}
|
||||
agg := make(map[string]*ipInfo)
|
||||
for i := range m.LoginAttempts {
|
||||
a := &m.LoginAttempts[i]
|
||||
if !matchesFilter(a, f) {
|
||||
continue
|
||||
}
|
||||
info, ok := agg[a.IP]
|
||||
if !ok {
|
||||
info = &ipInfo{}
|
||||
agg[a.IP] = info
|
||||
}
|
||||
info.count += int64(a.Count)
|
||||
if a.Country != "" {
|
||||
info.country = a.Country
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([]TopEntry, 0, len(agg))
|
||||
for ip, info := range agg {
|
||||
entries = append(entries, TopEntry{Value: ip, Country: info.country, Count: info.count})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Count > entries[j].Count
|
||||
})
|
||||
if limit > 0 && len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetFilteredTopCountries(_ context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for i := range m.LoginAttempts {
|
||||
a := &m.LoginAttempts[i]
|
||||
if a.Country == "" {
|
||||
continue
|
||||
}
|
||||
if !matchesFilter(a, f) {
|
||||
continue
|
||||
}
|
||||
counts[a.Country] += int64(a.Count)
|
||||
}
|
||||
|
||||
entries := make([]TopEntry, 0, len(counts))
|
||||
for k, v := range counts {
|
||||
entries = append(entries, TopEntry{Value: k, Count: v})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Count > entries[j].Count
|
||||
})
|
||||
if limit > 0 && len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// filteredTopN aggregates login attempts by the given field with filter applied and returns the top N. Must be called with m.mu held.
|
||||
func (m *MemoryStore) filteredTopN(field string, limit int, f DashboardFilter) []TopEntry {
|
||||
counts := make(map[string]int64)
|
||||
for i := range m.LoginAttempts {
|
||||
a := &m.LoginAttempts[i]
|
||||
if !matchesFilter(a, f) {
|
||||
continue
|
||||
}
|
||||
var key string
|
||||
switch field {
|
||||
case "username":
|
||||
key = a.Username
|
||||
case "password":
|
||||
key = a.Password
|
||||
case "ip":
|
||||
key = a.IP
|
||||
}
|
||||
counts[key] += int64(a.Count)
|
||||
}
|
||||
|
||||
entries := make([]TopEntry, 0, len(counts))
|
||||
for k, v := range counts {
|
||||
entries = append(entries, TopEntry{Value: k, Count: v})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Count > entries[j].Count
|
||||
})
|
||||
if limit > 0 && len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -56,6 +56,33 @@ type DashboardStats struct {
|
||||
ActiveSessions int64
|
||||
}
|
||||
|
||||
// TimeSeriesPoint represents a single data point in a time series.
|
||||
type TimeSeriesPoint struct {
|
||||
Timestamp time.Time
|
||||
Count int64
|
||||
}
|
||||
|
||||
// HourlyCount represents the total attempts for a given hour of day.
|
||||
type HourlyCount struct {
|
||||
Hour int // 0-23
|
||||
Count int64
|
||||
}
|
||||
|
||||
// CountryCount represents the total attempts from a given country.
|
||||
type CountryCount struct {
|
||||
Country string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// DashboardFilter contains optional filters for dashboard queries.
|
||||
type DashboardFilter struct {
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
IP string
|
||||
Country string
|
||||
Username string
|
||||
}
|
||||
|
||||
// TopEntry represents a value and its count for top-N queries.
|
||||
type TopEntry struct {
|
||||
Value string
|
||||
@@ -127,6 +154,30 @@ type Store interface {
|
||||
// sessions left over from a previous unclean shutdown.
|
||||
CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error)
|
||||
|
||||
// GetAttemptsOverTime returns daily attempt counts for the last N days.
|
||||
GetAttemptsOverTime(ctx context.Context, days int, since, until *time.Time) ([]TimeSeriesPoint, error)
|
||||
|
||||
// GetHourlyPattern returns total attempts grouped by hour of day (0-23).
|
||||
GetHourlyPattern(ctx context.Context, since, until *time.Time) ([]HourlyCount, error)
|
||||
|
||||
// GetCountryStats returns total attempts per country, ordered by count DESC.
|
||||
GetCountryStats(ctx context.Context) ([]CountryCount, error)
|
||||
|
||||
// GetFilteredDashboardStats returns aggregate counts with optional filters applied.
|
||||
GetFilteredDashboardStats(ctx context.Context, f DashboardFilter) (*DashboardStats, error)
|
||||
|
||||
// GetFilteredTopUsernames returns top usernames with optional filters applied.
|
||||
GetFilteredTopUsernames(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error)
|
||||
|
||||
// GetFilteredTopPasswords returns top passwords with optional filters applied.
|
||||
GetFilteredTopPasswords(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error)
|
||||
|
||||
// GetFilteredTopIPs returns top IPs with optional filters applied.
|
||||
GetFilteredTopIPs(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error)
|
||||
|
||||
// GetFilteredTopCountries returns top countries with optional filters applied.
|
||||
GetFilteredTopCountries(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error)
|
||||
|
||||
// Close releases any resources held by the store.
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -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