diff --git a/dashboard/README.md b/dashboard/README.md index e940de0..bceabad 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -26,7 +26,7 @@ Optimized for Raspberry Pi 3B+ (1GB RAM): ```bash # Run locally -go run ./cmd/server --port 8080 --data ./data +go run ./cmd/server --port 8080 --data ./data --claude ~/.claude # Build binary go build -o server ./cmd/server @@ -72,6 +72,7 @@ kubectl apply -k deploy/ |------|---------|-------------| | --port | 8080 | Server port | | --data | /data | Data directory for persistent state | +| --claude | ~/.claude | Claude Code directory | ## API Endpoints diff --git a/dashboard/cmd/server/config_test.go b/dashboard/cmd/server/config_test.go new file mode 100644 index 0000000..ac26cfe --- /dev/null +++ b/dashboard/cmd/server/config_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultClaudeDir(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir: %v", err) + } + want := filepath.Join(home, ".claude") + got := defaultClaudeDir() + if got != want { + t.Fatalf("defaultClaudeDir() = %q, want %q", got, want) + } +} diff --git a/dashboard/cmd/server/main.go b/dashboard/cmd/server/main.go index f52cb05..d0e11f1 100644 --- a/dashboard/cmd/server/main.go +++ b/dashboard/cmd/server/main.go @@ -7,20 +7,31 @@ import ( "log" "net/http" "os" + "path/filepath" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/will/k8s-agent-dashboard/internal/api" + "github.com/will/k8s-agent-dashboard/internal/claude" "github.com/will/k8s-agent-dashboard/internal/store" ) //go:embed all:web var webFS embed.FS +func defaultClaudeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "/home/will/.claude" // fallback; best-effort + } + return filepath.Join(home, ".claude") +} + func main() { port := flag.String("port", "8080", "Server port") dataDir := flag.String("data", "/data", "Data directory for state") + claudeDir := flag.String("claude", defaultClaudeDir(), "Claude Code directory") flag.Parse() // Initialize store @@ -29,6 +40,12 @@ func main() { log.Fatalf("Failed to initialize store: %v", err) } + // Initialize Claude loader + claudeLoader := claude.NewLoader(*claudeDir) + + // Initialize event hub + hub := claude.NewEventHub(1000) + // Create router r := chi.NewRouter() @@ -48,6 +65,16 @@ func main() { // API routes r.Route("/api", func(r chi.Router) { r.Get("/health", api.HealthCheck) + + r.Route("/claude", func(r chi.Router) { + r.Get("/health", api.GetClaudeHealth(claudeLoader)) + r.Get("/stats", api.GetClaudeStats(claudeLoader)) + r.Get("/summary", api.GetClaudeSummary(claudeLoader)) + r.Get("/inventory", api.GetClaudeInventory(claudeLoader)) + r.Get("/debug/files", api.GetClaudeDebugFiles(claudeLoader)) + r.Get("/live/backlog", api.GetClaudeLiveBacklog(claudeLoader)) + r.Get("/stream", api.GetClaudeStream(hub)) + }) r.Get("/status", api.GetClusterStatus(s)) r.Get("/pending", api.GetPendingActions(s)) r.Post("/pending/{id}/approve", api.ApproveAction(s)) @@ -78,6 +105,10 @@ func main() { log.Printf("Starting server on %s", addr) log.Printf("Data directory: %s", *dataDir) + log.Printf("Claude directory: %s", *claudeDir) + + stop := make(chan struct{}) + go claude.TailHistoryFile(stop, hub, filepath.Join(*claudeDir, "history.jsonl")) if err := http.ListenAndServe(addr, r); err != nil { log.Fatalf("Server failed: %v", err) diff --git a/dashboard/cmd/server/web/index.html b/dashboard/cmd/server/web/index.html index 7f29293..c20d54a 100644 --- a/dashboard/cmd/server/web/index.html +++ b/dashboard/cmd/server/web/index.html @@ -16,6 +16,14 @@
+ +
+
+

Overview

+
+

Loading Claude overview...

+
+
+
+ + +
+
+

Usage

+ + + + + + + + + + + + +
DateSessionsMessagesTool Calls
Loading usage...
+
+
+ + +
+
+

Inventory

+
+

Loading inventory...

+
+
+
+ + +
+
+

Debug

+ + + + + + + + + + + + +
FileStatusMTimeError
Loading debug info...
+
+
+ + +
+
+

Live Feed

+
+ Connecting... +
+ + + + + + + + + + + + +
TimeTypeSummaryDetails
Waiting for events...
+
+
+
diff --git a/dashboard/cmd/server/web/static/css/style.css b/dashboard/cmd/server/web/static/css/style.css index 224b441..61a04b3 100644 --- a/dashboard/cmd/server/web/static/css/style.css +++ b/dashboard/cmd/server/web/static/css/style.css @@ -63,6 +63,7 @@ nav { background: var(--bg-secondary); padding: 0.5rem 2rem; display: flex; + flex-wrap: wrap; gap: 0.5rem; border-bottom: 1px solid var(--bg-card); } @@ -178,6 +179,47 @@ td { color: var(--success); } +/* Claude dashboard extra badges */ +.status-ok { + background: rgba(74, 222, 128, 0.2); + color: var(--success); +} + +.status-missing { + background: rgba(239, 68, 68, 0.2); + color: var(--danger); +} + +.simple-list { + margin-left: 1.25rem; +} + +.simple-list li { + margin: 0.25rem 0; +} + +.inventory-section + .inventory-section { + margin-top: 1.25rem; +} + +.metric { + font-size: 2rem; + font-weight: 700; + margin-top: 0.25rem; +} + +/* Grid helper for Claude overview (keeps markup minimal) */ +.grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +/* In some contexts we want a grid inside a card; remove card bottom margin in that case */ +.grid .card { + margin-bottom: 0; +} + .alerts-list, .pending-list, .workflows-list { display: flex; flex-direction: column; @@ -328,6 +370,59 @@ footer { .progress-bar .fill.warning { background: var(--warning); } .progress-bar .fill.danger { background: var(--danger); } +/* Live feed styles */ +.live-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.conn-badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; +} + +.conn-badge-connected { + background: rgba(74, 222, 128, 0.2); + color: var(--success); +} + +.conn-badge-error { + background: rgba(239, 68, 68, 0.2); + color: var(--danger); +} + +.conn-badge-yellow { + background: rgba(251, 191, 36, 0.2); + color: var(--warning); +} + +.raw-json { + background: var(--bg-secondary); + padding: 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + overflow-x: auto; + margin-top: 0.5rem; +} + +.btn-sm { + padding: 0.25rem 0.5rem; + border: 1px solid var(--bg-secondary); + background: transparent; + color: var(--text-primary); + border-radius: 4px; + cursor: pointer; + font-size: 0.75rem; +} + +.btn-sm:hover { + background: var(--bg-secondary); +} + /* Responsive */ @media (max-width: 768px) { header { diff --git a/dashboard/cmd/server/web/static/js/app.js b/dashboard/cmd/server/web/static/js/app.js index 0e22d5d..f289c98 100644 --- a/dashboard/cmd/server/web/static/js/app.js +++ b/dashboard/cmd/server/web/static/js/app.js @@ -5,12 +5,34 @@ const API_BASE = '/api'; // State let currentView = 'status'; +// Live feed state +let pendingLiveEvents = []; +let liveEvents = []; +let liveEventSource = null; + // Initialize document.addEventListener('DOMContentLoaded', () => { setupNavigation(); loadAllData(); // Refresh data every 30 seconds setInterval(loadAllData, 30000); + + // Initialize live feed + initLiveFeed(); + + // Batch render live events every 1s + setInterval(() => { + if (pendingLiveEvents.length > 0) { + liveEvents = [...pendingLiveEvents, ...liveEvents]; + if (liveEvents.length > 500) { + liveEvents = liveEvents.slice(0, 500); + } + pendingLiveEvents = []; + if (currentView === 'live') { + renderLiveEvents(); + } + } + }, 1000); }); // Navigation @@ -41,10 +63,16 @@ function switchView(view) { async function loadAllData() { try { await Promise.all([ + // Existing k8s dashboard data loadClusterStatus(), loadPendingActions(), loadHistory(), - loadWorkflows() + loadWorkflows(), + + // Claude dashboard data + loadClaudeStats(), + loadClaudeInventory(), + loadClaudeDebugFiles() ]); updateLastUpdate(); } catch (error) { @@ -52,6 +80,123 @@ async function loadAllData() { } } +async function initLiveFeed() { + try { + // Load initial backlog + const response = await fetch(`${API_BASE}/claude/live/backlog?limit=200`); + const data = await response.json(); + liveEvents = data.events || []; + renderLiveEvents(); + + // Setup SSE + liveEventSource = new EventSource(`${API_BASE}/claude/stream`); + + liveEventSource.onopen = () => { + updateLiveConnStatus('connected'); + }; + + liveEventSource.onerror = () => { + updateLiveConnStatus('error'); + }; + + liveEventSource.onmessage = (e) => { + try { + const ev = JSON.parse(e.data); + pendingLiveEvents.push(ev); + } catch (err) { + console.error('Error parsing SSE event:', err); + } + }; + } catch (error) { + console.error('Error initializing live feed:', error); + updateLiveConnStatus('error'); + } +} + +function updateLiveConnStatus(status) { + const el = document.getElementById('claude-live-conn'); + if (!el) return; + + el.className = `conn-badge conn-badge-${status}`; + el.textContent = status === 'connected' ? 'Connected' : status === 'error' ? 'Disconnected' : 'Connecting...'; +} + +function renderLiveEvents() { + const tbody = document.querySelector('#claude-live-table tbody'); + if (!tbody) return; + + if (liveEvents.length === 0) { + tbody.innerHTML = 'Waiting for events...'; + return; + } + + tbody.innerHTML = liveEvents.map(ev => { + const summary = ev.data?.summary || {}; + const summaryText = Object.values(summary).filter(Boolean).join(' ') || '-'; + const display = ev.data?.json?.display || ''; + + return ` + + ${formatDateTime(ev.ts)} + ${ev.type} + ${summaryText} + + + + + + `; + }).join(''); +} + +function toggleRawJson(btn) { + const pre = btn.nextElementSibling; + if (pre.style.display === 'none') { + pre.style.display = 'block'; + btn.textContent = 'Hide JSON'; + } else { + pre.style.display = 'none'; + btn.textContent = 'Show JSON'; + } +} + +// Claude dashboard data loading +async function loadClaudeStats() { + try { + const response = await fetch(`${API_BASE}/claude/stats`); + const data = await response.json(); + renderClaudeOverview(data); + renderClaudeUsage(data); + } catch (error) { + // Keep k8s dashboard working even if claude endpoints are unavailable + console.error('Error loading Claude stats:', error); + renderClaudeOverview(null); + renderClaudeUsage(null); + } +} + +async function loadClaudeInventory() { + try { + const response = await fetch(`${API_BASE}/claude/inventory`); + const data = await response.json(); + renderClaudeInventory(data); + } catch (error) { + console.error('Error loading Claude inventory:', error); + renderClaudeInventory(null); + } +} + +async function loadClaudeDebugFiles() { + try { + const response = await fetch(`${API_BASE}/claude/debug/files`); + const data = await response.json(); + renderClaudeDebugFiles(data); + } catch (error) { + console.error('Error loading Claude debug files:', error); + renderClaudeDebugFiles(null); + } +} + async function loadClusterStatus() { try { const response = await fetch(`${API_BASE}/status`); @@ -226,6 +371,151 @@ function renderWorkflows(workflows) { `).join(''); } +// Claude dashboard rendering +function renderClaudeOverview(stats) { + const el = document.getElementById('claude-overview'); + if (!el) return; + + if (!stats) { + el.innerHTML = '

