Ctrl K
ComunidadGuías…

internal/server/analytics.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"database/sql"
	"encoding/csv"
	"fmt"
	"log"
	"net"
	"net/http"
	"net/url"
	"path"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"

	"github.com/X4Applegate/caddyui/internal/auth"
	"github.com/X4Applegate/caddyui/internal/caddy"
	"github.com/X4Applegate/caddyui/internal/models"
)

// Settings-table keys for the visitor-analytics feature. All values
// are stored as plain strings via models.GetSetting/SetSetting.
const (
	// settingAnalyticsEnabled is "1" when the admin has turned on access-log
	// ingestion. Controls whether postSettings calls the Caddy admin API to
	// install the net-writer logger — the ingest listener itself runs all
	// the time (see main.go), this just controls whether Caddy feeds it.
	settingAnalyticsEnabled = "analytics_enabled"

	// settingAnalyticsIngestTarget is the host:port Caddy connects to to
	// ship logs. Defaults to "caddyui:9019" which is the container-hostname
	// the docker-compose file in the README uses. On host-network
	// deployments an admin can override to "host.docker.internal:9019" or
	// similar. We store what the admin typed (after trim) rather than the
	// effective value — the handler substitutes the default only when the
	// field is blank.
	settingAnalyticsIngestTarget = "analytics_ingest_target"

	// settingAnalyticsExcludeIPs is a newline-or-comma separated list of
	// plain IPs and CIDR blocks whose events should be dropped before the
	// DB insert. Typical entries: your home LAN ("10.0.0.0/8"), a VPN
	// subnet, the office public IP, an uptime-monitor provider's /24.
	settingAnalyticsExcludeIPs = "analytics_exclude_ips"

	// settingAnalyticsSoftStart controls whether Caddy may load its config
	// while CaddyUI's analytics listener is unavailable. It defaults on so
	// CaddyUI is never a hard startup dependency for Caddy.
	settingAnalyticsSoftStart = "analytics_soft_start"

	// settingAnalyticsDialTimeoutSec limits the initial/reconnect wait for
	// Caddy's net log writer. The value is stored as whole seconds.
	settingAnalyticsDialTimeoutSec = "analytics_dial_timeout_sec"

	// defaultAnalyticsIngestTarget is what the ingest configuration card
	// pre-fills when no value has been saved yet. Chosen to match the
	// docker-compose example in the README — a copy-paste config that
	// works out of the box for users who didn't customise their stack.
	defaultAnalyticsIngestTarget = "caddyui:9019"

	defaultAnalyticsDialTimeoutSec = 5
)

// loadAnalyticsConfig reads the analytics settings into a tidy struct.
// Called both by the /settings handler (to render the form) and by the
// save-then-enable path below (to know what target to pass to EnableAccessLogs).
type analyticsConfig struct {
	Enabled     bool
	Target      string   // host:port, resolved (default applied if blank)
	TargetRaw   string   // the admin's literal input, possibly ""
	ExcludeIPs  []string // one entry per line of the admin's list
	ExcludeRaw  string   // the admin's literal textarea value, for re-render
	SoftStart   bool
	DialTimeout time.Duration
}

func loadAnalyticsConfig(db *sql.DB) analyticsConfig {
	softStartRaw := strings.TrimSpace(mustGetSetting(db, settingAnalyticsSoftStart))
	dialTimeoutSec := defaultAnalyticsDialTimeoutSec
	if raw := strings.TrimSpace(mustGetSetting(db, settingAnalyticsDialTimeoutSec)); raw != "" {
		if n, err := strconv.Atoi(raw); err == nil && n >= 1 && n <= 60 {
			dialTimeoutSec = n
		}
	}
	cfg := analyticsConfig{
		Enabled:     mustGetSetting(db, settingAnalyticsEnabled) == "1",
		TargetRaw:   strings.TrimSpace(mustGetSetting(db, settingAnalyticsIngestTarget)),
		ExcludeRaw:  mustGetSetting(db, settingAnalyticsExcludeIPs),
		SoftStart:   softStartRaw != "0",
		DialTimeout: time.Duration(dialTimeoutSec) * time.Second,
	}
	cfg.Target = cfg.TargetRaw
	if cfg.Target == "" {
		cfg.Target = defaultAnalyticsIngestTarget
	}
	cfg.ExcludeIPs = parseExcludeIPs(cfg.ExcludeRaw)
	return cfg
}

// parseExcludeIPs splits a raw textarea value into entries. Accepts commas
// and/or newlines as separators so admins can paste either format. Blank
// entries are dropped; order is preserved; no dedup (the ingest checks
// nets in O(n), so preserving admin intent is more valuable than a tiny
// speedup from dedup on what's typically <10 entries).
func parseExcludeIPs(raw string) []string {
	if raw == "" {
		return nil
	}
	// Unify separators: replace commas with newlines, then Fields-on-newline.
	raw = strings.ReplaceAll(raw, ",", "\n")
	var out []string
	for _, line := range strings.Split(raw, "\n") {
		line = strings.TrimSpace(line)
		if line != "" {
			out = append(out, line)
		}
	}
	return out
}

// applyAnalyticsToggle is called from postSettings after the analytics settings
// keys have been saved. It does two things:
//
//  1. Pushes the new exclude-IP list to the live ingest listener so the
//     change is visible immediately without a restart.
//  2. Enables or disables Caddy's access-log forwarding via the admin API
//     depending on the toggle state. Enable+already-enabled re-pushes the
//     target so a changed target takes effect on save.
//
// v2.7.6: applies the toggle to *every* registered Caddy server, not just
// the primary. Before this, only s.Caddy (the CADDY_ADMIN_URL primary) got
// reconfigured — secondary servers added via /servers kept their default
// "no access logs" state, so events from those servers never reached the
// ingest. An admin picking a secondary in the /analytics server filter saw
// "no data" even when the server was live and serving real traffic.
// External-type servers are skipped (read-only from CaddyUI's point of view
// — we can't push config changes to them).
//
// Errors from individual servers are collected into a single aggregated
// error rather than short-circuiting on the first failure — one unreachable
// Caddy shouldn't stop us configuring the other three.
func (s *Server) applyAnalyticsToggle(cfg analyticsConfig) error {
	// Push exclude list to the ingest regardless of enable state — even
	// when disabled, keeping the filter in sync means a later re-enable
	// picks up whatever the admin configured in the meantime.
	if s.analyticsIngest != nil {
		s.analyticsIngest.SetExcludeIPs(cfg.ExcludeIPs)
	}

	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		return fmt.Errorf("list caddy servers: %w", err)
	}

	// Fallback: on a fresh install that hasn't seeded any server rows yet
	// (the SeedBootstrapServer step in main.go hasn't run, or the DB lost
	// its servers row), fall back to the pre-2.7.6 behaviour and toggle
	// via s.Caddy directly so the first-boot analytics enable still works.
	if len(servers) == 0 {
		if cfg.Enabled {
			if err := s.Caddy.EnableAccessLogs(cfg.Target, caddy.AccessLogOptions{
				SoftStart: cfg.SoftStart, DialTimeout: cfg.DialTimeout,
			}); err != nil {
				return fmt.Errorf("enable access logs: %w", err)
			}
			return nil
		}
		if err := s.Caddy.DisableAccessLogs(); err != nil {
			return fmt.Errorf("disable access logs: %w", err)
		}
		return nil
	}

	var errMsgs []string
	for _, sr := range servers {
		// External servers are read-only — we can't reconfigure them via
		// the admin API, so skip silently. The typical case is a Caddy
		// managed by a different tool that CaddyUI is observing but not
		// authoritative over.
		if sr.Type == models.CaddyServerTypeExternal {
			continue
		}
		cli := newCaddyClient(sr.AdminURL, sr.AdminUsername, sr.AdminPassword)
		if cfg.Enabled {
			// v2.37.0: each node ships to the target IT can reach.
			if err := cli.EnableAccessLogs(sr.EffectiveIngestTarget(cfg.Target), caddy.AccessLogOptions{
				SoftStart: cfg.SoftStart, DialTimeout: cfg.DialTimeout,
				ServerID: sr.ID, ServerName: sr.Name,
			}); err != nil {
				errMsgs = append(errMsgs, fmt.Sprintf("server %q (%s): enable: %v", sr.Name, sr.AdminURL, err))
				continue
			}
		} else {
			if err := cli.DisableAccessLogs(); err != nil {
				errMsgs = append(errMsgs, fmt.Sprintf("server %q (%s): disable: %v", sr.Name, sr.AdminURL, err))
				continue
			}
		}
	}
	if len(errMsgs) > 0 {
		return fmt.Errorf("analytics toggle: %s", strings.Join(errMsgs, "; "))
	}
	return nil
}

