Add Claude integration to dashboard
Add comprehensive Claude Code monitoring and realtime streaming to the K8s dashboard. Includes API endpoints for health, stats, summary, inventory, and live event streaming. Frontend provides overview, usage, inventory, debug, and live feed views.
This commit is contained in:
162
dashboard/internal/api/claude_handlers.go
Normal file
162
dashboard/internal/api/claude_handlers.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
// ClaudeLoader is a minimal interface for Claude Ops endpoints.
|
||||
//
|
||||
// Keep it small so handlers are easy to test with fakes.
|
||||
type ClaudeLoader interface {
|
||||
ClaudeDir() string
|
||||
LoadStatsCache() (*claude.StatsCache, error)
|
||||
ListDir(name string) ([]claude.DirEntry, error)
|
||||
FileMeta(relPath string) (claude.FileMeta, error)
|
||||
PathExists(relPath string) bool
|
||||
}
|
||||
|
||||
func GetClaudeStats(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
type ClaudeSummaryResponse struct {
|
||||
Totals ClaudeSummaryTotals `json:"totals"`
|
||||
PerModel map[string]claude.ModelUsage `json:"perModel"`
|
||||
Derived ClaudeSummaryDerived `json:"derived"`
|
||||
}
|
||||
|
||||
type ClaudeSummaryTotals struct {
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMessages int `json:"totalMessages"`
|
||||
}
|
||||
|
||||
type ClaudeSummaryDerived struct {
|
||||
CacheHitRatioEstimate float64 `json:"cacheHitRatioEstimate"`
|
||||
TopModelByOutputTokens string `json:"topModelByOutputTokens"`
|
||||
}
|
||||
|
||||
func GetClaudeSummary(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := ClaudeSummaryResponse{
|
||||
Totals: ClaudeSummaryTotals{
|
||||
TotalSessions: stats.TotalSessions,
|
||||
TotalMessages: stats.TotalMessages,
|
||||
},
|
||||
PerModel: stats.ModelUsage,
|
||||
}
|
||||
|
||||
var inputTokens, cacheRead, cacheCreate int
|
||||
maxOut := -1
|
||||
topModel := ""
|
||||
for model, usage := range stats.ModelUsage {
|
||||
inputTokens += usage.InputTokens
|
||||
cacheRead += usage.CacheReadInputTokens
|
||||
cacheCreate += usage.CacheCreationInputTokens
|
||||
if usage.OutputTokens > maxOut {
|
||||
maxOut = usage.OutputTokens
|
||||
topModel = model
|
||||
}
|
||||
}
|
||||
|
||||
den := float64(inputTokens + cacheRead + cacheCreate)
|
||||
ratio := 0.0
|
||||
if den > 0 {
|
||||
ratio = float64(cacheRead) / den
|
||||
}
|
||||
|
||||
resp.Derived = ClaudeSummaryDerived{
|
||||
CacheHitRatioEstimate: ratio,
|
||||
TopModelByOutputTokens: topModel,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeHealth(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]bool{}
|
||||
missing := false
|
||||
for _, rel := range []string{"stats-cache.json", "history.jsonl", filepath.Join("state", "component-registry.json")} {
|
||||
exists := loader.PathExists(rel)
|
||||
checks[rel] = exists
|
||||
if !exists {
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if missing {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status,
|
||||
"claudeDir": loader.ClaudeDir(),
|
||||
"fileChecks": checks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeInventory(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := loader.ListDir("agents")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
skills, err := loader.ListDir("skills")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
commands, err := loader.ListDir("commands")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"agents": agents,
|
||||
"skills": skills,
|
||||
"commands": commands,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeDebugFiles(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var metas []claude.FileMeta
|
||||
for _, rel := range []string{
|
||||
"stats-cache.json",
|
||||
"history.jsonl",
|
||||
filepath.Join("state", "component-registry.json"),
|
||||
} {
|
||||
meta, err := loader.FileMeta(rel)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"files": metas,
|
||||
})
|
||||
}
|
||||
}
|
||||
110
dashboard/internal/api/claude_handlers_test.go
Normal file
110
dashboard/internal/api/claude_handlers_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeLoader struct{}
|
||||
|
||||
func (f fakeLoader) ClaudeDir() string { return "/tmp/claude" }
|
||||
|
||||
func (f fakeLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{TotalSessions: 3}, nil
|
||||
}
|
||||
|
||||
func (f fakeLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
|
||||
func (f fakeLoader) FileMeta(relPath string) (claude.FileMeta, error) { return claude.FileMeta{}, nil }
|
||||
|
||||
func (f fakeLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestGetClaudeStats(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stats", GetClaudeStats(fakeLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClaudeSummary_IncludesDerivedCostSignals(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/summary", GetClaudeSummary(fakeSummaryLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Smoke-check that derived fields exist.
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"cacheHitRatioEstimate", "topModelByOutputTokens"} {
|
||||
if !jsonContainsKey(body, want) {
|
||||
t.Fatalf("expected response to include key %s, body=%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSummaryLoader struct{ fakeLoader }
|
||||
|
||||
func (f fakeSummaryLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{
|
||||
TotalSessions: 3,
|
||||
TotalMessages: 10,
|
||||
ModelUsage: map[string]claude.ModelUsage{
|
||||
"claude-3-5-sonnet": {
|
||||
InputTokens: 100,
|
||||
OutputTokens: 250,
|
||||
CacheReadInputTokens: 50,
|
||||
CacheCreationInputTokens: 25,
|
||||
},
|
||||
"claude-3-5-haiku": {
|
||||
InputTokens: 80,
|
||||
OutputTokens: 300,
|
||||
CacheReadInputTokens: 20,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonContainsKey(body, key string) bool {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
return false
|
||||
}
|
||||
return mapContainsKey(m, key)
|
||||
}
|
||||
|
||||
func mapContainsKey(v any, key string) bool {
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
if _, ok := vv[key]; ok {
|
||||
return true
|
||||
}
|
||||
for _, child := range vv {
|
||||
if mapContainsKey(child, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range vv {
|
||||
if mapContainsKey(child, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
79
dashboard/internal/api/claude_live_handlers.go
Normal file
79
dashboard/internal/api/claude_live_handlers.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type BacklogResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
Events []claude.Event `json:"events"`
|
||||
}
|
||||
|
||||
func GetClaudeLiveBacklog(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 200
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
historyPath := filepath.Join(loader.ClaudeDir(), "history.jsonl")
|
||||
lines, err := claude.TailLastNLines(historyPath, limit)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
events := make([]claude.Event, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
ev := parseHistoryLine(line)
|
||||
events = append(events, ev)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, BacklogResponse{
|
||||
Limit: limit,
|
||||
Events: events,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseHistoryLine(line string) claude.Event {
|
||||
data := map[string]any{
|
||||
"rawLine": line,
|
||||
}
|
||||
|
||||
var jsonData map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &jsonData); err != nil {
|
||||
data["parseError"] = err.Error()
|
||||
} else {
|
||||
data["json"] = jsonData
|
||||
|
||||
summary := map[string]string{}
|
||||
if v, ok := jsonData["sessionId"].(string); ok {
|
||||
summary["sessionId"] = v
|
||||
}
|
||||
if v, ok := jsonData["project"].(string); ok {
|
||||
summary["project"] = v
|
||||
}
|
||||
if v, ok := jsonData["display"].(string); ok {
|
||||
summary["display"] = v
|
||||
}
|
||||
data["summary"] = summary
|
||||
}
|
||||
|
||||
return claude.Event{
|
||||
TS: time.Now(),
|
||||
Type: claude.EventTypeHistoryAppend,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
48
dashboard/internal/api/claude_live_handlers_test.go
Normal file
48
dashboard/internal/api/claude_live_handlers_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeClaudeDirLoader struct{ dir string }
|
||||
|
||||
func (f fakeClaudeDirLoader) ClaudeDir() string { return f.dir }
|
||||
func (f fakeClaudeDirLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{}, nil
|
||||
}
|
||||
func (f fakeClaudeDirLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
func (f fakeClaudeDirLoader) FileMeta(relPath string) (claude.FileMeta, error) {
|
||||
return claude.FileMeta{}, nil
|
||||
}
|
||||
func (f fakeClaudeDirLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestClaudeLiveBacklog_DefaultLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/model\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
loader := fakeClaudeDirLoader{dir: dir}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/live/backlog", GetClaudeLiveBacklog(loader))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/live/backlog", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !jsonContainsKey(w.Body.String(), "events") {
|
||||
t.Fatalf("expected events in response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
89
dashboard/internal/api/claude_routes_smoke_test.go
Normal file
89
dashboard/internal/api/claude_routes_smoke_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
// Integration-style smoke test for the Claude endpoints.
|
||||
//
|
||||
// This does NOT start a server process; it wires chi routes directly and calls
|
||||
// them via httptest.
|
||||
func TestClaudeRoutes_Smoke(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Minimal filesystem layout expected by endpoints.
|
||||
mustMkdirAll(t, filepath.Join(tmp, "agents"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "skills"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "commands"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "state"))
|
||||
|
||||
// Minimal stats-cache.json required by /stats, /summary, /debug/files.
|
||||
// Keep it tiny and deterministic.
|
||||
statsCache := `{
|
||||
"totalSessions": 1,
|
||||
"totalMessages": 1,
|
||||
"modelUsage": {
|
||||
"claude-test": {
|
||||
"inputTokens": 1,
|
||||
"outputTokens": 1,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(tmp, "stats-cache.json"), []byte(statsCache), 0o600); err != nil {
|
||||
t.Fatalf("write stats-cache.json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "history.jsonl"), []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write history.jsonl: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "state", "component-registry.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write state/component-registry.json: %v", err)
|
||||
}
|
||||
|
||||
loader := claude.NewLoader(tmp)
|
||||
|
||||
r := chi.NewRouter()
|
||||
// Mirror the /api/claude routes from cmd/server/main.go.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Route("/claude", func(r chi.Router) {
|
||||
r.Get("/health", GetClaudeHealth(loader))
|
||||
r.Get("/stats", GetClaudeStats(loader))
|
||||
r.Get("/summary", GetClaudeSummary(loader))
|
||||
r.Get("/inventory", GetClaudeInventory(loader))
|
||||
r.Get("/debug/files", GetClaudeDebugFiles(loader))
|
||||
})
|
||||
})
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/claude/health",
|
||||
"/api/claude/stats",
|
||||
"/api/claude/inventory",
|
||||
"/api/claude/debug/files",
|
||||
"/api/claude/summary",
|
||||
} {
|
||||
path := path
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status=%d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
51
dashboard/internal/api/claude_stream_handlers.go
Normal file
51
dashboard/internal/api/claude_stream_handlers.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func GetClaudeStream(hub *claude.EventHub) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "event: %s\n", ev.Type)
|
||||
fmt.Fprintf(w, "id: %d\n", ev.ID)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
|
||||
case <-notify:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
dashboard/internal/api/claude_stream_handlers_test.go
Normal file
40
dashboard/internal/api/claude_stream_handlers_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func TestClaudeStream_SendsEvent(t *testing.T) {
|
||||
hub := claude.NewEventHub(10)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stream", GetClaudeStream(hub))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stream", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
hub.Publish(claude.Event{Type: claude.EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
}()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type=%q", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "event:") || !strings.Contains(w.Body.String(), "data:") {
|
||||
t.Fatalf("body=%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user