internal/server/analytics_requests.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/X4Applegate/caddyui/internal/models"
)
// The request explorer (issue #145).
//
// The reporter's complaint was not that the data was missing but that getting
// to it took "a lot of back and forth": status mix on one page, visitors on
// another, user agents only under an individual visitor. Each drill-down
// answered one question and then dead-ended.
//
// This is one page that takes every dimension at once — host, status class,
// user agent, path, client IP and an explicit time window — and lists the
// matching requests. The existing per-host drill-downs still exist and now
// link here, so "4xx for this host, last hour, from this bot" is one view
// rather than four.
// requestFilterForm is the parsed filter, kept as strings so the template can
// render the form back without reformatting.
type requestFilterForm struct {
Host string
Status string
UA string
Path string
ClientIP string
Window string
From string // RFC3339-ish, from chart drill-through
To string
}
// any reports whether the operator has narrowed anything at all. An unfiltered
// explorer is just a firehose, so the page says so rather than rendering the
// most recent 200 requests as if they were a result.
func (f requestFilterForm) any() bool {
return f.Host != "" || f.Status != "" || f.UA != "" || f.Path != "" ||
f.ClientIP != "" || f.From != ""
}
// parseRequestFilter reads the query string into a filter form.
func parseRequestFilter(r *http.Request) requestFilterForm {
q := r.URL.Query()
get := func(k string) string { return strings.TrimSpace(q.Get(k)) }
f := requestFilterForm{
Host: strings.ToLower(get("host")),
Status: strings.ToLower(get("status")),
UA: get("ua"),
Path: get("path"),
ClientIP: get("ip"),
Window: strings.ToLower(get("window")),
From: get("from"),
To: get("to"),
}
switch f.Status {
case "2xx", "3xx", "4xx", "5xx", "other":
default:
f.Status = ""
}
return f
}
// windowDuration maps the window token to a duration, defaulting to 7 days.
func windowDuration(token string) time.Duration {
switch token {
case "1h":
return time.Hour
case "6h":
return 6 * time.Hour
case "1d", "24h", "day":
return 24 * time.Hour
case "30d":
return 30 * 24 * time.Hour
default:
return 7 * 24 * time.Hour
}
}
// parseUnixParam reads a Unix-seconds query parameter. The chart links pass
// bucket boundaries this way, which keeps the drill-through exact rather than
// re-deriving a window from a label.
func parseUnixParam(v string) (time.Time, bool) {
if v == "" {
return time.Time{}, false
}
sec, err := strconv.ParseInt(v, 10, 64)
if err != nil || sec <= 0 {
return time.Time{}, false
}
return time.Unix(sec, 0).UTC(), true
}
// getAnalyticsRequests renders the request explorer.
func (s *Server) getAnalyticsRequests(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if u == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
isAdmin := u.Role == models.RoleAdmin
serverScopeID := s.analyticsServerScope(r)
f := parseRequestFilter(r)
// Non-admins may only see hosts they own. The filter is intersected with
// that set rather than trusted, so a hand-edited ?host= cannot reach
// another tenant's traffic.
var allowedHosts []string
if !isAdmin {
var err error
allowedHosts, err = s.scopedHostsForAnalytics(u, serverScopeID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if f.Host != "" {
permitted := false
for _, h := range allowedHosts {
if h == f.Host {
permitted = true
break
}
}
if !permitted {
// 404 rather than 403, so the set of hosts stays unguessable.
http.NotFound(w, r)
return
}
}
}
now := time.Now()
window := windowDuration(f.Window)
since := now.Add(-window)
var until time.Time
// An explicit from/to (a chart drill-through) overrides the window.
if from, ok := parseUnixParam(f.From); ok {
since = from
if to, ok2 := parseUnixParam(f.To); ok2 {
until = to
}
}
filter := models.InsightFilter{
From: since,
To: until,
Host: f.Host,
Status: f.Status,
Path: f.Path,
ClientIP: f.ClientIP,
UASearch: f.UA,
ServerID: serverScopeID,
// v2.68.3: a viewer's total, status mix and facets count only their
// own hosts. Filtering the rows afterwards left the counts fleet-wide.
Scoped: !isAdmin,
OnlyHosts: allowedHosts,
}
var (
rows []models.RequestRow
total int64
facets []models.HostFacet
mix models.StatusBuckets
errMsg string
)
if f.any() {
var err error
if rows, err = models.SearchRequests(s.DB, filter, 200); err != nil {
errMsg = "Could not read requests: " + err.Error()
}
total, _ = models.SearchRequestCount(s.DB, filter)
facets, _ = models.SearchHostFacets(s.DB, filter, 12)
mix, _ = models.SearchStatusMix(s.DB, filter)
// Belt and braces: a viewer's SQL is already scoped to these hosts.
if !isAdmin {
rows = filterRowsToHosts(rows, allowedHosts)
facets = filterFacetsToHosts(facets, allowedHosts)
}
}
allServers, _ := models.ListCaddyServers(s.DB)
s.render(w, r, "analytics_requests.html", map[string]any{
"User": u,
"IsAdmin": isAdmin,
"Filter": f,
"HasFilter": f.any(),
"Rows": rows,
"RowCount": len(rows),
"Total": total,
"Truncated": total > int64(len(rows)),
"Facets": facets,
"StatusMix": mix,
"Error": errMsg,
"WindowLabel": windowLabelFor(f.Window),
"ExplicitRange": f.From != "",
"RangeFrom": since,
"RangeTo": until,
"AllServers": allServers,
"SelectedServerID": serverScopeID,
"Section": "analytics",
})
}
// windowLabelFor is the human label for a window token.
func windowLabelFor(token string) string {
switch token {
case "1h":
return "last hour"
case "6h":
return "last 6 hours"
case "1d", "24h", "day":
return "last 24 hours"
case "30d":
return "last 30 days"
default:
return "last 7 days"
}
}
// filterRowsToHosts drops rows for hosts the caller may not see, matching
// wildcard hosts the same way the SQL does. An empty allowed list keeps
// nothing (v2.68.3: it used to keep everything).
func filterRowsToHosts(rows []models.RequestRow, allowed []string) []models.RequestRow {
out := rows[:0]
for _, r := range rows {
if eventHostAllowed(r.Host, allowed) {
out = append(out, r)
}
}
return out
}
func filterFacetsToHosts(facets []models.HostFacet, allowed []string) []models.HostFacet {
out := facets[:0]
for _, f := range facets {
if eventHostAllowed(f.Host, allowed) {
out = append(out, f)
}
}
return out
}