feat: add charts, world map, and filters to web dashboard
Add Chart.js line/bar charts for attack trends (attempts over time, hourly pattern), an SVG world map choropleth colored by attack origin country, and a collapsible filter form (date range, IP, country, username) that narrows both charts and top-N tables. New store methods: GetAttemptsOverTime, GetHourlyPattern, GetCountryStats, and filtered variants of dashboard stats/top-N queries. New JSON API endpoints at /api/charts/* and an htmx fragment at /fragments/dashboard-content for filtered table updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
@@ -180,6 +182,186 @@ type apiEventsResponse struct {
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
sessionID := r.PathValue("id")
|
||||
|
||||
Reference in New Issue
Block a user