// ReconcileAnalyticsAccessLogs repairs legacy CaddyUI default-logger overrides
// on every managed node, then refreshes an enabled analytics stream. The repair
// is unconditional because fleet file logging can be enabled independently of
// analytics, and both v2.25.0/v2.25.1 logger names could redirect normal Caddy
// console or file output.
func (s *Server) ReconcileAnalyticsAccessLogs() error {
	cfg := loadAnalyticsConfig(s.DB)
	repairErr := s.repairCaddyUIDefaultLoggers()
	if !cfg.Enabled {
		if s.analyticsIngest != nil {
			s.analyticsIngest.SetExcludeIPs(cfg.ExcludeIPs)
		}
		return repairErr
	}
	toggleErr := s.applyAnalyticsToggle(cfg)
	if repairErr != nil && toggleErr != nil {
		return fmt.Errorf("repair logger defaults: %v; refresh analytics logger: %w", repairErr, toggleErr)
	}
	if repairErr != nil {
		return repairErr
	}
	return toggleErr
}

func (s *Server) repairCaddyUIDefaultLoggers() error {
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		return fmt.Errorf("list caddy servers for logger repair: %w", err)
	}
	if len(servers) == 0 {
		return s.Caddy.RepairDefaultLoggerOverrides(caddy.AccessLogLoggerName, fleetAccessLoggerName)
	}
	var errMsgs []string
	for _, server := range servers {
		if server.Type != models.CaddyServerTypeManaged {
			continue
		}
		client := newCaddyClient(server.AdminURL, server.AdminUsername, server.AdminPassword)
		if err := client.RepairDefaultLoggerOverrides(caddy.AccessLogLoggerName, fleetAccessLoggerName); err != nil {
			errMsgs = append(errMsgs, fmt.Sprintf("server %q (%s): %v", server.Name, server.AdminURL, err))
		}
	}
	if len(errMsgs) > 0 {
		return fmt.Errorf("default-logger repair: %s", strings.Join(errMsgs, "; "))
	}
	return nil
}

// userAllowedHosts returns the list of host strings (as Caddy would see them
// in http.log.access.request.host) that the given user is allowed to see
// analytics for. Admins get nil, which callers interpret as "no filter".
// Non-admins get the union of their owned proxy-host domains and raw-route
// names across every Caddy server in the DB — analytics is cross-server by
// default (one dashboard for the whole fleet).
//
// Thin wrapper over scopedHostsForAnalytics — preserved as a separate name
// for callers that don't yet pass a server scope.
func (s *Server) userAllowedHosts(u *models.User) ([]string, error) {
	return s.scopedHostsForAnalytics(u, 0)
}

// scopedHostsForAnalytics returns the set of hosts a user is allowed to see
// analytics for, optionally narrowed to a single Caddy server. v2.7.1
// added the `serverID` parameter to power the inline server-filter UI on
// /analytics — admins with 5+ servers were swamped by a fleet-wide dashboard
// and wanted to focus on one at a time.
//
//	serverID == 0 → cross-fleet scope (legacy behaviour).
//	  - admin    → nil (means "no host filter", single-query hot path).
//	  - non-admin → union of owned hosts across every server.
//
//	serverID > 0 → scoped to that one server.
//	  - admin    → nil host filter; access_events.server_id is authoritative.
//	  - non-admin → intersection of "owned" with "routed by this server"
//	              (i.e. the same ListProxyHosts/ListRawRoutes call with
//	              viewerID + isAdmin=false).
//
// The host list is lowercased + deduped so downstream SQL IN-clauses don't
// have to worry about case or repeats.
func (s *Server) scopedHostsForAnalytics(u *models.User, serverID int64) ([]string, error) {
	if u == nil {
		return []string{}, nil
	}
	isAdmin := u.Role == models.RoleAdmin
	// Admin views never infer node ownership from current route hostnames.
	// The stamped access_events.server_id is authoritative, including for
	// shared hostnames and routes that were changed after an event arrived.
	if isAdmin {
		return nil, nil
	}

	var servers []models.CaddyServer
	if serverID > 0 {
		// Single-server scope — fetch just that one and proceed with a
		// one-element loop. Missing/unknown server_id returns empty hosts
		// rather than an error so a stale query-string doesn't 500 the
		// page; the UI picker will re-render with "All servers" selected.
		sr, err := models.GetCaddyServer(s.DB, serverID)
		if err != nil || sr == nil {
			return []string{}, nil
		}
		servers = []models.CaddyServer{*sr}
	} else {
		// Cross-fleet non-admin: every server.
		var err error
		servers, err = models.ListCaddyServers(s.DB)
		if err != nil {
			return nil, err
		}
	}

	viewerID := u.ID
	queryAsAdmin := false
	// Analytics visibility follows list visibility — if a user can see a
	// teammate's proxy host, they should also see its traffic.
	peers, _ := models.GroupPeerIDs(s.DB, u.ID)

	// Never nil for a non-admin: callers read nil as "admin, no host filter",
	// so a viewer who could see no hosts was shown the whole fleet (v2.68.3).
	hosts := []string{}
	seen := make(map[string]bool)
	add := func(raw string) {
		d := strings.ToLower(strings.TrimSpace(raw))
		if d == "" || seen[d] {
			return
		}
		seen[d] = true
		hosts = append(hosts, d)
	}
	for _, sr := range servers {
		proxies, err := models.ListProxyHosts(s.DB, sr.ID, viewerID, queryAsAdmin, peers)
		if err != nil {
			return nil, err
		}
		for _, p := range proxies {
			for _, d := range p.DomainList() {
				add(d)
			}
		}
		// v2.12.17: include redirection hosts in analytics scope. The
		// access-log ingest tags every request by hostname regardless of
		// what handler served it (proxy / redirect / raw), so leaving
		// redirections out meant the dashboard cards undercounted users
		// who land on a redirect, follow it, and produce two access_events
		// — one for the source hostname (redirect) and one for the target
		// (proxy). Only the target was being counted.
		redirs, err := models.ListRedirectionHosts(s.DB, sr.ID, viewerID, queryAsAdmin, peers)
		if err != nil {
			return nil, err
		}
		for _, rh := range redirs {
			for _, d := range rh.DomainList() {
				add(d)
			}
		}
		raws, err := models.ListRawRoutes(s.DB, sr.ID, viewerID, queryAsAdmin, peers)
		if err != nil {
			return nil, err
		}
		for _, r := range raws {
			// RawRoute doesn't have a DomainList() like ProxyHost —
			// hostnames live inside the JSONData blob's match[].host[]
			// array. rawRouteHosts (server.go) is the canonical extractor.
			for _, d := range rawRouteHosts(r) {
				add(d)
			}
		}
	}
	return hosts, nil
}

