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:
@@ -44,6 +44,7 @@ Key settings:
|
|||||||
- `shell.fake_user` — override username in prompt; empty uses the authenticated user
|
- `shell.fake_user` — override username in prompt; empty uses the authenticated user
|
||||||
- `web.enabled` — enable the web dashboard (default `false`)
|
- `web.enabled` — enable the web dashboard (default `false`)
|
||||||
- `web.listen_addr` — web dashboard listen address (default `:8080`)
|
- `web.listen_addr` — web dashboard listen address (default `:8080`)
|
||||||
|
- Dashboard includes Chart.js charts (attempts over time, hourly pattern), an SVG world map choropleth colored by attack origin, and filter controls for date range / IP / country / username
|
||||||
- Session detail pages at `/sessions/{id}` include terminal replay via xterm.js
|
- Session detail pages at `/sessions/{id}` include terminal replay via xterm.js
|
||||||
- `web.metrics_enabled` — expose Prometheus metrics at `/metrics` (default `true`)
|
- `web.metrics_enabled` — expose Prometheus metrics at `/metrics` (default `true`)
|
||||||
- `web.metrics_token` — bearer token to protect `/metrics`; empty means no auth (default empty)
|
- `web.metrics_token` — bearer token to protect `/metrics`; empty means no auth (default empty)
|
||||||
|
|||||||
@@ -399,6 +399,258 @@ func (m *MemoryStore) CloseActiveSessions(_ context.Context, disconnectedAt time
|
|||||||
return count, nil
|
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 {
|
func (m *MemoryStore) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -454,6 +455,265 @@ func (s *SQLiteStore) CloseActiveSessions(ctx context.Context, disconnectedAt ti
|
|||||||
return res.RowsAffected()
|
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 {
|
func (s *SQLiteStore) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,33 @@ type DashboardStats struct {
|
|||||||
ActiveSessions int64
|
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.
|
// TopEntry represents a value and its count for top-N queries.
|
||||||
type TopEntry struct {
|
type TopEntry struct {
|
||||||
Value string
|
Value string
|
||||||
@@ -127,6 +154,30 @@ type Store interface {
|
|||||||
// sessions left over from a previous unclean shutdown.
|
// sessions left over from a previous unclean shutdown.
|
||||||
CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error)
|
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 releases any resources held by the store.
|
||||||
Close() error
|
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) {
|
func TestGetRecentSessions(t *testing.T) {
|
||||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||||
t.Run("empty", func(t *testing.T) {
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -180,6 +182,186 @@ type apiEventsResponse struct {
|
|||||||
Events []apiEvent `json:"events"`
|
Events []apiEvent `json:"events"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseDateParam parses a "YYYY-MM-DD" query parameter into a *time.Time.
|
||||||
|
func parseDateParam(r *http.Request, name string) *time.Time {
|
||||||
|
v := r.URL.Query().Get(name)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// For "until" dates, set to end of day.
|
||||||
|
if name == "until" {
|
||||||
|
t = t.Add(24*time.Hour - time.Second)
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDashboardFilter(r *http.Request) storage.DashboardFilter {
|
||||||
|
return storage.DashboardFilter{
|
||||||
|
Since: parseDateParam(r, "since"),
|
||||||
|
Until: parseDateParam(r, "until"),
|
||||||
|
IP: r.URL.Query().Get("ip"),
|
||||||
|
Country: r.URL.Query().Get("country"),
|
||||||
|
Username: r.URL.Query().Get("username"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiTimeSeriesPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAttemptsOverTimeResponse struct {
|
||||||
|
Points []apiTimeSeriesPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAPIAttemptsOverTime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
days := 30
|
||||||
|
if v := r.URL.Query().Get("days"); v != "" {
|
||||||
|
if d, err := strconv.Atoi(v); err == nil && d > 0 && d <= 365 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
since := parseDateParam(r, "since")
|
||||||
|
until := parseDateParam(r, "until")
|
||||||
|
|
||||||
|
points, err := s.store.GetAttemptsOverTime(r.Context(), days, since, until)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get attempts over time", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := apiAttemptsOverTimeResponse{Points: make([]apiTimeSeriesPoint, len(points))}
|
||||||
|
for i, p := range points {
|
||||||
|
resp.Points[i] = apiTimeSeriesPoint{
|
||||||
|
Date: p.Timestamp.Format("2006-01-02"),
|
||||||
|
Count: p.Count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
s.logger.Error("failed to encode attempts over time", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiHourlyCount struct {
|
||||||
|
Hour int `json:"hour"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiHourlyPatternResponse struct {
|
||||||
|
Hours []apiHourlyCount `json:"hours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAPIHourlyPattern(w http.ResponseWriter, r *http.Request) {
|
||||||
|
since := parseDateParam(r, "since")
|
||||||
|
until := parseDateParam(r, "until")
|
||||||
|
|
||||||
|
counts, err := s.store.GetHourlyPattern(r.Context(), since, until)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get hourly pattern", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := apiHourlyPatternResponse{Hours: make([]apiHourlyCount, len(counts))}
|
||||||
|
for i, c := range counts {
|
||||||
|
resp.Hours[i] = apiHourlyCount{Hour: c.Hour, Count: c.Count}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
s.logger.Error("failed to encode hourly pattern", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiCountryCount struct {
|
||||||
|
Country string `json:"country"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiCountryStatsResponse struct {
|
||||||
|
Countries []apiCountryCount `json:"countries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAPICountryStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
counts, err := s.store.GetCountryStats(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get country stats", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := apiCountryStatsResponse{Countries: make([]apiCountryCount, len(counts))}
|
||||||
|
for i, c := range counts {
|
||||||
|
resp.Countries[i] = apiCountryCount{Country: c.Country, Count: c.Count}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
s.logger.Error("failed to encode country stats", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFragmentDashboardContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
f := parseDashboardFilter(r)
|
||||||
|
|
||||||
|
stats, err := s.store.GetFilteredDashboardStats(ctx, f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get filtered stats", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topUsernames, err := s.store.GetFilteredTopUsernames(ctx, 10, f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get filtered top usernames", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topPasswords, err := s.store.GetFilteredTopPasswords(ctx, 10, f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get filtered top passwords", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topIPs, err := s.store.GetFilteredTopIPs(ctx, 10, f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get filtered top IPs", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topCountries, err := s.store.GetFilteredTopCountries(ctx, 10, f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get filtered top countries", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := dashboardData{
|
||||||
|
Stats: stats,
|
||||||
|
TopUsernames: topUsernames,
|
||||||
|
TopPasswords: topPasswords,
|
||||||
|
TopIPs: topIPs,
|
||||||
|
TopCountries: topCountries,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := s.tmpl.dashboard.ExecuteTemplate(w, "dashboard_content", data); err != nil {
|
||||||
|
s.logger.Error("failed to render dashboard content fragment", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
sessionID := r.PathValue("id")
|
sessionID := r.PathValue("id")
|
||||||
|
|||||||
14
internal/web/static/chart.min.js
vendored
Normal file
14
internal/web/static/chart.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
271
internal/web/static/dashboard.js
Normal file
271
internal/web/static/dashboard.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Chart.js theme for Pico dark mode
|
||||||
|
Chart.defaults.color = '#b0b0b8';
|
||||||
|
Chart.defaults.borderColor = '#3a3a4a';
|
||||||
|
|
||||||
|
var attemptsChart = null;
|
||||||
|
var hourlyChart = null;
|
||||||
|
|
||||||
|
function getFilterParams() {
|
||||||
|
var form = document.getElementById('filter-form');
|
||||||
|
if (!form) return '';
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
var since = form.elements['since'].value;
|
||||||
|
var until = form.elements['until'].value;
|
||||||
|
if (since) params.set('since', since);
|
||||||
|
if (until) params.set('until', until);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAttemptsChart() {
|
||||||
|
var canvas = document.getElementById('chart-attempts');
|
||||||
|
if (!canvas) return;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
var qs = getFilterParams();
|
||||||
|
var url = '/api/charts/attempts-over-time' + (qs ? '?' + qs : '');
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var labels = data.points.map(function(p) { return p.date; });
|
||||||
|
var values = data.points.map(function(p) { return p.count; });
|
||||||
|
|
||||||
|
if (attemptsChart) {
|
||||||
|
attemptsChart.data.labels = labels;
|
||||||
|
attemptsChart.data.datasets[0].data = values;
|
||||||
|
attemptsChart.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptsChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Attempts',
|
||||||
|
data: values,
|
||||||
|
borderColor: '#6366f1',
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHourlyChart() {
|
||||||
|
var canvas = document.getElementById('chart-hourly');
|
||||||
|
if (!canvas) return;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
var qs = getFilterParams();
|
||||||
|
var url = '/api/charts/hourly-pattern' + (qs ? '?' + qs : '');
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
// Fill all 24 hours, defaulting to 0
|
||||||
|
var hourMap = {};
|
||||||
|
data.hours.forEach(function(h) { hourMap[h.hour] = h.count; });
|
||||||
|
var labels = [];
|
||||||
|
var values = [];
|
||||||
|
for (var i = 0; i < 24; i++) {
|
||||||
|
labels.push(i + ':00');
|
||||||
|
values.push(hourMap[i] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hourlyChart) {
|
||||||
|
hourlyChart.data.labels = labels;
|
||||||
|
hourlyChart.data.datasets[0].data = values;
|
||||||
|
hourlyChart.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hourlyChart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Attempts',
|
||||||
|
data: values,
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.6)',
|
||||||
|
borderColor: '#6366f1',
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWorldMap() {
|
||||||
|
var container = document.getElementById('world-map');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
fetch('/static/world.svg')
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(svgText) {
|
||||||
|
container.innerHTML = svgText;
|
||||||
|
|
||||||
|
fetch('/api/charts/country-stats')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
colorMap(container, data.countries);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorMap(container, countries) {
|
||||||
|
if (!countries || countries.length === 0) return;
|
||||||
|
|
||||||
|
var maxCount = countries[0].count; // already sorted DESC
|
||||||
|
var logMax = Math.log(maxCount + 1);
|
||||||
|
|
||||||
|
// Build lookup
|
||||||
|
var lookup = {};
|
||||||
|
countries.forEach(function(c) {
|
||||||
|
lookup[c.country.toLowerCase()] = c.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create tooltip element
|
||||||
|
var tooltip = document.createElement('div');
|
||||||
|
tooltip.id = 'map-tooltip';
|
||||||
|
tooltip.style.cssText = 'position:fixed;display:none;background:#1a1a2e;color:#e0e0e8;padding:4px 8px;border-radius:4px;font-size:13px;pointer-events:none;z-index:1000;border:1px solid #3a3a4a;';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
|
||||||
|
var svg = container.querySelector('svg');
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
var paths = svg.querySelectorAll('path[id]');
|
||||||
|
paths.forEach(function(path) {
|
||||||
|
var id = path.id.toLowerCase();
|
||||||
|
if (id.charAt(0) === '_') return; // skip non-country paths
|
||||||
|
|
||||||
|
var count = lookup[id];
|
||||||
|
if (count) {
|
||||||
|
var intensity = Math.log(count + 1) / logMax;
|
||||||
|
var r = Math.round(30 + intensity * 69); // 30 -> 99
|
||||||
|
var g = Math.round(30 + intensity * 72); // 30 -> 102
|
||||||
|
var b = Math.round(62 + intensity * 179); // 62 -> 241
|
||||||
|
path.style.fill = 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
path.addEventListener('mouseenter', function(e) {
|
||||||
|
var cc = id.toUpperCase();
|
||||||
|
var n = lookup[id] || 0;
|
||||||
|
tooltip.textContent = cc + ': ' + n.toLocaleString() + ' attempts';
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
path.addEventListener('mousemove', function(e) {
|
||||||
|
tooltip.style.left = (e.clientX + 12) + 'px';
|
||||||
|
tooltip.style.top = (e.clientY - 10) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
path.addEventListener('mouseleave', function() {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
path.addEventListener('click', function() {
|
||||||
|
var input = document.querySelector('#filter-form input[name="country"]');
|
||||||
|
if (input) {
|
||||||
|
input.value = id.toUpperCase();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
path.style.cursor = 'pointer';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
// Re-fetch charts with filter params
|
||||||
|
initAttemptsChart();
|
||||||
|
initHourlyChart();
|
||||||
|
|
||||||
|
// Re-fetch dashboard content via htmx
|
||||||
|
var form = document.getElementById('filter-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
['since', 'until', 'ip', 'country', 'username'].forEach(function(name) {
|
||||||
|
var val = form.elements[name].value;
|
||||||
|
if (val) params.set(name, val);
|
||||||
|
});
|
||||||
|
|
||||||
|
var target = document.getElementById('dashboard-content');
|
||||||
|
if (target) {
|
||||||
|
var url = '/fragments/dashboard-content?' + params.toString();
|
||||||
|
htmx.ajax('GET', url, {target: '#dashboard-content', swap: 'innerHTML'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side filter for recent sessions table
|
||||||
|
filterSessionsTable(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSessionsTable(form) {
|
||||||
|
var ip = form.elements['ip'].value.toLowerCase();
|
||||||
|
var country = form.elements['country'].value.toLowerCase();
|
||||||
|
var username = form.elements['username'].value.toLowerCase();
|
||||||
|
|
||||||
|
var rows = document.querySelectorAll('#recent-sessions-table tbody tr');
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
var cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length < 4) { row.style.display = ''; return; }
|
||||||
|
|
||||||
|
var show = true;
|
||||||
|
if (ip && cells[1].textContent.toLowerCase().indexOf(ip) === -1) show = false;
|
||||||
|
if (country && cells[2].textContent.toLowerCase().indexOf(country) === -1) show = false;
|
||||||
|
if (username && cells[3].textContent.toLowerCase().indexOf(username) === -1) show = false;
|
||||||
|
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearFilters = function() {
|
||||||
|
var form = document.getElementById('filter-form');
|
||||||
|
if (form) {
|
||||||
|
form.reset();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.applyFilters = applyFilters;
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initAttemptsChart();
|
||||||
|
initHourlyChart();
|
||||||
|
initWorldMap();
|
||||||
|
|
||||||
|
var form = document.getElementById('filter-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
1
internal/web/static/world.svg
Normal file
1
internal/web/static/world.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 55 KiB |
@@ -3,6 +3,94 @@
|
|||||||
{{template "stats" .Stats}}
|
{{template "stats" .Stats}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Filters</summary>
|
||||||
|
<form id="filter-form">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Since <input type="date" name="since"></label>
|
||||||
|
<label>Until <input type="date" name="until"></label>
|
||||||
|
<label>IP <input type="text" name="ip" placeholder="10.0.0.1"></label>
|
||||||
|
<label>Country <input type="text" name="country" placeholder="CN" maxlength="2"></label>
|
||||||
|
<label>Username <input type="text" name="username" placeholder="root"></label>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
<button type="button" class="secondary" onclick="clearFilters()">Clear</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Attack Trends</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<article>
|
||||||
|
<header>Attempts Over Time</header>
|
||||||
|
<canvas id="chart-attempts"></canvas>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header>Hourly Pattern (UTC)</header>
|
||||||
|
<canvas id="chart-hourly"></canvas>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Attack Origins</h3>
|
||||||
|
<article>
|
||||||
|
<div id="world-map"></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="dashboard-content">
|
||||||
|
{{template "dashboard_content" .}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Active Sessions</h3>
|
||||||
|
<div id="active-sessions" hx-get="/fragments/active-sessions" hx-trigger="every 10s" hx-swap="innerHTML">
|
||||||
|
{{template "active_sessions" .ActiveSessions}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Recent Sessions</h3>
|
||||||
|
<table id="recent-sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Score</th>
|
||||||
|
<th>Connected</th>
|
||||||
|
<th>Disconnected</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .RecentSessions}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
|
||||||
|
<td>{{.IP}}</td>
|
||||||
|
<td>{{.Country}}</td>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
|
||||||
|
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{formatTime .ConnectedAt}}</td>
|
||||||
|
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="8">No sessions</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script src="/static/chart.min.js"></script>
|
||||||
|
<script src="/static/dashboard.js"></script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "dashboard_content"}}
|
||||||
<section>
|
<section>
|
||||||
<h3>Top Credentials & IPs</h3>
|
<h3>Top Credentials & IPs</h3>
|
||||||
<div class="top-grid">
|
<div class="top-grid">
|
||||||
@@ -83,45 +171,4 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Active Sessions</h3>
|
|
||||||
<div id="active-sessions" hx-get="/fragments/active-sessions" hx-trigger="every 10s" hx-swap="innerHTML">
|
|
||||||
{{template "active_sessions" .ActiveSessions}}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Recent Sessions</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>IP</th>
|
|
||||||
<th>Country</th>
|
|
||||||
<th>Username</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Score</th>
|
|
||||||
<th>Connected</th>
|
|
||||||
<th>Disconnected</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .RecentSessions}}
|
|
||||||
<tr>
|
|
||||||
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
|
|
||||||
<td>{{.IP}}</td>
|
|
||||||
<td>{{.Country}}</td>
|
|
||||||
<td>{{.Username}}</td>
|
|
||||||
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
|
|
||||||
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
|
|
||||||
<td>{{formatTime .ConnectedAt}}</td>
|
|
||||||
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="8">No sessions</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
#world-map svg { width: 100%; height: auto; }
|
||||||
|
#world-map svg path { fill: #2a2a3e; stroke: #555; stroke-width: 0.5; transition: fill 0.2s; }
|
||||||
|
#world-map svg path:hover { stroke: #fff; stroke-width: 1; }
|
||||||
nav h1 {
|
nav h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -56,5 +59,6 @@
|
|||||||
<main class="container">
|
<main class="container">
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -40,9 +40,13 @@ func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Han
|
|||||||
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
s.mux.HandleFunc("GET /sessions/{id}", s.handleSessionDetail)
|
s.mux.HandleFunc("GET /sessions/{id}", s.handleSessionDetail)
|
||||||
s.mux.HandleFunc("GET /api/sessions/{id}/events", s.handleAPISessionEvents)
|
s.mux.HandleFunc("GET /api/sessions/{id}/events", s.handleAPISessionEvents)
|
||||||
|
s.mux.HandleFunc("GET /api/charts/attempts-over-time", s.handleAPIAttemptsOverTime)
|
||||||
|
s.mux.HandleFunc("GET /api/charts/hourly-pattern", s.handleAPIHourlyPattern)
|
||||||
|
s.mux.HandleFunc("GET /api/charts/country-stats", s.handleAPICountryStats)
|
||||||
s.mux.HandleFunc("GET /", s.handleDashboard)
|
s.mux.HandleFunc("GET /", s.handleDashboard)
|
||||||
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
||||||
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
||||||
|
s.mux.HandleFunc("GET /fragments/dashboard-content", s.handleFragmentDashboardContent)
|
||||||
|
|
||||||
if metricsHandler != nil {
|
if metricsHandler != nil {
|
||||||
h := metricsHandler
|
h := metricsHandler
|
||||||
|
|||||||
@@ -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) {
|
func TestStaticAssets(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
|
|
||||||
@@ -404,6 +533,9 @@ func TestStaticAssets(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"/static/pico.min.css", "text/css"},
|
{"/static/pico.min.css", "text/css"},
|
||||||
{"/static/htmx.min.js", "text/javascript"},
|
{"/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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user