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:
19
dashboard/cmd/server/config_test.go
Normal file
19
dashboard/cmd/server/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user