Claude stats unavailable

'; + return; + } + + const lastComputedDate = stats.lastComputedDate || stats.lastComputed || stats.lastComputedAt || null; + + // Support both shapes: {totals:{...}} and flat {totalSessions,totalMessages,...} + const totalSessions = (stats.totalSessions != null) ? stats.totalSessions : (stats.totals && stats.totals.sessions != null ? stats.totals.sessions : 0); + const totalMessages = (stats.totalMessages != null) ? stats.totalMessages : (stats.totals && stats.totals.messages != null ? stats.totals.messages : 0); + const totalToolCalls = (stats.totalToolCalls != null) ? stats.totalToolCalls : (stats.totals && (stats.totals.toolCalls != null ? stats.totals.toolCalls : (stats.totals.tools != null ? stats.totals.tools : 0))); + + el.innerHTML = ` +
+
+

Total Sessions

+
${totalSessions}
+
+
+

Total Messages

+
${totalMessages}
+
+
+

Total Tool Calls

+
${totalToolCalls}
+
+
+

Last Computed

+
${lastComputedDate ? formatDateTime(lastComputedDate) : 'Unknown'}
+
+
+ `; +} + +function renderClaudeUsage(stats) { + const tbody = document.querySelector('#claude-usage-table tbody'); + if (!tbody) return; + + const daily = (stats && (stats.dailyActivity || stats.daily)) ? (stats.dailyActivity || stats.daily) : []; + + if (!daily || daily.length === 0) { + tbody.innerHTML = 'No usage data available'; + return; + } + + tbody.innerHTML = daily.map(d => { + const sessions = (d.sessionCount != null) ? d.sessionCount : (d.sessions != null ? d.sessions : 0); + const messages = (d.messageCount != null) ? d.messageCount : (d.messages != null ? d.messages : 0); + const toolCalls = (d.toolCallCount != null) ? d.toolCallCount : ((d.toolCalls != null) ? d.toolCalls : ((d.tools != null) ? d.tools : 0)); + return ` + + ${d.date || d.day || ''} + ${sessions} + ${messages} + ${toolCalls} + + `; + }).join(''); +} + +function renderClaudeInventory(inv) { + const el = document.getElementById('claude-inventory'); + if (!el) return; + + if (!inv) { + el.innerHTML = '

Claude inventory unavailable

'; + return; + } + + const agents = inv.agents || []; + const skills = inv.skills || []; + const commands = inv.commands || []; + + el.innerHTML = ` +
+

