Files
fathom/internal/web/ui.go
zzg 9bc48467a4 Add the HTTP server, embedded UI, config, and CLI
Three views: a trace list, the trace detail waterfall with its correlated
transcript, and an aggregate query explorer. Vanilla JS in a go:embed'd
directory -- no bundler, no npm, and no way for the API and UI to drift.

Several fixes came directly from screenshotting the running app in Chrome:

- model.Span had no JSON tags, so Go marshalled SpanID while the client read
  span_id. The inspector sat on "Loading..." forever. Tagged the model and
  made Kind/Status marshal as names, so the client keeps no parallel enum.
- replaceChildren stringifies null rather than skipping it, rendering a
  literal "null" in the trace header.
- Cost was empty for every trace: it is reported on the api_request LOG
  record, never on a span, and the list scanned only spans.
- Every tool call rendered three times, because Claude Code emits tool,
  tool.execution, and tool.blocked_on_user for one logical call. Wrapper
  spans that restate their parent are now folded, with a count on the parent.
- The list showed "claude_code.interaction" on all 40 rows while the prompt
  that distinguishes runs sat in a subtitle. The prompt now leads.
- A keyless log record was placed by timestamp containment, filing the user's
  prompt under whichever model call started at the same microsecond. It now
  prefers its own span_id, which for keyless records is the right signal.
- The Explore chart plotted the first numeric column, so a "slowest tools"
  query rendered call counts. Results now carry the ORDER BY measure.
- An unnamed category (the wrapper spans) crushed every named bar; unnamed
  rows are left to the table with a note.
2026-09-15 21:55:49 -07:00

37 lines
1.0 KiB
Go

package web
import (
"embed"
"io/fs"
"net/http"
)
// The UI ships inside the binary. No bundler, no npm, no separate frontend
// deploy, and no way for the API and the UI to drift out of sync.
//
//go:embed static
var staticFS embed.FS
func uiHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
// Only reachable if the embed directive and the directory disagree,
// which is a build-time mistake rather than a runtime condition.
panic(err)
}
fileServer := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The UI is a single page with client-side routing, so any path that
// is not a real file serves index.html and lets the app route it. That
// is what makes a deep link to a trace survive a page reload.
if r.URL.Path != "/" {
if _, err := fs.Stat(sub, r.URL.Path[1:]); err != nil {
r = r.Clone(r.Context())
r.URL.Path = "/"
}
}
w.Header().Set("Cache-Control", "no-cache")
fileServer.ServeHTTP(w, r)
})
}