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