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:
OpenCode Test
2026-01-03 10:54:48 -08:00
parent de89f3066c
commit ae958528a6
26 changed files with 1638 additions and 2 deletions
+2 -1
View File
@@ -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
+19
View File
@@ -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)
}
}
+31
View File
@@ -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)
+91
View File
@@ -16,6 +16,14 @@
</header>
<nav>
<!-- General -->
<button class="nav-btn" data-view="overview">Overview</button>
<button class="nav-btn" data-view="usage">Usage</button>
<button class="nav-btn" data-view="inventory">Inventory</button>
<button class="nav-btn" data-view="debug">Debug</button>
<button class="nav-btn" data-view="live">Live</button>
<!-- Existing K8s views (kept intact) -->
<button class="nav-btn active" data-view="status">Status</button>
<button class="nav-btn" data-view="pending">Pending <span id="pending-count" class="badge">0</span></button>
<button class="nav-btn" data-view="history">History</button>
@@ -23,6 +31,89 @@
</nav>
<main>
<!-- Overview View -->
<section id="overview-view" class="view">
<div class="card">
<h2>Overview</h2>
<div id="claude-overview">
<p class="empty-state">Loading Claude overview...</p>
</div>
</div>
</section>
<!-- Usage View -->
<section id="usage-view" class="view">
<div class="card">
<h2>Usage</h2>
<table id="claude-usage-table">
<thead>
<tr>
<th>Date</th>
<th>Sessions</th>
<th>Messages</th>
<th>Tool Calls</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="empty-state">Loading usage...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Inventory View -->
<section id="inventory-view" class="view">
<div class="card">
<h2>Inventory</h2>
<div id="claude-inventory">
<p class="empty-state">Loading inventory...</p>
</div>
</div>
</section>
<!-- Debug View -->
<section id="debug-view" class="view">
<div class="card">
<h2>Debug</h2>
<table id="claude-debug-table">
<thead>
<tr>
<th>File</th>
<th>Status</th>
<th>MTime</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="empty-state">Loading debug info...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Live View -->
<section id="live-view" class="view">
<div class="card">
<h2>Live Feed</h2>
<div class="live-header">
<span id="claude-live-conn" class="conn-badge conn-badge-yellow">Connecting...</span>
</div>
<table id="claude-live-table">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Summary</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Status View -->
<section id="status-view" class="view active">
<div class="card">
@@ -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 {
+291 -1
View File
@@ -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 = '<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>';
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 `
<tr>
<td>${formatDateTime(ev.ts)}</td>
<td><span class="badge">${ev.type}</span></td>
<td>${summaryText}</td>
<td>
<button class="btn-sm" onclick="toggleRawJson(this)">Show JSON</button>
<pre class="raw-json" style="display:none">${escapeHtml(JSON.stringify(ev.data?.json || ev.data?.rawLine || {}, null, 2))}</pre>
</td>
</tr>
`;
}).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 = '<p class="empty-state">Claude stats unavailable</p>';
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 = `
<div class="grid">
<div class="card">
<h3>Total Sessions</h3>
<div class="metric">${totalSessions}</div>
</div>
<div class="card">
<h3>Total Messages</h3>
<div class="metric">${totalMessages}</div>
</div>
<div class="card">
<h3>Total Tool Calls</h3>
<div class="metric">${totalToolCalls}</div>
</div>
<div class="card">
<h3>Last Computed</h3>
<div class="metric" style="font-size: 14px; font-weight: 600;">${lastComputedDate ? formatDateTime(lastComputedDate) : 'Unknown'}</div>
</div>
</div>
`;
}
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 = '<tr><td colspan="4" class="empty-state">No usage data available</td></tr>';
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 `
<tr>
<td>${d.date || d.day || ''}</td>
<td>${sessions}</td>
<td>${messages}</td>
<td>${toolCalls}</td>
</tr>
`;
}).join('');
}
function renderClaudeInventory(inv) {
const el = document.getElementById('claude-inventory');
if (!el) return;
if (!inv) {
el.innerHTML = '<p class="empty-state">Claude inventory unavailable</p>';
return;
}
const agents = inv.agents || [];
const skills = inv.skills || [];
const commands = inv.commands || [];
el.innerHTML = `
<div class="inventory-section">
<h3>Agents (${agents.length})</h3>
${renderSimpleList(agents.map(a => a.name || a.path || a))}
</div>
<div class="inventory-section">
<h3>Skills (${skills.length})</h3>
${renderSimpleList(skills.map(s => s.name || s.path || s))}
</div>
<div class="inventory-section">
<h3>Commands (${commands.length})</h3>
${renderSimpleList(commands.map(c => c.name || c.path || c))}
</div>
`;
}
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 = '<tr><td colspan="4" class="empty-state">No debug file info available</td></tr>';
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 `
<tr>
<td><code>${escapeHtml(f.name || f.path || '')}</code></td>
<td><span class="status-badge ${badgeClass}">${status}</span></td>
<td>${(f.mtime || f.modTime) ? formatDateTime(f.mtime || f.modTime) : ''}</td>
<td>${f.error ? escapeHtml(f.error) : ''}</td>
</tr>
`;
}).join('');
}
function renderSimpleList(items) {
const safeItems = (items || []).filter(Boolean);
if (safeItems.length === 0) return '<p class="empty-state">None</p>';
return `
<ul class="simple-list">
${safeItems.map(i => `<li>${escapeHtml(String(i))}</li>`).join('')}
</ul>
`;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Actions
async function approveAction(id) {
try {
+162
View 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,
})
}
}
@@ -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
}
@@ -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,
}
}
@@ -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())
}
}
@@ -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)
}
}
@@ -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
}
}
}
}
@@ -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())
}
}
+85
View File
@@ -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
}
@@ -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)
}
}
+19
View File
@@ -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"`
}
+11
View File
@@ -0,0 +1,11 @@
package claude
import "testing"
func TestEventTypesCompile(t *testing.T) {
_ = Event{}
_ = EventTypeHistoryAppend
_ = EventTypeFileChanged
_ = EventTypeServerNotice
_ = EventTypeServerError
}
+105
View File
@@ -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()
}
@@ -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)
}
+100
View File
@@ -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
}
+25
View File
@@ -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)
}
}
+33
View File
@@ -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"`
}
+9
View File
@@ -0,0 +1,9 @@
package claude
import "testing"
func TestModelTypesCompile(t *testing.T) {
_ = StatsCache{}
_ = DailyActivity{}
_ = ModelUsage{}
}
+24
View File
@@ -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
}
+34
View File
@@ -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)
}
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
go test ./...