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.
597 lines
19 KiB
JavaScript
597 lines
19 KiB
JavaScript
// K8s Agent Dashboard - Frontend JavaScript
|
|
|
|
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
|
|
function setupNavigation() {
|
|
document.querySelectorAll('.nav-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const view = btn.dataset.view;
|
|
switchView(view);
|
|
});
|
|
});
|
|
}
|
|
|
|
function switchView(view) {
|
|
currentView = view;
|
|
|
|
// Update nav buttons
|
|
document.querySelectorAll('.nav-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.view === view);
|
|
});
|
|
|
|
// Update views
|
|
document.querySelectorAll('.view').forEach(v => {
|
|
v.classList.toggle('active', v.id === `${view}-view`);
|
|
});
|
|
}
|
|
|
|
// Data Loading
|
|
async function loadAllData() {
|
|
try {
|
|
await Promise.all([
|
|
// Existing k8s dashboard data
|
|
loadClusterStatus(),
|
|
loadPendingActions(),
|
|
loadHistory(),
|
|
loadWorkflows(),
|
|
|
|
// Claude dashboard data
|
|
loadClaudeStats(),
|
|
loadClaudeInventory(),
|
|
loadClaudeDebugFiles()
|
|
]);
|
|
updateLastUpdate();
|
|
} catch (error) {
|
|
console.error('Error loading data:', error);
|
|
}
|
|
}
|
|
|
|
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`);
|
|
const data = await response.json();
|
|
renderClusterStatus(data);
|
|
} catch (error) {
|
|
console.error('Error loading status:', error);
|
|
}
|
|
}
|
|
|
|
async function loadPendingActions() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/pending`);
|
|
const data = await response.json();
|
|
renderPendingActions(data.actions || []);
|
|
document.getElementById('pending-count').textContent = data.count || 0;
|
|
} catch (error) {
|
|
console.error('Error loading pending:', error);
|
|
}
|
|
}
|
|
|
|
async function loadHistory() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/history?limit=20`);
|
|
const data = await response.json();
|
|
renderHistory(data.history || []);
|
|
} catch (error) {
|
|
console.error('Error loading history:', error);
|
|
}
|
|
}
|
|
|
|
async function loadWorkflows() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/workflows`);
|
|
const data = await response.json();
|
|
renderWorkflows(data.workflows || []);
|
|
} catch (error) {
|
|
console.error('Error loading workflows:', error);
|
|
}
|
|
}
|
|
|
|
// Rendering
|
|
function renderClusterStatus(status) {
|
|
// Update health indicator
|
|
const healthEl = document.getElementById('cluster-health');
|
|
const indicator = healthEl.querySelector('.health-indicator');
|
|
const text = healthEl.querySelector('.health-text');
|
|
|
|
const health = (status.health || 'Unknown').toLowerCase();
|
|
indicator.className = `health-indicator ${health}`;
|
|
text.textContent = status.health || 'Unknown';
|
|
|
|
// Render nodes
|
|
const nodesBody = document.querySelector('#nodes-table tbody');
|
|
if (status.nodes && status.nodes.length > 0) {
|
|
nodesBody.innerHTML = status.nodes.map(node => `
|
|
<tr>
|
|
<td>${node.name}</td>
|
|
<td><span class="status-badge status-${node.status.toLowerCase()}">${node.status}</span></td>
|
|
<td>
|
|
<div class="progress-bar">
|
|
<div class="fill ${getProgressClass(node.cpu_percent)}" style="width: ${node.cpu_percent}%"></div>
|
|
</div>
|
|
${node.cpu_percent.toFixed(0)}%
|
|
</td>
|
|
<td>
|
|
<div class="progress-bar">
|
|
<div class="fill ${getProgressClass(node.memory_percent)}" style="width: ${node.memory_percent}%"></div>
|
|
</div>
|
|
${node.memory_percent.toFixed(0)}%
|
|
</td>
|
|
<td>${node.conditions}</td>
|
|
</tr>
|
|
`).join('');
|
|
} else {
|
|
nodesBody.innerHTML = '<tr><td colspan="5" class="empty-state">No nodes data available</td></tr>';
|
|
}
|
|
|
|
// Render alerts
|
|
const alertsList = document.getElementById('alerts-list');
|
|
if (status.alerts && status.alerts.length > 0) {
|
|
alertsList.innerHTML = status.alerts.map(alert => `
|
|
<div class="alert-item ${alert.severity}">
|
|
<strong>[${alert.severity.toUpperCase()}]</strong>
|
|
<span>${alert.name}</span>
|
|
<span class="description">${alert.description}</span>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
alertsList.innerHTML = '<p class="empty-state">No active alerts</p>';
|
|
}
|
|
|
|
// Render apps
|
|
const appsBody = document.querySelector('#apps-table tbody');
|
|
if (status.apps && status.apps.length > 0) {
|
|
appsBody.innerHTML = status.apps.map(app => `
|
|
<tr>
|
|
<td>${app.name}</td>
|
|
<td><span class="status-badge status-${app.sync_status.toLowerCase().replace(' ', '')}">${app.sync_status}</span></td>
|
|
<td><span class="status-badge status-${app.health.toLowerCase()}">${app.health}</span></td>
|
|
<td><code>${app.revision.substring(0, 7)}</code></td>
|
|
</tr>
|
|
`).join('');
|
|
} else {
|
|
appsBody.innerHTML = '<tr><td colspan="4" class="empty-state">No ArgoCD apps data available</td></tr>';
|
|
}
|
|
}
|
|
|
|
function renderPendingActions(actions) {
|
|
const list = document.getElementById('pending-list');
|
|
|
|
if (actions.length === 0) {
|
|
list.innerHTML = '<p class="empty-state">No pending actions</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = actions.map(action => `
|
|
<div class="pending-item" data-id="${action.id}">
|
|
<div class="header">
|
|
<div>
|
|
<span class="agent">${action.agent}</span>
|
|
<div class="action">${action.action}</div>
|
|
</div>
|
|
<span class="status-badge status-pending">${action.risk} risk</span>
|
|
</div>
|
|
<div class="description">${action.description}</div>
|
|
<div class="buttons">
|
|
<button class="btn btn-approve" onclick="approveAction('${action.id}')">Approve</button>
|
|
<button class="btn btn-reject" onclick="rejectAction('${action.id}')">Reject</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderHistory(history) {
|
|
const tbody = document.querySelector('#history-table tbody');
|
|
|
|
if (history.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No history available</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = history.map(entry => `
|
|
<tr>
|
|
<td>${formatTime(entry.timestamp)}</td>
|
|
<td>${entry.agent}</td>
|
|
<td>${entry.action}</td>
|
|
<td><span class="status-badge status-${entry.result}">${entry.result}</span></td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function renderWorkflows(workflows) {
|
|
const list = document.getElementById('workflows-list');
|
|
|
|
if (workflows.length === 0) {
|
|
list.innerHTML = '<p class="empty-state">No workflows defined</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = workflows.map(wf => `
|
|
<div class="workflow-item">
|
|
<div class="info">
|
|
<h3>${wf.name}</h3>
|
|
<p>${wf.description}</p>
|
|
<div class="triggers">
|
|
${wf.triggers.map(t => `<span class="trigger-tag">${t}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-run" onclick="runWorkflow('${wf.name}')">Run</button>
|
|
</div>
|
|
`).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 {
|
|
const response = await fetch(`${API_BASE}/pending/${id}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
if (response.ok) {
|
|
loadPendingActions();
|
|
loadHistory();
|
|
} else {
|
|
alert('Failed to approve action');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error approving action:', error);
|
|
alert('Error approving action');
|
|
}
|
|
}
|
|
|
|
async function rejectAction(id) {
|
|
const reason = prompt('Reason for rejection (optional):');
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/pending/${id}/reject`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reason: reason || 'Rejected by user' })
|
|
});
|
|
|
|
if (response.ok) {
|
|
loadPendingActions();
|
|
loadHistory();
|
|
} else {
|
|
alert('Failed to reject action');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error rejecting action:', error);
|
|
alert('Error rejecting action');
|
|
}
|
|
}
|
|
|
|
async function runWorkflow(name) {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/workflows/${name}/run`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const data = await response.json();
|
|
alert(data.message);
|
|
} catch (error) {
|
|
console.error('Error running workflow:', error);
|
|
alert('Error running workflow');
|
|
}
|
|
}
|
|
|
|
// Helpers
|
|
function getProgressClass(percent) {
|
|
if (percent >= 80) return 'danger';
|
|
if (percent >= 60) return 'warning';
|
|
return '';
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function updateLastUpdate() {
|
|
const now = new Date();
|
|
document.getElementById('last-update').textContent = now.toLocaleTimeString();
|
|
}
|