// analyticsServerScope resolves the inline server filter. Bare analytics
// pages follow the globally selected server; an explicit "all" requests the
// cross-fleet view.
func (s *Server) analyticsServerScope(r *http.Request) int64 {
	if !r.URL.Query().Has("server") {
		return s.currentServerID(r)
	}
	raw := strings.TrimSpace(r.URL.Query().Get("server"))
	if raw == "" || raw == "0" || raw == "all" {
		return 0
	}
	serverID, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || serverID <= 0 {
		return 0
	}
	return serverID
}

// getAnalytics renders the /analytics overview page. Shows totals (today +
// last 7d), the "live now" visitor count, a per-host table, a 24-hour
// hourly sparkline, and a status-code breakdown. Scope is admin-see-all
// vs non-admin-see-only-my-hosts, driven by scopedHostsForAnalytics above.
func (s *Server) getAnalytics(w http.ResponseWriter, r *http.Request) {
	u := s.currentUser(r)

	// v2.12.13: bare /analytics now defaults to the picker-active server
	// (s.currentServerID), not the cross-fleet view. The all-fleet rollup
	// is the slow path on multi-server installs; users almost always want
	// "show me what's happening on the server I picked." Explicit
	// ?server=all (or ?server=0 for back-compat with old bookmarks)
	// still gets you the cross-fleet view.
	serverScopeID := s.analyticsServerScope(r)

	allowedHosts, err := s.scopedHostsForAnalytics(u, serverScopeID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	isAdmin := u != nil && u.Role == models.RoleAdmin
	// A non-admin is always in per-host filter mode; with no visible hosts
	// the loops below run zero times and every figure is 0. An admin takes
	// the single-query hot path, narrowed by access_events.server_id when a
	// server is picked. v2.68.3: decided by role, not by allowedHosts being
	// nil — a viewer with no hosts used to fall through to the fleet view.
	scoped := !isAdmin
	allServers, _ := models.ListCaddyServers(s.DB)

	now := time.Now()
	// UTC midnight boundaries — access_events stores unix seconds, so all
	// windows compute against time.Unix. "Today" here means "since local
	// midnight in the active timezone" so the card matches what admins
	// read on the clock, not an arbitrary UTC cutoff.
	loc := activeLocation()
	todayStart := time.Date(now.In(loc).Year(), now.In(loc).Month(), now.In(loc).Day(), 0, 0, 0, 0, loc)
	sevenDaysAgo := now.Add(-7 * 24 * time.Hour)
	twentyFourHoursAgo := now.Add(-24 * time.Hour)

	// Filter helper: unscoped (admin + no server filter) sees everything via
	// a single-query hot path. Scoped (non-admin anywhere, or admin with
	// ?server=<id>) loops per-host and sums, because a 20-way IN clause on
	// every aggregate query is slower than 20 indexed single-host lookups
	// — and keeps AccessTotalsSince's public signature uncluttered.
	var (
		todayTotals  models.AccessTotals
		sevenTotals  models.AccessTotals
		liveVisitors int
	)
	if !scoped {
		var err2 error
		todayTotals, err2 = models.AccessTotalsSince(s.DB, todayStart, "", serverScopeID)
		if err2 != nil {
			log.Printf("analytics: AccessTotalsSince(today): %v", err2)
		}
		sevenTotals, err2 = models.AccessTotalsSince(s.DB, sevenDaysAgo, "", serverScopeID)
		if err2 != nil {
			log.Printf("analytics: AccessTotalsSince(7d): %v", err2)
		}
		liveVisitors, err2 = models.AccessLiveVisitors(s.DB, 5*time.Minute, serverScopeID)
		if err2 != nil {
			log.Printf("analytics: AccessLiveVisitors: %v", err2)
		}
	} else {
		for _, h := range allowedHosts {
			t1, _ := models.AccessTotalsSince(s.DB, todayStart, h, serverScopeID)
			todayTotals.Views += t1.Views
			todayTotals.Visitors += t1.Visitors
			t7, _ := models.AccessTotalsSince(s.DB, sevenDaysAgo, h, serverScopeID)
			sevenTotals.Views += t7.Views
			sevenTotals.Visitors += t7.Visitors
		}
		// Live: distinct IPs across owned hosts. Per-host counts aren't
		// additive (an IP visiting two hosts would double-count), so we
		// union in code rather than sum.
		liveVisitors = s.liveVisitorsAcrossHosts(allowedHosts, 5*time.Minute, serverScopeID)
	}

	// Per-host table: top 50 by views in last 7 days.
	var hostRows []models.HostStats
	if !scoped {
		var err2 error
		hostRows, err2 = models.TopHostsSince(s.DB, sevenDaysAgo, 50, serverScopeID)
		if err2 != nil {
			log.Printf("analytics: TopHostsSince: %v", err2)
		}
	} else {
		var err2 error
		hostRows, err2 = models.HostStatsForHosts(s.DB, sevenDaysAgo, allowedHosts, serverScopeID)
		if err2 != nil {
			log.Printf("analytics: HostStatsForHosts: %v", err2)
		}
	}

	// 24h hourly sparkline: fill zero-buckets so the chart doesn't jump.
	bucketSec := int64(3600) // 1h buckets
	var rawBuckets []models.HourlyBucket
	if !scoped {
		var err2 error
		rawBuckets, err2 = models.AccessBuckets(s.DB, twentyFourHoursAgo, now, bucketSec, "", serverScopeID)
		if err2 != nil {
			log.Printf("analytics: AccessBuckets(unscoped): %v", err2)
		}
	} else {
		// Scoped path needs per-host buckets summed. AccessBuckets takes a
		// single host string, so loop and merge by hour.
		merged := make(map[int64]models.HourlyBucket)
		for _, h := range allowedHosts {
			bs, _ := models.AccessBuckets(s.DB, twentyFourHoursAgo, now, bucketSec, h, serverScopeID)
			for _, b := range bs {
				key := b.Hour.Unix()
				m := merged[key]
				m.Hour = b.Hour
				m.Views += b.Views
				m.Visitors += b.Visitors
				merged[key] = m
			}
		}
		for _, b := range merged {
			rawBuckets = append(rawBuckets, b)
		}
	}
	sparkline := fillBuckets(rawBuckets, twentyFourHoursAgo, now, bucketSec)
	// v2.68.2 (issue #145 follow-up): colour the fleet chart by response class
	// and make each bar drill through, matching the per-host chart. Scoped the
	// same way the sparkline is, so a viewer never sees other tenants' errors.
	var mixHosts []string
	if scoped {
		mixHosts = allowedHosts
	}
	mainStatusMix := s.statusMixByBucket(twentyFourHoursAgo, now, bucketSec, mixHosts, serverScopeID)

	// Bandwidth (bytes_out) sparkline — same window and bucket as request sparkline.
	var bwBuckets []models.BandwidthBucket
	if !scoped {
		bwBuckets, _ = models.BandwidthBuckets(s.DB, twentyFourHoursAgo, now, bucketSec, "", serverScopeID)
	} else {
		bwMap := make(map[int64]models.BandwidthBucket)
		for _, h := range allowedHosts {
			bs, _ := models.BandwidthBuckets(s.DB, twentyFourHoursAgo, now, bucketSec, h, serverScopeID)
			for _, b := range bs {
				key := b.Hour.Unix()
				m := bwMap[key]
				m.Hour = b.Hour
				m.BytesOut += b.BytesOut
				bwMap[key] = m
			}
		}
		for _, b := range bwMap {
			bwBuckets = append(bwBuckets, b)
		}
	}
	// Fill zero-gaps.
	bwSparkline := fillBandwidthBuckets(bwBuckets, twentyFourHoursAgo, now, bucketSec)
	// Compute total and max bandwidth for the template.
	var bwTotal, bwMax int64
	for _, b := range bwSparkline {
		bwTotal += b.BytesOut
		if b.BytesOut > bwMax {
			bwMax = b.BytesOut
		}
	}
	if bwMax == 0 {
		bwMax = 1
	}

	// Status-class pie.
	var statusBuckets models.StatusBuckets
	if !scoped {
		var err2 error
		statusBuckets, err2 = models.StatusBucketsSince(s.DB, sevenDaysAgo, "", serverScopeID)
		if err2 != nil {
			log.Printf("analytics: StatusBucketsSince(unscoped): %v", err2)
		}
	} else {
		for _, h := range allowedHosts {
			b, err2 := models.StatusBucketsSince(s.DB, sevenDaysAgo, h, serverScopeID)
			if err2 != nil {
				log.Printf("analytics: StatusBucketsSince(%s): %v", h, err2)
				continue
			}
			statusBuckets.S2xx += b.S2xx
			statusBuckets.S3xx += b.S3xx
			statusBuckets.S4xx += b.S4xx
			statusBuckets.S5xx += b.S5xx
			statusBuckets.SOther += b.SOther
		}
	}

	// Ingest health card — values only meaningful on admin view, so render
	// conditionally in the template.
	var ingestStats map[string]any
	if s.analyticsIngest != nil {
		snap := s.analyticsIngest.Stats()
		ingestStats = map[string]any{
			"Connections": snap.Connections,
			"Events":      snap.Events,
			"Excluded":    snap.Excluded,
			"Errors":      snap.Errors,
			"LastEvent":   snap.LastEventAt,
			"Healthy":     snap.Events > 0 && time.Since(snap.LastEventAt) < 10*time.Minute,
		}
	}

	cfg := loadAnalyticsConfig(s.DB)
	// v2.37.0: with one node in scope, say where that node ships its logs —
	// and warn when it plainly can't reach that address. A remote node handed
	// the default Docker service name showed an empty page with no clue why.
	var scopeIngest map[string]any
	if serverScopeID > 0 {
		for _, sr := range allServers {
			if sr.ID != serverScopeID {
				continue
			}
			target := sr.EffectiveIngestTarget(cfg.Target)
			scopeIngest = map[string]any{
				"ServerID": sr.ID, "ServerName": sr.Name, "Target": target,
				"Warning": sr.IngestTargetWarning(target),
			}
			break
		}
	}
	s.render(w, r, "analytics.html", map[string]any{
		"User":               u,
		"IsAdmin":            isAdmin,
		"Today":              todayTotals,
		"SevenDay":           sevenTotals,
		"LiveVisitors":       liveVisitors,
		"Hosts":              hostRows,
		"Sparkline":          sparkline,
		"StatusMixByBucket":  mainStatusMix, // issue #145 follow-up
		"BucketSeconds":      bucketSec,
		"BandwidthSparkline": bwSparkline,
		"BandwidthTotal":     bwTotal,
		"BandwidthMax":       bwMax,
		"Status":             statusBuckets,
		"IngestStats":        ingestStats,
		"AnalyticsEnabled":   cfg.Enabled,
		"ScopeIngest":        scopeIngest,
		// Server-switcher data. AllServers feeds the <select>; the template
		// only renders the picker when len > 1 so a one-server deployment
		// doesn't get a useless dropdown. SelectedServerID is 0 for "all
		// servers" so the picker's first <option value="0"> is marked
		// selected when nothing's been picked.
		"AllServers":       allServers,
		"SelectedServerID": serverScopeID,
		"Section":          "analytics",
	})
}

// getAnalyticsHost is the per-host drill-down. Shows path leaderboard,
// client-IP leaderboard, hourly chart for the selected window, and status
// breakdown — all scoped to one host. Non-admins only see hosts they own.
func (s *Server) getAnalyticsHost(w http.ResponseWriter, r *http.Request) {
	host := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "host")))
	if host == "" {
		http.Redirect(w, r, "/analytics", http.StatusSeeOther)
		return
	}
	u := s.currentUser(r)
	isAdmin := u != nil && u.Role == models.RoleAdmin
	serverScopeID := s.analyticsServerScope(r)
	if !isAdmin {
		allowed, err := s.scopedHostsForAnalytics(u, serverScopeID)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		ok := false
		for _, h := range allowed {
			if h == host {
				ok = true
				break
			}
		}
		if !ok {
			// 404 rather than 403 — leaking "this host exists but you
			// can't see it" would help attackers enumerate other users'
			// sites. The standard "not found" matches what they'd get
			// for a host that genuinely has no events either.
			http.NotFound(w, r)
			return
		}
	}

	// Window: default 7d, let ?window= override with 1d/30d. Kept in a
	// small set of approved values so a tampered query-string can't make
	// us scan a trillion-row range.
	window := 7 * 24 * time.Hour
	switch strings.ToLower(r.URL.Query().Get("window")) {
	case "1d", "24h", "day":
		window = 24 * time.Hour
	case "30d":
		window = 30 * 24 * time.Hour
	}
	now := time.Now()
	since := now.Add(-window)
	prevSince := since.Add(-window) // start of previous period
	prevTo := since                 // = start of current period

	totals, _ := models.AccessTotalsBetween(s.DB, since, now, host, serverScopeID)
	prevTotals, _ := models.AccessTotalsBetween(s.DB, prevSince, prevTo, host, serverScopeID)

	var viewsChange, visitorsChange int
	var viewsUp, visitorsUp bool
	if prevTotals.Views > 0 {
		viewsChange = (totals.Views - prevTotals.Views) * 100 / prevTotals.Views
		viewsUp = totals.Views >= prevTotals.Views
	} else if totals.Views > 0 {
		viewsChange = 100
		viewsUp = true
	}
	if prevTotals.Visitors > 0 {
		visitorsChange = (totals.Visitors - prevTotals.Visitors) * 100 / prevTotals.Visitors
		visitorsUp = totals.Visitors >= prevTotals.Visitors
	} else if totals.Visitors > 0 {
		visitorsChange = 100
		visitorsUp = true
	}
	viewsChangeAbs := viewsChange
	if viewsChangeAbs < 0 {
		viewsChangeAbs = -viewsChangeAbs
	}
	visitorsChangeAbs := visitorsChange
	if visitorsChangeAbs < 0 {
		visitorsChangeAbs = -visitorsChangeAbs
	}
	paths, _ := models.TopPaths(s.DB, since, host, 20, serverScopeID)
	clients, _ := models.TopClientIPs(s.DB, since, host, 20, serverScopeID)
	status, _ := models.StatusBucketsSince(s.DB, since, host, serverScopeID)
	rtStats, err2 := models.ResponseTimeSince(s.DB, since, host, serverScopeID)
	if err2 != nil {
		log.Printf("analytics: ResponseTimeSince(%s): %v", host, err2)
	}
	bandwidth, err3 := models.BandwidthSince(s.DB, since, host, serverScopeID)
	if err3 != nil {
		log.Printf("analytics: BandwidthSince(%s): %v", host, err3)
	}
	errorPaths, err4 := models.TopErrorPaths(s.DB, since, host, 15, serverScopeID)
	if err4 != nil {
		log.Printf("analytics: TopErrorPaths(%s): %v", host, err4)
	}
	// issue #94: exact status-code + path breakdown, so "1200 4xx" becomes
	// "404 /wp-login.php ×840" — what is actually failing/being probed.
	errorDetails, err5 := models.TopErrorDetails(s.DB, since, host, 15, serverScopeID)
	if err5 != nil {
		log.Printf("analytics: TopErrorDetails(%s): %v", host, err5)
	}
	browsers, _ := models.TopBrowsers(s.DB, since, host, 8, serverScopeID)

	bucketSec := int64(3600) // 1h buckets regardless of window — keeps the
	if window >= 30*24*time.Hour {
		bucketSec = 86400 // ...except 30d+ views where hourly would be 720 bars.
	}
	rawBuckets, _ := models.AccessBuckets(s.DB, since, now, bucketSec, host, serverScopeID)
	buckets := fillBuckets(rawBuckets, since, now, bucketSec)

	// v2.66.0 (issue #145): the extra dimensions the reporter was having to
	// chase across several pages — the raw user agents hitting this host
	// (TopBrowsers above only buckets them into Chrome/Firefox/…), the full
	// latency picture, and the per-bucket status mix so the request chart can
	// be coloured by response class.
	insight := models.InsightFilter{From: since, Host: host, ServerID: serverScopeID}
	hostAgents, _ := models.HostUserAgents(s.DB, insight, 12)
	latency, errLat := models.LatencySummary(s.DB, insight)
	if errLat != nil {
		log.Printf("analytics: LatencySummary(%s): %v", host, errLat)
	}
	mixByBucket := s.statusMixByBucket(since, now, bucketSec, []string{host}, serverScopeID)
	serverScopeName := "All servers"
	if serverScopeID > 0 {
		serverScopeName = fmt.Sprintf("Server %d", serverScopeID)
		if server, _ := models.GetCaddyServer(s.DB, serverScopeID); server != nil {
			serverScopeName = server.Name
		}
	}

	s.render(w, r, "analytics_host.html", map[string]any{
		"User":              u,
		"IsAdmin":           isAdmin,
		"Host":              host,
		"SelectedServerID":  serverScopeID,
		"ServerScopeName":   serverScopeName,
		"Window":            window,
		"WindowLabel":       formatWindow(window),
		"WindowQuery":       windowQuery(window),
		"Totals":            totals,
		"Paths":             paths,
		"Clients":           clients,
		"Status":            status,
		"Buckets":           buckets,
		"BucketSeconds":     bucketSec,
		"ResponseTime":      rtStats,
		"Bandwidth":         bandwidth,
		"ErrorPaths":        errorPaths,
		"ErrorDetails":      errorDetails,
		"Browsers":          browsers,
		"HostAgents":        hostAgents,  // issue #145 item 4
		"Latency":           latency,     // issue #145 item 6
		"StatusMixByBucket": mixByBucket, // issue #145 item 3
		"Section":           "analytics",
		"ViewsChange":       viewsChange,
		"ViewsUp":           viewsUp,
		"ViewsChangeAbs":    viewsChangeAbs,
		"VisitorsChange":    visitorsChange,
		"VisitorsUp":        visitorsUp,
		"VisitorsChangeAbs": visitorsChangeAbs,
		"PrevPeriod":        fmt.Sprintf("prev %s", formatWindow(window)),
	})
}

