Files
fathom/internal/web/insights.go
zzg 47fbe583dc Make the Insights tab respect the selected time range
The Insights tab ignored the time-range picker in the upper right: clicking
'15 minutes' still showed findings from the analyzer's fixed background
window (WindowDays, e.g. 7 days), because /api/insights always returned the
cached background result regardless of what range the UI requested.

- internal/insights/analyzer.go: split Refresh()'s logic into a shared
  analyze(from, to) core, and add a public AnalyzeRange(from, to) that runs
  the detectors on demand for an arbitrary window without disturbing the
  cached background result used by Latest() and the Stop-hook/surfacing
  path.
- internal/web/insights.go: handleInsights now honors optional from/to query
  params, calling AnalyzeRange for that window instead of Latest() when
  present.
- internal/web/static/app.js: loadInsights() sends the current
  state.rangeMicros as from/to, and the window label now renders
  minutes/hours/days appropriately (windowLabel()) instead of always
  rounding up to whole days, so a 15-minute selection reads as '15 minutes'
  rather than '1 day'.
2026-09-19 21:15:47 -07:00

138 lines
4.2 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"git.z.gd/zzg/fathom/internal/insights"
)
// The insights API.
//
// The analyzer runs on its own schedule in the background; these handlers only
// read what it last produced. A request never triggers analysis, so opening the
// tab cannot make the server do work proportional to the store.
func (s *Server) handleInsights(w http.ResponseWriter, r *http.Request) {
if s.insights == nil {
writeJSON(w, map[string]any{"ready": false, "disabled": true})
return
}
// An explicit range — from the UI's time-range picker — gets its own
// on-demand analysis rather than the background result, so the tab actually
// reflects the range someone clicked instead of a fixed lookback window.
if fromStr, toStr := r.URL.Query().Get("from"), r.URL.Query().Get("to"); fromStr != "" && toStr != "" {
from, err1 := strconv.ParseInt(fromStr, 10, 64)
to, err2 := strconv.ParseInt(toStr, 10, 64)
if err1 != nil || err2 != nil {
httpError(w, http.StatusBadRequest, "from/to must be unix micros")
return
}
res, err := s.insights.AnalyzeRange(from, to)
if err != nil {
httpError(w, http.StatusInternalServerError, "%v", err)
return
}
writeJSON(w, map[string]any{"ready": true, "result": res})
return
}
res, ready := s.insights.Latest()
if !ready {
// Distinguish "nothing found" from "not looked yet", or an empty tab on
// first load reads as a claim that there is nothing to find.
writeJSON(w, map[string]any{"ready": false})
return
}
writeJSON(w, map[string]any{"ready": true, "result": res})
}
func (s *Server) handleDismissInsight(w http.ResponseWriter, r *http.Request) {
if s.insights == nil {
httpError(w, http.StatusNotFound, "insights are not enabled")
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil || req.ID == "" {
httpError(w, http.StatusBadRequest, "no insight id given")
return
}
s.insights.Dismiss(req.ID)
res, _ := s.insights.Latest()
writeJSON(w, map[string]any{"ready": true, "result": res})
}
func (s *Server) handleRestoreInsights(w http.ResponseWriter, r *http.Request) {
if s.insights == nil {
httpError(w, http.StatusNotFound, "insights are not enabled")
return
}
s.insights.Restore()
res, _ := s.insights.Latest()
writeJSON(w, map[string]any{"ready": true, "result": res})
}
func (s *Server) handleLocalToggle(w http.ResponseWriter, r *http.Request) {
if s.insights == nil {
httpError(w, http.StatusNotFound, "insights are not enabled")
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "expected {\"enabled\": true|false}")
return
}
s.insights.SetLocalEnabled(req.Enabled)
res, _ := s.insights.Latest()
writeJSON(w, map[string]any{"ready": true, "result": res})
}
// handleSurface returns findings not yet reported and marks them as reported,
// in one call.
//
// It is a POST because it has a side effect, and one call rather than two
// because two sessions can end at the same moment: a read-then-write would have
// both report the same finding.
func (s *Server) handleSurface(w http.ResponseWriter, r *http.Request) {
if s.insights == nil {
writeJSON(w, map[string]any{"suppressed": "insights are not enabled"})
return
}
var req struct {
MinSeverity string `json:"min_severity"`
MinCostUSD float64 `json:"min_cost_usd"`
CooldownSecs int `json:"cooldown_secs"`
}
// A body is optional; the defaults are the point.
json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req)
opts := insights.DefaultSurfaceOptions()
if req.MinSeverity != "" {
opts.MinSeverity = insights.Severity(req.MinSeverity)
}
if req.MinCostUSD > 0 {
opts.MinCostUSD = req.MinCostUSD
}
if req.CooldownSecs > 0 {
opts.Cooldown = time.Duration(req.CooldownSecs) * time.Second
}
writeJSON(w, s.insights.SurfaceNew(opts))
}
func (s *Server) handleResetSurfaced(w http.ResponseWriter, r *http.Request) {
if s.insights == nil {
httpError(w, http.StatusNotFound, "insights are not enabled")
return
}
s.insights.ResetSurfaced()
writeJSON(w, map[string]any{"ok": true})
}