Files
fathom/internal/web/insights.go
zzgandClaude Opus 5 79fe343be5 Move the module path to git.z.gd/zzg/fathom
The module was declared as github.com/zzg/fathom, which is not where
this repo lives and never has been. Nothing is published at that path,
so `go get github.com/zzg/fathom` resolves to a 404 while the import
paths in every file assert otherwise.

git.z.gd is a real module path rather than a cosmetic correction:
Gitea serves the go-import meta tag for it,

  <meta name="go-import" content="git.z.gd/zzg/fathom git https://git.z.gd/zzg/fathom.git">

and the repo answers unauthenticated over HTTPS, so `go get
git.z.gd/zzg/fathom` resolves where the old path could not.

Mechanical: the module line plus 46 import paths across 23 files. There
is no go.sum to churn, since fathom has no third-party dependencies.
go build, go vet, gofmt and the full test suite are clean under the new
path, and the built binary still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:31:18 -07:00

117 lines
3.4 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"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
}
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})
}