// analyticsScope carries the resolved+authorized host, server scope and time
// window shared by the per-host drill-down endpoints (issue #94).
type analyticsScope struct {
	Host            string
	User            *models.User
	IsAdmin         bool
	ServerScopeID   int64
	ServerScopeName string
	Window          time.Duration
	Since           time.Time
	Now             time.Time
}

// resolveAnalyticsScope validates the {host} URL param, authorizes the caller
// for it, and parses the window/server scope. It returns ok=false when it has
// already written a response (redirect / 404), so callers must simply return.
func (s *Server) resolveAnalyticsScope(w http.ResponseWriter, r *http.Request) (*analyticsScope, bool) {
	host := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "host")))
	if host == "" {
		http.Redirect(w, r, "/analytics", http.StatusSeeOther)
		return nil, false
	}
	u := s.currentUser(r)
	isAdmin := u != nil && u.Role == models.RoleAdmin
	serverScopeID := s.analyticsServerScope(r)
	if !isAdmin {
		allowed, err := s.scopedHostsForAnalytics(u, serverScopeID)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return nil, false
		}
		ok := false
		for _, h := range allowed {
			if h == host {
				ok = true
				break
			}
		}
		if !ok {
			// 404 (not 403) so a viewer can't enumerate hosts they can't see.
			http.NotFound(w, r)
			return nil, false
		}
	}
	window := 7 * 24 * time.Hour
	switch strings.ToLower(r.URL.Query().Get("window")) {
	case "1d", "24h", "day":
		window = 24 * time.Hour
	case "30d":
		window = 30 * 24 * time.Hour
	}
	now := time.Now()
	serverScopeName := "All servers"
	if serverScopeID > 0 {
		serverScopeName = fmt.Sprintf("Server %d", serverScopeID)
		if server, _ := models.GetCaddyServer(s.DB, serverScopeID); server != nil {
			serverScopeName = server.Name
		}
	}
	return &analyticsScope{
		Host:            host,
		User:            u,
		IsAdmin:         isAdmin,
		ServerScopeID:   serverScopeID,
		ServerScopeName: serverScopeName,
		Window:          window,
		Since:           now.Add(-window),
		Now:             now,
	}, true
}

