package web import ( "context" "encoding/json" "fmt" "html/template" "net/http" "os" "sort" "time" "github.com/Yatsuiii/llmtrace/internal/agent" "github.com/Yatsuiii/llmtrace/internal/detect" "github.com/Yatsuiii/llmtrace/internal/ingest" "github.com/Yatsuiii/llmtrace/internal/proxy" "github.com/Yatsuiii/llmtrace/internal/storage" "github.com/Yatsuiii/llmtrace/internal/watcher" ) func Serve(ctx context.Context, db *storage.DB, port int, w *watcher.Watcher) error { mux := http.NewServeMux() mux.HandleFunc("/", dashboardHandler(db, w)) mux.HandleFunc("/investigate", investigateHandler(db)) mux.HandleFunc("/chat", chatHandler(db)) mux.HandleFunc("/vision", visionHandler(db)) mux.HandleFunc("/events", eventsHandler(w)) mux.HandleFunc("/ingest/call", ingest.CallHandler(db)) mux.HandleFunc("/ingest/deploy", ingest.DeployHandler(db)) mux.HandleFunc("/v1/messages", proxy.Handler(db)) addr := fmt.Sprintf(":%d", port) fmt.Printf("llmtrace dashboard → http://localhost%s\n", addr) srv := &http.Server{Addr: addr, Handler: mux} go func() { <-ctx.Done() srv.Shutdown(context.Background()) }() return srv.ListenAndServe() } func dashboardHandler(db *storage.DB, w *watcher.Watcher) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() since := time.Now().UTC().AddDate(0, 0, -30) if _, err := detect.Run(ctx, db, detect.DefaultConfig(), since); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } anomalies, err := db.ListAnomalies(ctx, since) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } daily, err := db.DailyCostByKey(ctx, since) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } deploys, err := db.ListDeploys(ctx, since) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } actions, err := db.ListAgentActions(ctx, since) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } chartJSON, err := buildChartJSON(daily, deploys) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } var watcherStatus watcherStatusData if w != nil { watcherStatus.Active = true watcherStatus.LastScan = formatTime(w.LastScan) watcherStatus.NextScan = formatTime(w.NextScan) watcherStatus.ScanCount = w.ScanCount } var projection projectionData if len(anomalies) > 0 { projection = buildProjection(daily, anomalies[0].APIKeyID) } data := dashboardData{ Anomalies: anomalies, AgentActions: actions, Now: time.Now().UTC().Format("2006-01-02 15:04 UTC"), ChartDataJSON: template.JS(chartJSON), Watcher: watcherStatus, Projection: projection, } rw.Header().Set("Content-Type", "text/html; charset=utf-8") if err := dashboardTmpl.Execute(rw, data); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) } } } func eventsHandler(w *watcher.Watcher) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { if w == nil { http.Error(rw, "autonomous mode not enabled", http.StatusServiceUnavailable) return } rw.Header().Set("Content-Type", "text/event-stream") rw.Header().Set("Cache-Control", "no-cache") rw.Header().Set("X-Accel-Buffering", "no") flusher := rw.(http.Flusher) ch := w.Subscribe() defer w.Unsubscribe(ch) fmt.Fprintf(rw, "data: [connected to autonomous watcher]\n\n") flusher.Flush() for { select { case <-r.Context().Done(): return case msg := <-ch: fmt.Fprintf(rw, "data: %s\n\n", msg) flusher.Flush() } } } } func chatHandler(db *storage.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") if q == "" { http.Error(w, "missing q", http.StatusBadRequest) return } apiKey := os.Getenv("GEMINI_API_KEY") if apiKey == "" { http.Error(w, "GEMINI_API_KEY not set", http.StatusServiceUnavailable) return } ctx := r.Context() inv, err := agent.New(db, apiKey, os.Getenv("GEMINI_MODEL")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") flusher := w.(http.Flusher) emit := func(msg string) { fmt.Fprintf(w, "data: %s\n\n", msg) flusher.Flush() } var note string if anomalies, err := db.ListAnomalies(ctx, time.Now().UTC().AddDate(0, 0, -30)); err == nil && len(anomalies) > 0 { note = fmt.Sprintf("%d spend anomaly(ies) detected in the last 30 days; the largest is on key %s, date %s.", len(anomalies), anomalies[0].APIKeyID, anomalies[0].Date) } if err := inv.Chat(ctx, q, note, emit); err != nil { emit(fmt.Sprintf("[error] %v", err)) } emit("[[END]]") } } type watcherStatusData struct { Active bool LastScan string NextScan string ScanCount int } type dashboardData struct { Anomalies []storage.AnomalyRow AgentActions []storage.AgentActionRow Now string ChartDataJSON template.JS Watcher watcherStatusData Projection projectionData } type projectionData struct { Show bool Key string MonthlyNow float64 MonthlyBase float64 Overspend float64 } // buildProjection estimates monthly run-rate for a key from its daily series: // baseline = mean of the earliest 7 days, current = mean of the latest 7 days. func buildProjection(daily []storage.KeyDailyCost, key string) projectionData { var series []float64 dates := map[string]float64{} for _, d := range daily { if d.APIKeyID == key { dates[d.Date] = d.CostUSD } } var keys []string for d := range dates { keys = append(keys, d) } sort.Strings(keys) for _, d := range keys { series = append(series, dates[d]) } if len(series) < 14 { return projectionData{} } mean := func(xs []float64) float64 { var s float64 for _, x := range xs { s += x } return s / float64(len(xs)) } base := mean(series[:7]) now := mean(series[len(series)-7:]) p := projectionData{ Key: key, MonthlyNow: now * 30, MonthlyBase: base * 30, Overspend: (now - base) * 30, } p.Show = p.Overspend > 1 return p } func formatTime(t time.Time) string { if t.IsZero() { return "—" } return t.Format("15:04:05 UTC") } var keyColors = map[string]string{ "prod-frontend": "#f87171", "internal-tools": "#475569", "background-jobs": "#334155", } func buildChartJSON(daily []storage.KeyDailyCost, deploys []storage.DeployRow) (string, error) { dateSet := map[string]struct{}{} keyData := map[string]map[string]float64{} for _, d := range daily { dateSet[d.Date] = struct{}{} if keyData[d.APIKeyID] == nil { keyData[d.APIKeyID] = map[string]float64{} } keyData[d.APIKeyID][d.Date] = d.CostUSD } var labels []string for d := range dateSet { labels = append(labels, d) } sort.Strings(labels) var keys []string for k := range keyData { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { if keys[i] == "prod-frontend" { return true } if keys[j] == "prod-frontend" { return false } return keys[i] < keys[j] }) type dataset struct { Label string `json:"label"` Data []float64 `json:"data"` BorderColor string `json:"borderColor"` BackgroundColor string `json:"backgroundColor"` BorderWidth int `json:"borderWidth"` PointRadius int `json:"pointRadius"` Tension float64 `json:"tension"` } var datasets []dataset for _, k := range keys { color, ok := keyColors[k] if !ok { color = "#64748b" } var pts []float64 for _, lbl := range labels { pts = append(pts, keyData[k][lbl]) } datasets = append(datasets, dataset{ Label: k, Data: pts, BorderColor: color, BackgroundColor: color + "22", BorderWidth: 2, PointRadius: 0, Tension: 0.3, }) } type annotationLine struct { Type string `json:"type"` XMin string `json:"xMin"` XMax string `json:"xMax"` BorderColor string `json:"borderColor"` BorderWidth int `json:"borderWidth"` BorderDash []int `json:"borderDash"` Label struct { Display bool `json:"display"` Content string `json:"content"` BackgroundColor string `json:"backgroundColor"` Color string `json:"color"` Font struct { Size int `json:"size"` } `json:"font"` Position string `json:"position"` } `json:"label"` } // One marker per deploy day — multiple deploys on a day collapse to a count. deploysByDate := map[string]int{} for _, d := range deploys { deploysByDate[d.StartedAt.Format("2006-01-02")]++ } annotations := map[string]annotationLine{} i := 0 for date, n := range deploysByDate { var a annotationLine a.Type = "line" a.XMin = date a.XMax = date a.BorderColor = "#fbbf24" a.BorderWidth = 2 a.BorderDash = []int{6, 4} a.Label.Display = true if n == 1 { a.Label.Content = "deploy" } else { a.Label.Content = fmt.Sprintf("%d deploys", n) } a.Label.BackgroundColor = "#fbbf2422" a.Label.Color = "#fbbf24" a.Label.Font.Size = 11 a.Label.Position = "start" annotations[fmt.Sprintf("deploy%d", i)] = a i++ } out, err := json.Marshal(map[string]any{ "labels": labels, "datasets": datasets, "annotations": annotations, }) return string(out), err } var dashboardTmpl = template.Must(template.New("dashboard").Parse(`
| Time | Action | Anomaly | Status | Result |
|---|---|---|---|---|
| {{.CreatedAt.Format "01-02 15:04"}} | {{.ActionType}} |
#{{.AnomalyID}} | {{.Status}} | {{.Result}} |