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'.
138 lines
4.2 KiB
Go
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})
|
|
}
|