// statusMixByBucket returns the per-bucket status breakdown keyed by bucket
// start, for colouring a request chart by response class.
//
// hosts nil means fleet-wide; a non-nil list restricts to those hosts and
// merges their buckets, which is what a non-admin viewer needs — otherwise
// the chart above their own hosts would be coloured by everyone's errors.
//
// v2.68.2 (issue #145 follow-up).
func (s *Server) statusMixByBucket(from, to time.Time, bucketSec int64, hosts []string, serverID int64) map[int64]models.StatusMixBucket {
	out := map[int64]models.StatusMixBucket{}
	add := func(buckets []models.StatusMixBucket) {
		for _, b := range buckets {
			key := b.Hour.Unix()
			m := out[key]
			m.Hour = b.Hour
			m.S2xx += b.S2xx
			m.S3xx += b.S3xx
			m.S4xx += b.S4xx
			m.S5xx += b.S5xx
			m.Other += b.Other
			out[key] = m
		}
	}
	if hosts == nil {
		mix, err := models.StatusMixBuckets(s.DB, models.InsightFilter{From: from, To: to, ServerID: serverID}, bucketSec)
		if err != nil {
			log.Printf("analytics: StatusMixBuckets(unscoped): %v", err)
		}
		add(mix)
		return out
	}
	for _, h := range hosts {
		mix, _ := models.StatusMixBuckets(s.DB, models.InsightFilter{From: from, To: to, Host: h, ServerID: serverID}, bucketSec)
		add(mix)
	}
	return out
}