Agents (${agents.length})

+ ${renderSimpleList(agents.map(a => a.name || a.path || a))} +
+
+

Skills (${skills.length})

+ ${renderSimpleList(skills.map(s => s.name || s.path || s))} +
+
+

Commands (${commands.length})

+ ${renderSimpleList(commands.map(c => c.name || c.path || c))} +
+ `; +} + +function renderClaudeDebugFiles(debug) { + const tbody = document.querySelector('#claude-debug-table tbody'); + if (!tbody) return; + + const files = (debug && (debug.files || debug.keyFiles)) ? (debug.files || debug.keyFiles) : []; + + if (!files || files.length === 0) { + tbody.innerHTML = 'No debug file info available'; + return; + } + + tbody.innerHTML = files.map(f => { + const exists = (f.exists != null) ? f.exists : !f.missing; + const status = (f.status || ((!exists) ? 'missing' : 'ok')).toLowerCase(); + const badgeClass = status === 'ok' ? 'status-ok' : 'status-missing'; + return ` + + ${escapeHtml(f.name || f.path || '')} + ${status} + ${(f.mtime || f.modTime) ? formatDateTime(f.mtime || f.modTime) : ''} + ${f.error ? escapeHtml(f.error) : ''} + + `; + }).join(''); +} + +function renderSimpleList(items) { + const safeItems = (items || []).filter(Boolean); + if (safeItems.length === 0) return '

None

'; + return ` +
    + ${safeItems.map(i => `
  • ${escapeHtml(String(i))}
  • `).join('')} +
