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:
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function approveAction(id) {
|
||||
try {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventTypesCompile(t *testing.T) {
|
||||
_ = Event{}
|
||||
_ = EventTypeHistoryAppend
|
||||
_ = EventTypeFileChanged
|
||||
_ = EventTypeServerNotice
|
||||
_ = EventTypeServerError
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModelTypesCompile(t *testing.T) {
|
||||
_ = StatsCache{}
|
||||
_ = DailyActivity{}
|
||||
_ = ModelUsage{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
go test ./...
|
||||
Reference in New Issue
Block a user