// getAnalyticsVisitor renders the per-visitor drill-down for a single client IP
// on a host: its top paths, status breakdown, exact error detail and user
// agents. Answers "is this a noisy client or someone probing?" (issue #94).
func (s *Server) getAnalyticsVisitor(w http.ResponseWriter, r *http.Request) {
	sc, ok := s.resolveAnalyticsScope(w, r)
	if !ok {
		return
	}
	ip := strings.TrimSpace(r.URL.Query().Get("ip"))
	if ip == "" {
		http.Redirect(w, r, "/analytics/"+sc.Host, http.StatusSeeOther)
		return
	}
	views, _ := models.VisitorTotals(s.DB, sc.Since, sc.Host, ip, sc.ServerScopeID)
	paths, _ := models.VisitorPaths(s.DB, sc.Since, sc.Host, ip, 30, sc.ServerScopeID)
	status, _ := models.VisitorStatusBuckets(s.DB, sc.Since, sc.Host, ip, sc.ServerScopeID)
	errorDetails, _ := models.VisitorErrorDetails(s.DB, sc.Since, sc.Host, ip, 20, sc.ServerScopeID)
	agents, _ := models.VisitorUserAgents(s.DB, sc.Since, sc.Host, ip, 10, sc.ServerScopeID)

	s.render(w, r, "analytics_visitor.html", map[string]any{
		"User":             sc.User,
		"IsAdmin":          sc.IsAdmin,
		"Host":             sc.Host,
		"ClientIP":         ip,
		"SelectedServerID": sc.ServerScopeID,
		"ServerScopeName":  sc.ServerScopeName,
		"Window":           sc.Window,
		"WindowLabel":      formatWindow(sc.Window),
		"WindowQuery":      windowQuery(sc.Window),
		"Views":            views,
		"Paths":            paths,
		"Status":           status,
		"ErrorDetails":     errorDetails,
		"UserAgents":       agents,
		"Blocked":          r.URL.Query().Get("blocked"),  // issue #100 flash
		"BlockErr":         r.URL.Query().Get("blockerr"), // issue #100 flash ("" | "1" | "nohost")
		"Section":          "analytics",
	})
}

// getAnalyticsPath renders the per-path drill-down for a single path on a host:
// which visitors requested it and its status breakdown (issue #94).
func (s *Server) getAnalyticsPath(w http.ResponseWriter, r *http.Request) {
	sc, ok := s.resolveAnalyticsScope(w, r)
	if !ok {
		return
	}
	path := r.URL.Query().Get("p")
	if path == "" {
		http.Redirect(w, r, "/analytics/"+sc.Host, http.StatusSeeOther)
		return
	}
	totals, _ := models.PathTotals(s.DB, sc.Since, sc.Host, path, sc.ServerScopeID)
	clients, _ := models.PathClients(s.DB, sc.Since, sc.Host, path, 30, sc.ServerScopeID)
	status, _ := models.PathStatusBuckets(s.DB, sc.Since, sc.Host, path, sc.ServerScopeID)

	s.render(w, r, "analytics_path.html", map[string]any{
		"User":             sc.User,
		"IsAdmin":          sc.IsAdmin,
		"Host":             sc.Host,
		"Path":             path,
		"SelectedServerID": sc.ServerScopeID,
		"ServerScopeName":  sc.ServerScopeName,
		"Window":           sc.Window,
		"WindowLabel":      formatWindow(sc.Window),
		"WindowQuery":      windowQuery(sc.Window),
		"Totals":           totals,
		"Clients":          clients,
		"Status":           status,
		"Section":          "analytics",
	})
}

// getAnalyticsStatus renders the per-status-class drill-down for a host: the
// exact status codes within the class, the top paths that produced them and the
// top visitors that received them (issue #94 follow-up). Reachable from the
// Status mix card and the HTTP status breakdown bar.
func (s *Server) getAnalyticsStatus(w http.ResponseWriter, r *http.Request) {
	sc, ok := s.resolveAnalyticsScope(w, r)
	if !ok {
		return
	}
	class := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("class")))
	labels := map[string]string{"2xx": "2xx Success", "3xx": "3xx Redirect", "4xx": "4xx Client error", "5xx": "5xx Server error", "other": "Other"}
	label, valid := labels[class]
	if !valid {
		http.Redirect(w, r, "/analytics/"+sc.Host, http.StatusSeeOther)
		return
	}
	totals, _ := models.StatusClassTotals(s.DB, sc.Since, sc.Host, class, sc.ServerScopeID)
	codes, _ := models.StatusClassCodes(s.DB, sc.Since, sc.Host, class, 20, sc.ServerScopeID)
	paths, _ := models.StatusClassPaths(s.DB, sc.Since, sc.Host, class, 30, sc.ServerScopeID)
	clients, _ := models.StatusClassClients(s.DB, sc.Since, sc.Host, class, 30, sc.ServerScopeID)

	s.render(w, r, "analytics_status.html", map[string]any{
		"User":             sc.User,
		"IsAdmin":          sc.IsAdmin,
		"Host":             sc.Host,
		"Class":            class,
		"ClassLabel":       label,
		"SelectedServerID": sc.ServerScopeID,
		"ServerScopeName":  sc.ServerScopeName,
		"Window":           sc.Window,
		"WindowLabel":      formatWindow(sc.Window),
		"WindowQuery":      windowQuery(sc.Window),
		"Totals":           totals,
		"Codes":            codes,
		"Paths":            paths,
		"Clients":          clients,
		"Section":          "analytics",
	})
}

// normalizeBlockCIDR turns a user-supplied IP or CIDR into a canonical CIDR
// string: a bare IPv4 becomes /32, a bare IPv6 /128. Returns ok=false for junk,
// so a tampered form value can't inject anything into the blocklist (issue #100).
func normalizeBlockCIDR(raw string) (string, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", false
	}
	if _, _, err := net.ParseCIDR(raw); err == nil {
		return raw, true
	}
	ip := net.ParseIP(raw)
	if ip == nil {
		return "", false
	}
	if ip.To4() != nil {
		return raw + "/32", true
	}
	return raw + "/128", true
}

// mergeCIDRList appends add to a comma-separated CIDR list, de-duplicating and
// dropping blanks while preserving order.
func mergeCIDRList(existing, add string) string {
	seen := map[string]bool{}
	var out []string
	// Split on commas, newlines and whitespace so a manually newline-edited
	// blocklist merges cleanly (issue #100 review).
	for _, part := range strings.FieldsFunc(existing, func(r rune) bool {
		return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
	}) {
		if part == "" || seen[part] {
			continue
		}
		seen[part] = true
		out = append(out, part)
	}
	if add != "" && !seen[add] {
		out = append(out, add)
	}
	return strings.Join(out, ",")
}

// safeAnalyticsReturn sanitises the user-supplied "return" value so the block
// action can only redirect back to a same-origin /analytics/ page. It rejects
// absolute URLs, scheme-relative ("//host") URLs, and path-traversal that would
// normalise off-origin (e.g. "/analytics/..//evil.com" → "//evil.com"), which a
// bare strings.HasPrefix check misses.
//
// Every return value is built as the constant "/analytics/" prefix plus a
// validated, traversal-free suffix. The literal path prefix both guarantees a
// same-origin redirect and satisfies CodeQL's URL-concatenation sanitiser for
// go/unvalidated-url-redirection.
func safeAnalyticsReturn(raw, host string) string {
	// Default suffix: the host's own analytics page.
	suffix := url.PathEscape(host)
	if raw != "" {
		if u, err := url.Parse(raw); err == nil && u.Scheme == "" && u.Host == "" {
			cleaned := path.Clean(u.Path)
			if cleaned == "/analytics" || strings.HasPrefix(cleaned, "/analytics/") {
				// Strip the constant prefix; path.Clean already removed any
				// ".." segments, so the remainder cannot escape /analytics/.
				// The query string is intentionally dropped so callers can
				// append their own status param after a literal "?".
				suffix = strings.TrimPrefix(strings.TrimPrefix(cleaned, "/analytics/"), "/analytics")
			}
		}
	}
	return "/analytics/" + suffix
}