+ `; +} + +function formatDateTime(value) { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return String(value); + return d.toLocaleString(); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // Actions async function approveAction(id) { try { diff --git a/dashboard/internal/api/claude_handlers.go b/dashboard/internal/api/claude_handlers.go new file mode 100644 index 0000000..5fc89a3 --- /dev/null +++ b/dashboard/internal/api/claude_handlers.go @@ -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, + }) + } +} diff --git a/dashboard/internal/api/claude_handlers_test.go b/dashboard/internal/api/claude_handlers_test.go new file mode 100644 index 0000000..b8dcafe --- /dev/null +++ b/dashboard/internal/api/claude_handlers_test.go @@ -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 +} diff --git a/dashboard/internal/api/claude_live_handlers.go b/dashboard/internal/api/claude_live_handlers.go new file mode 100644 index 0000000..c86f6b6 --- /dev/null +++ b/dashboard/internal/api/claude_live_handlers.go @@ -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, + } +} diff --git a/dashboard/internal/api/claude_live_handlers_test.go b/dashboard/internal/api/claude_live_handlers_test.go new file mode 100644 index 0000000..a93172c --- /dev/null +++ b/dashboard/internal/api/claude_live_handlers_test.go @@ -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()) + } +} diff --git a/dashboard/internal/api/claude_routes_smoke_test.go b/dashboard/internal/api/claude_routes_smoke_test.go new file mode 100644 index 0000000..de8960a --- /dev/null +++ b/dashboard/internal/api/claude_routes_smoke_test.go @@ -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) + } +} diff --git a/dashboard/internal/api/claude_stream_handlers.go b/dashboard/internal/api/claude_stream_handlers.go new file mode 100644 index 0000000..3c0cd85 --- /dev/null +++ b/dashboard/internal/api/claude_stream_handlers.go @@ -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 + } + } + } +} diff --git a/dashboard/internal/api/claude_stream_handlers_test.go b/dashboard/internal/api/claude_stream_handlers_test.go new file mode 100644 index 0000000..2830491 --- /dev/null +++ b/dashboard/internal/api/claude_stream_handlers_test.go @@ -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()) + } +} diff --git a/dashboard/internal/claude/eventhub.go b/dashboard/internal/claude/eventhub.go new file mode 100644 index 0000000..d0d6801 --- /dev/null +++ b/dashboard/internal/claude/eventhub.go @@ -0,0 +1,85 @@ +package claude + +import ( + "sync" + "sync/atomic" + "time" +) + +type EventHub struct { + mu sync.RWMutex + buffer []Event + nextID int64 + subscribers []chan Event + bufferSize int +} + +func NewEventHub(bufferSize int) *EventHub { + return &EventHub{ + buffer: make([]Event, 0, bufferSize), + subscribers: make([]chan Event, 0), + bufferSize: bufferSize, + } +} + +func (h *EventHub) Publish(ev Event) Event { + if ev.ID == 0 { + ev.ID = atomic.AddInt64(&h.nextID, 1) + } + if ev.TS.IsZero() { + ev.TS = time.Now() + } + + h.mu.Lock() + defer h.mu.Unlock() + + if len(h.buffer) >= h.bufferSize { + h.buffer = h.buffer[1:] + } + h.buffer = append(h.buffer, ev) + + for _, ch := range h.subscribers { + select { + case ch <- ev: + default: + } + } + + return ev +} + +func (h *EventHub) Subscribe() (chan Event, func()) { + ch := make(chan Event, 10) + + h.mu.Lock() + defer h.mu.Unlock() + + h.subscribers = append(h.subscribers, ch) + + cancel := func() { + h.mu.Lock() + defer h.mu.Unlock() + for i, c := range h.subscribers { + if c == ch { + h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...) + close(ch) + break + } + } + } + + return ch, cancel +} + +func (h *EventHub) ReplaySince(lastID int64) []Event { + h.mu.RLock() + defer h.mu.RUnlock() + + var result []Event + for _, ev := range h.buffer { + if ev.ID > lastID { + result = append(result, ev) + } + } + return result +} diff --git a/dashboard/internal/claude/eventhub_test.go b/dashboard/internal/claude/eventhub_test.go new file mode 100644 index 0000000..b5fa7c5 --- /dev/null +++ b/dashboard/internal/claude/eventhub_test.go @@ -0,0 +1,41 @@ +package claude + +import ( + "testing" + "time" +) + +func TestEventHub_PublishSubscribe(t *testing.T) { + hub := NewEventHub(10) + ch, cancel := hub.Subscribe() + defer cancel() + + hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice, Data: map[string]any{"msg": "hi"}}) + + select { + case ev := <-ch: + if ev.Type != EventTypeServerNotice { + t.Fatalf("type=%s", ev.Type) + } + if ev.ID == 0 { + t.Fatalf("expected id to be assigned") + } + default: + t.Fatalf("expected event") + } +} + +func TestEventHub_ReplaySince(t *testing.T) { + hub := NewEventHub(3) + hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice}) // id 1 + hub.Publish(Event{TS: time.Unix(2, 0), Type: EventTypeServerNotice}) // id 2 + hub.Publish(Event{TS: time.Unix(3, 0), Type: EventTypeServerNotice}) // id 3 + + got := hub.ReplaySince(1) + if len(got) != 2 { + t.Fatalf("len=%d", len(got)) + } + if got[0].ID != 2 || got[1].ID != 3 { + t.Fatalf("ids=%d,%d", got[0].ID, got[1].ID) + } +} diff --git a/dashboard/internal/claude/events.go b/dashboard/internal/claude/events.go new file mode 100644 index 0000000..256a92e --- /dev/null +++ b/dashboard/internal/claude/events.go @@ -0,0 +1,19 @@ +package claude + +import "time" + +type EventType string + +const ( + EventTypeHistoryAppend EventType = "history.append" + EventTypeFileChanged EventType = "file.changed" + EventTypeServerNotice EventType = "server.notice" + EventTypeServerError EventType = "server.error" +) + +type Event struct { + ID int64 `json:"id"` + TS time.Time `json:"ts"` + Type EventType `json:"type"` + Data any `json:"data"` +} diff --git a/dashboard/internal/claude/events_test.go b/dashboard/internal/claude/events_test.go new file mode 100644 index 0000000..65ddfd3 --- /dev/null +++ b/dashboard/internal/claude/events_test.go @@ -0,0 +1,11 @@ +package claude + +import "testing" + +func TestEventTypesCompile(t *testing.T) { + _ = Event{} + _ = EventTypeHistoryAppend + _ = EventTypeFileChanged + _ = EventTypeServerNotice + _ = EventTypeServerError +} diff --git a/dashboard/internal/claude/history_tailer.go b/dashboard/internal/claude/history_tailer.go new file mode 100644 index 0000000..bd9bd16 --- /dev/null +++ b/dashboard/internal/claude/history_tailer.go @@ -0,0 +1,105 @@ +package claude + +import ( + "bufio" + "encoding/json" + "os" + "time" +) + +func TailHistoryFile(stop <-chan struct{}, hub *EventHub, path string) { + var offset int64 + + for { + select { + case <-stop: + return + default: + } + + stat, err := os.Stat(path) + if err != nil { + if !os.IsNotExist(err) { + hub.Publish(Event{ + TS: time.Now(), + Type: EventTypeServerError, + Data: map[string]any{"error": err.Error()}, + }) + } + time.Sleep(1 * time.Second) + continue + } + + size := stat.Size() + if size > offset { + if err := processNewBytes(path, offset, size, hub); err != nil { + hub.Publish(Event{ + TS: time.Now(), + Type: EventTypeServerError, + Data: map[string]any{"error": err.Error()}, + }) + } + offset = size + } else if size < offset { + offset = 0 + hub.Publish(Event{ + TS: time.Now(), + Type: EventTypeServerNotice, + Data: map[string]any{"msg": "file truncated, resetting offset"}, + }) + } + + time.Sleep(500 * time.Millisecond) + } +} + +func processNewBytes(path string, oldSize, newSize int64, hub *EventHub) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.Seek(oldSize, 0); err != nil { + return err + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + 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 + } + + hub.Publish(Event{ + TS: time.Now(), + Type: EventTypeHistoryAppend, + Data: data, + }) + } + + return scanner.Err() +} diff --git a/dashboard/internal/claude/history_tailer_test.go b/dashboard/internal/claude/history_tailer_test.go new file mode 100644 index 0000000..74c83dc --- /dev/null +++ b/dashboard/internal/claude/history_tailer_test.go @@ -0,0 +1,40 @@ +package claude + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestHistoryTailer_EmitsOnAppend(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "history.jsonl") + if err := os.WriteFile(p, []byte(""), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + hub := NewEventHub(10) + ch, cancel := hub.Subscribe() + defer cancel() + + stop := make(chan struct{}) + go TailHistoryFile(stop, hub, p) + + time.Sleep(600 * time.Millisecond) + + if err := os.WriteFile(p, []byte("{\"display\":\"/status\"}\n"), 0o600); err != nil { + t.Fatalf("append: %v", err) + } + + select { + case ev := <-ch: + if ev.Type != EventTypeHistoryAppend { + t.Fatalf("type=%s", ev.Type) + } + case <-time.After(700 * time.Millisecond): + t.Fatalf("timed out waiting for event") + } + + close(stop) +} diff --git a/dashboard/internal/claude/loader.go b/dashboard/internal/claude/loader.go new file mode 100644 index 0000000..d2d2c74 --- /dev/null +++ b/dashboard/internal/claude/loader.go @@ -0,0 +1,100 @@ +package claude + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Loader reads Claude Code state files from a local claude directory (typically ~/.claude). +// +// Keep this minimal for now; more helpers (e.g. ListDir / FileInfo) can be added later. +type Loader struct { + claudeDir string +} + +type DirEntry struct { + Name string `json:"name"` + IsDir bool `json:"isDir"` +} + +type FileMeta struct { + Path string `json:"path"` + Exists bool `json:"exists"` + Size int64 `json:"size"` + ModTime string `json:"modTime"` +} + +func NewLoader(claudeDir string) *Loader { + return &Loader{claudeDir: claudeDir} +} + +func (l *Loader) ClaudeDir() string { return l.claudeDir } + +func (l *Loader) LoadStatsCache() (*StatsCache, error) { + if l.claudeDir == "" { + return nil, fmt.Errorf("claude dir is empty") + } + + p := filepath.Join(l.claudeDir, "stats-cache.json") + b, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("read stats cache %q: %w", p, err) + } + + var stats StatsCache + if err := json.Unmarshal(b, &stats); err != nil { + return nil, fmt.Errorf("parse stats cache %q: %w", p, err) + } + + return &stats, nil +} + +func (l *Loader) ListDir(name string) ([]DirEntry, error) { + if l.claudeDir == "" { + return nil, fmt.Errorf("claude dir is empty") + } + + entries, err := os.ReadDir(filepath.Join(l.claudeDir, name)) + if err != nil { + return nil, fmt.Errorf("read dir %q: %w", name, err) + } + + out := make([]DirEntry, 0, len(entries)) + for _, e := range entries { + out = append(out, DirEntry{Name: e.Name(), IsDir: e.IsDir()}) + } + return out, nil +} + +func (l *Loader) PathExists(relPath string) bool { + if l.claudeDir == "" { + return false + } + _, err := os.Stat(filepath.Join(l.claudeDir, relPath)) + return err == nil +} + +func (l *Loader) FileMeta(relPath string) (FileMeta, error) { + if l.claudeDir == "" { + return FileMeta{}, fmt.Errorf("claude dir is empty") + } + + p := filepath.Join(l.claudeDir, relPath) + st, err := os.Stat(p) + if err != nil { + if os.IsNotExist(err) { + return FileMeta{Path: relPath, Exists: false}, nil + } + return FileMeta{}, fmt.Errorf("stat %q: %w", p, err) + } + + return FileMeta{ + Path: relPath, + Exists: true, + Size: st.Size(), + ModTime: st.ModTime().UTC().Format(time.RFC3339), + }, nil +} diff --git a/dashboard/internal/claude/loader_test.go b/dashboard/internal/claude/loader_test.go new file mode 100644 index 0000000..acc667f --- /dev/null +++ b/dashboard/internal/claude/loader_test.go @@ -0,0 +1,25 @@ +package claude + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadStatsCache(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "stats-cache.json") + err := os.WriteFile(p, []byte(`{"version":1,"lastComputedDate":"2025-12-31","totalSessions":1,"totalMessages":2}`), 0644) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + + loader := NewLoader(dir) + stats, err := loader.LoadStatsCache() + if err != nil { + t.Fatalf("LoadStatsCache: %v", err) + } + if stats.TotalSessions != 1 { + t.Fatalf("TotalSessions=%d", stats.TotalSessions) + } +} diff --git a/dashboard/internal/claude/models.go b/dashboard/internal/claude/models.go new file mode 100644 index 0000000..8bd7190 --- /dev/null +++ b/dashboard/internal/claude/models.go @@ -0,0 +1,33 @@ +package claude + +type DailyActivity struct { + Date string `json:"date"` + MessageCount int `json:"messageCount"` + SessionCount int `json:"sessionCount"` + ToolCallCount int `json:"toolCallCount"` +} + +type DailyModelTokens struct { + Date string `json:"date"` + TokensByModel map[string]int `json:"tokensByModel"` +} + +type ModelUsage struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + CacheReadInputTokens int `json:"cacheReadInputTokens"` + CacheCreationInputTokens int `json:"cacheCreationInputTokens"` + WebSearchRequests int `json:"webSearchRequests"` + CostUSD float64 `json:"costUSD"` + ContextWindow int `json:"contextWindow"` +} + +type StatsCache struct { + Version int `json:"version"` + LastComputedDate string `json:"lastComputedDate"` + DailyActivity []DailyActivity `json:"dailyActivity"` + DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"` + ModelUsage map[string]ModelUsage `json:"modelUsage"` + TotalSessions int `json:"totalSessions"` + TotalMessages int `json:"totalMessages"` +} diff --git a/dashboard/internal/claude/models_test.go b/dashboard/internal/claude/models_test.go new file mode 100644 index 0000000..271fcb1 --- /dev/null +++ b/dashboard/internal/claude/models_test.go @@ -0,0 +1,9 @@ +package claude + +import "testing" + +func TestModelTypesCompile(t *testing.T) { + _ = StatsCache{} + _ = DailyActivity{} + _ = ModelUsage{} +} diff --git a/dashboard/internal/claude/tail.go b/dashboard/internal/claude/tail.go new file mode 100644 index 0000000..9f45ca6 --- /dev/null +++ b/dashboard/internal/claude/tail.go @@ -0,0 +1,24 @@ +package claude + +import ( + "os" + "strings" +) + +func TailLastNLines(path string, n int) ([]string, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + lines := strings.Split(string(content), "\n") + + var result []string + for i := len(lines) - 1; i >= 0 && len(result) < n; i-- { + if lines[i] != "" { + result = append(result, lines[i]) + } + } + + return result, nil +} diff --git a/dashboard/internal/claude/tail_test.go b/dashboard/internal/claude/tail_test.go new file mode 100644 index 0000000..3e89d25 --- /dev/null +++ b/dashboard/internal/claude/tail_test.go @@ -0,0 +1,34 @@ +package claude + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTailLastNLines_NewestFirst(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "history.jsonl") + + var b strings.Builder + for i := 1; i <= 5; i++ { + b.WriteString("line") + b.WriteString([]string{"1", "2", "3", "4", "5"}[i-1]) + b.WriteString("\n") + } + if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + lines, err := TailLastNLines(p, 2) + if err != nil { + t.Fatalf("TailLastNLines: %v", err) + } + if len(lines) != 2 { + t.Fatalf("len=%d", len(lines)) + } + if lines[0] != "line5" || lines[1] != "line4" { + t.Fatalf("got=%v", lines) + } +} diff --git a/dashboard/run_tests.sh b/dashboard/run_tests.sh new file mode 100644 index 0000000..6d98bb0 --- /dev/null +++ b/dashboard/run_tests.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +go test ./...