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} |
+
+
+ ${escapeHtml(JSON.stringify(ev.data?.json || ev.data?.rawLine || {}, null, 2))}
+ |
+
+ `;
+ }).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 ./...