// postAnalyticsBlock adds a client IP to a host's blocklist ("host" scope) or
// the fleet-wide blocklist ("global" scope) and re-syncs, from the analytics
// drill-down (issue #100). Admin-only, CSRF-protected by the router middleware.
func (s *Server) postAnalyticsBlock(w http.ResponseWriter, r *http.Request) {
	u := s.currentUser(r)
	if u == nil || u.Role != models.RoleAdmin {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}
	host := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "host")))
	scope := strings.TrimSpace(r.FormValue("scope"))
	serverScopeID := s.analyticsServerScope(r)

	// back is a sanitised, query-free /analytics/ path, so status params are
	// appended after a literal "?" (keeps CodeQL's query-context sanitiser happy).
	back := safeAnalyticsReturn(r.FormValue("return"), host)
	cidr, ok := normalizeBlockCIDR(r.FormValue("ip"))
	if !ok {
		http.Redirect(w, r, back+"?blockerr=1", http.StatusSeeOther)
		return
	}

	syncIDs := map[int64]bool{}
	changed := false
	if scope == "global" {
		cur := mustGetSetting(s.DB, settingGlobalIPBlocklist)
		if err := models.SetSetting(s.DB, settingGlobalIPBlocklist, mergeCIDRList(cur, cidr)); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		changed = true
		// Fleet-wide: sync every non-external server.
		if servers, err := models.ListCaddyServers(s.DB); err == nil {
			for _, sr := range servers {
				if sr.Type != models.CaddyServerTypeExternal {
					syncIDs[sr.ID] = true
				}
			}
		}
	} else {
		// Host scope: update every proxy host AND redirection host serving this
		// domain (both honour IPBlocklist). Iterate per server so we know which
		// server to sync, and so "all servers" scope is covered too.
		var serverIDs []int64
		if serverScopeID > 0 {
			serverIDs = []int64{serverScopeID}
		} else if servers, err := models.ListCaddyServers(s.DB); err == nil {
			for _, sr := range servers {
				serverIDs = append(serverIDs, sr.ID)
			}
		}
		for _, sid := range serverIDs {
			proxies, _ := models.ListProxyHosts(s.DB, sid, u.ID, true, nil)
			for _, ph := range proxies {
				if domainListMatches(ph.DomainList(), host) {
					_ = models.UpdateProxyHostIPBlocklist(s.DB, ph.ID, mergeCIDRList(ph.IPBlocklist, cidr))
					syncIDs[sid] = true
					changed = true
				}
			}
			redirs, _ := models.ListRedirectionHosts(s.DB, sid, u.ID, true, nil)
			for _, rh := range redirs {
				if domainListMatches(rh.DomainList(), host) {
					_ = models.UpdateRedirectionHostIPBlocklist(s.DB, rh.ID, mergeCIDRList(rh.IPBlocklist, cidr))
					syncIDs[sid] = true
					changed = true
				}
			}
		}
	}

	// Don't claim success when nothing matched (e.g. the analytics host has no
	// managed host on the selected server) — tell the admin instead.
	if !changed {
		http.Redirect(w, r, back+"?blockerr=nohost", http.StatusSeeOther)
		return
	}

	_ = models.LogActivity(s.DB, serverScopeID, u.Email, "analytics_block_ip", host, "scope="+scope+" cidr="+cidr, true)
	for sid := range syncIDs {
		if err := s.syncCaddy(sid, false); err != nil {
			log.Printf("analytics block: sync server %d failed (non-fatal): %v", sid, err)
		}
	}
	http.Redirect(w, r, back+"?blocked="+url.QueryEscape(cidr), http.StatusSeeOther)
}

// domainListMatches reports whether host equals any domain in the list,
// case-insensitively and trimmed.
func domainListMatches(domains []string, host string) bool {
	for _, d := range domains {
		if strings.EqualFold(strings.TrimSpace(d), host) {
			return true
		}
	}
	return false
}

// liveVisitorsAcrossHosts counts distinct client IPs on the given host
// list over the last `window`. Used by the non-admin path of getAnalytics
// so an IP visiting two owned hosts is counted once, not twice.
func (s *Server) liveVisitorsAcrossHosts(hosts []string, window time.Duration, serverIDs ...int64) int {
	if len(hosts) == 0 {
		return 0
	}
	// One-shot query with IN(...) — same pattern as HostStatsForHosts.
	placeholders := make([]byte, 0, len(hosts)*2)
	args := []any{time.Now().Add(-window).Unix()}
	for i, h := range hosts {
		if i > 0 {
			placeholders = append(placeholders, ',')
		}
		placeholders = append(placeholders, '?')
		args = append(args, h)
	}
	q := `SELECT COUNT(DISTINCT client_ip) FROM access_events WHERE ts >= ? AND host IN (` + string(placeholders) + `)`
	if len(serverIDs) > 0 && serverIDs[0] > 0 {
		q += ` AND server_id = ?`
		args = append(args, serverIDs[0])
	}
	var n int
	if err := s.DB.QueryRow(q, args...).Scan(&n); err != nil {
		return 0
	}
	return n
}

// fillBuckets takes the sparse bucket slice from models.AccessBuckets and
// returns a dense slice covering every bucketSec window between from and
// to — gaps get zero-valued entries so the chart reads like a continuous
// series. Sorted ascending by bucket start.
func fillBuckets(sparse []models.HourlyBucket, from, to time.Time, bucketSec int64) []models.HourlyBucket {
	if bucketSec <= 0 {
		bucketSec = 3600
	}
	// Snap from/to to bucket boundaries so the first and last entries
	// align with what the SQL groups on.
	start := (from.Unix() / bucketSec) * bucketSec
	end := (to.Unix() / bucketSec) * bucketSec
	byKey := make(map[int64]models.HourlyBucket, len(sparse))
	for _, b := range sparse {
		byKey[(b.Hour.Unix()/bucketSec)*bucketSec] = b
	}
	var out []models.HourlyBucket
	for t := start; t <= end; t += bucketSec {
		if b, ok := byKey[t]; ok {
			out = append(out, b)
			continue
		}
		out = append(out, models.HourlyBucket{Hour: time.Unix(t, 0)})
	}
	// Defensive sort in case models returned out-of-order rows (SQLite
	// GROUP BY without ORDER BY is stable in practice, but be explicit).
	sort.Slice(out, func(i, j int) bool { return out[i].Hour.Before(out[j].Hour) })
	return out
}

// fillBandwidthBuckets is the bandwidth analogue of fillBuckets — fills in
// zero-BytesOut buckets for each expected slot between from and to.
func fillBandwidthBuckets(raw []models.BandwidthBucket, from, to time.Time, bucketSeconds int64) []models.BandwidthBucket {
	if bucketSeconds <= 0 {
		bucketSeconds = 3600
	}
	start := (from.Unix() / bucketSeconds) * bucketSeconds
	end := (to.Unix() / bucketSeconds) * bucketSeconds
	byKey := make(map[int64]int64, len(raw))
	for _, b := range raw {
		byKey[(b.Hour.Unix()/bucketSeconds)*bucketSeconds] = b.BytesOut
	}
	var out []models.BandwidthBucket
	for t := start; t <= end; t += bucketSeconds {
		out = append(out, models.BandwidthBucket{Hour: time.Unix(t, 0), BytesOut: byKey[t]})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Hour.Before(out[j].Hour) })
	return out
}

// formatWindow turns a duration into a label for the page headline. Used
// by both the overview page (7d default, no override) and the per-host
// page (1d / 7d / 30d selectable).
// windowQuery maps a window duration back to the ?window= value used across the
// analytics pages, so drill-down links preserve the selected window.
func windowQuery(d time.Duration) string {
	switch {
	case d <= 24*time.Hour:
		return "1d"
	case d >= 30*24*time.Hour:
		return "30d"
	default:
		return "7d"
	}
}

func formatWindow(d time.Duration) string {
	h := int(d.Hours())
	switch {
	case h <= 1:
		return "last hour"
	case h <= 24:
		return "last 24 hours"
	case h < 24*7:
		return fmt.Sprintf("last %d days", h/24)
	case h < 24*14:
		return "last 7 days"
	case h < 24*60:
		return fmt.Sprintf("last %d days", h/24)
	case h < 24*180:
		return "last 30 days"
	default:
		return fmt.Sprintf("last %d days", h/24)
	}
}

// --- Global search ---

// getSearch handles GET /search — full-text search across proxy hosts,
// redirection hosts, raw routes, and certificates.
func (s *Server) getSearch(w http.ResponseWriter, r *http.Request) {
	u := s.currentUser(r)
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	isAdmin := u != nil && u.Role == models.RoleAdmin

	var proxyHosts []models.ProxyHost
	var redirects []models.RedirectionHost
	var rawRoutes []models.RawRoute
	var certs []models.Certificate

	if q != "" {
		sid := s.currentServerID(r)
		viewerID := int64(0)
		if u != nil && !isAdmin {
			viewerID = u.ID
		}
		var peers []int64
		if u != nil && !isAdmin {
			peers, _ = models.GroupPeerIDs(s.DB, u.ID)
		}
		qLower := strings.ToLower(q)

		// Search proxy hosts by domain or forward host.
		all, _ := models.ListProxyHosts(s.DB, sid, viewerID, isAdmin, peers)
		for _, p := range all {
			if strings.Contains(strings.ToLower(p.Domains), qLower) ||
				strings.Contains(strings.ToLower(p.ForwardHost), qLower) {
				proxyHosts = append(proxyHosts, p)
			}
		}

		// Search redirection hosts.
		allR, _ := models.ListRedirectionHosts(s.DB, sid, viewerID, isAdmin, peers)
		for _, rh := range allR {
			if strings.Contains(strings.ToLower(rh.Domains), qLower) ||
				strings.Contains(strings.ToLower(rh.ForwardDomain), qLower) {
				redirects = append(redirects, rh)
			}
		}

		// Search raw routes by label.
		allRaw, _ := models.ListRawRoutes(s.DB, sid, viewerID, isAdmin, peers)
		for _, rr := range allRaw {
			if strings.Contains(strings.ToLower(rr.Label), qLower) {
				rawRoutes = append(rawRoutes, rr)
			}
		}

		// Search certificates by name or domains.
		allCerts, _ := models.ListCertificatesForUser(s.DB, sid, viewerID, isAdmin, peers)
		for _, c := range allCerts {
			if strings.Contains(strings.ToLower(c.Name), qLower) ||
				strings.Contains(strings.ToLower(c.Domains), qLower) {
				certs = append(certs, c)
			}
		}
	}

	s.render(w, r, "search.html", map[string]any{
		"User":       u,
		"IsAdmin":    isAdmin,
		"Query":      q,
		"ProxyHosts": proxyHosts,
		"Redirects":  redirects,
		"RawRoutes":  rawRoutes,
		"Certs":      certs,
		"Section":    "",
	})
}

// --- Session manager ---

// SessionRow is one row in the sessions table view.
type SessionRow struct {
	Token     string
	UserEmail string
	ExpiresAt time.Time
	IsCurrent bool
}

// getSessions handles GET /sessions.
func (s *Server) getSessions(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

	// Get current token to mark it. Sessions are stored hashed (the raw token
	// lives only in the cookie), so compare against the hash of the cookie.
	currentToken := ""
	if c, err := r.Cookie("caddyui_session"); err == nil && c.Value != "" {
		currentToken = auth.HashSessionToken(c.Value)
	}

	var rows []SessionRow
	var dbRows *sql.Rows
	var err error
	if isAdmin {
		dbRows, err = s.DB.Query(
			`SELECT s.token, u.email, s.expires_at FROM sessions s
			   JOIN users u ON u.id = s.user_id
			  WHERE s.expires_at > ?
			  ORDER BY s.expires_at DESC`, time.Now().UTC())
	} else {
		dbRows, err = s.DB.Query(
			`SELECT token, ?, expires_at FROM sessions
			  WHERE user_id = ? AND expires_at > ?
			  ORDER BY expires_at DESC`, u.Email, u.ID, time.Now().UTC())
	}
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer dbRows.Close()
	for dbRows.Next() {
		var row SessionRow
		if err := dbRows.Scan(&row.Token, &row.UserEmail, &row.ExpiresAt); err != nil {
			continue
		}
		row.IsCurrent = row.Token == currentToken
		rows = append(rows, row)
	}

	s.render(w, r, "sessions.html", map[string]any{
		"User":     u,
		"IsAdmin":  isAdmin,
		"Sessions": rows,
		"Section":  "",
	})
}

// revokeSession handles POST /sessions/{token}/revoke.
func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
	u := s.currentUser(r)
	if u == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}
	token := chi.URLParam(r, "token")
	isAdmin := u.Role == models.RoleAdmin

	if isAdmin {
		s.DB.Exec(`DELETE FROM sessions WHERE token = ?`, token)
	} else {
		// Non-admin can only revoke their own sessions.
		s.DB.Exec(`DELETE FROM sessions WHERE token = ? AND user_id = ?`, token, u.ID)
	}
	http.Redirect(w, r, "/sessions", http.StatusSeeOther)
}

// exportAnalyticsCSV exports access_events as a downloadable CSV file.
// Query params: ?host= (filter to one host), ?days=7 (lookback window, max 90).
func (s *Server) exportAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
	u := s.currentUser(r)
	isAdmin := u != nil && u.Role == models.RoleAdmin

	host := strings.TrimSpace(r.URL.Query().Get("host"))
	days := 7
	if v := r.URL.Query().Get("days"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 90 {
			days = n
		}
	}
	var serverID int64
	if raw := strings.TrimSpace(r.URL.Query().Get("server")); raw != "" && raw != "all" && raw != "0" {
		serverID, _ = strconv.ParseInt(raw, 10, 64)
	}

	// Non-admins: verify they own the requested host.
	if !isAdmin && host != "" {
		allowed, err := s.userAllowedHosts(u)
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		ok := false
		for _, h := range allowed {
			if h == host {
				ok = true
				break
			}
		}
		if !ok {
			http.NotFound(w, r)
			return
		}
	} else if !isAdmin && host == "" {
		// Non-admin without host filter: require a host param for non-admins.
		http.Error(w, "specify ?host= for non-admin export", http.StatusForbidden)
		return
	}

	since := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
	rows, err := models.ExportAccessEvents(s.DB, since, host, 50000, serverID)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}

	fname := "analytics"
	if host != "" {
		fname = strings.ReplaceAll(host, ".", "_")
	}
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s_%dd.csv"`, fname, days))

	cw := csv.NewWriter(w)
	_ = cw.Write([]string{"timestamp", "server_id", "server_name", "host", "path", "method", "status", "client_ip", "user_agent", "duration_ms", "bytes_out"})
	for _, e := range rows {
		_ = cw.Write([]string{
			e.TS.UTC().Format(time.RFC3339),
			strconv.FormatInt(e.ServerID, 10), csvSafe(e.ServerName),
			csvSafe(e.Host), csvSafe(e.Path), csvSafe(e.Method),
			strconv.Itoa(e.Status),
			csvSafe(e.ClientIP), csvSafe(e.UserAgent),
			strconv.FormatInt(e.DurationMs, 10),
			strconv.FormatInt(e.BytesOut, 10),
		})
	}
	cw.Flush()
}