4662 lines
186 KiB
HTML
4662 lines
186 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title data-i18n="app.title">OpenClaw Command Center</title>
|
||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||
<link rel="stylesheet" href="css/dashboard.css" />
|
||
<script src="js/lib/morphdom.min.js"></script>
|
||
<script src="js/i18n.js"></script>
|
||
<!-- Shared Sidebar Loader -->
|
||
<script src="js/sidebar.js"></script>
|
||
</head>
|
||
<body>
|
||
<!-- Sidebar container (populated by sidebar.js) -->
|
||
<div id="sidebar-container"></div>
|
||
|
||
<!-- Main Content -->
|
||
<div class="main-wrapper" id="main-wrapper">
|
||
<header>
|
||
<div class="header-left">
|
||
<button
|
||
class="sidebar-toggle"
|
||
onclick="toggleSidebar()"
|
||
style="display: none"
|
||
id="mobile-menu-btn"
|
||
>
|
||
☰
|
||
</button>
|
||
<h1 class="page-title" data-i18n="app.title">OpenClaw Command Center</h1>
|
||
<div
|
||
id="lang-switcher"
|
||
style="
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-left: 12px;
|
||
flex-shrink: 0;
|
||
"
|
||
>
|
||
<span style="font-size: 0.75rem; opacity: 0.8">🌐</span>
|
||
<select
|
||
id="lang-select"
|
||
style="
|
||
font-size: 0.8rem;
|
||
padding: 3px 8px;
|
||
background: var(--card-bg, #161b22);
|
||
color: var(--text, #c9d1d9);
|
||
border: 1px solid var(--border, #30363d);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
"
|
||
>
|
||
<option value="en">English</option>
|
||
<option value="zh-CN">简体中文</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="status-pill" id="connection-status" title="SSE connection status">
|
||
<div class="pulse" id="connection-pulse"></div>
|
||
<span id="gateway-status" data-i18n="app.connecting">Connecting...</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="stats-bar">
|
||
<div class="stat">
|
||
<div class="stat-value" id="total-tokens">-</div>
|
||
<div class="stat-label" data-i18n="stats.totalTokens">Total Tokens</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-value" id="input-tokens">-</div>
|
||
<div class="stat-label" data-i18n="stats.input">Input</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-value" id="output-tokens">-</div>
|
||
<div class="stat-label" data-i18n="stats.output">Output</div>
|
||
</div>
|
||
<div
|
||
class="stat"
|
||
title="Sessions updated within the last 15 minutes (not necessarily actively running)"
|
||
>
|
||
<div class="stat-value" id="active-sessions">-</div>
|
||
<div class="stat-label" data-i18n="stats.active15m">Recently active (15m)</div>
|
||
</div>
|
||
<div
|
||
class="stat"
|
||
title="Click for cost breakdown"
|
||
onclick="openCostModal()"
|
||
style="cursor: pointer"
|
||
>
|
||
<div class="stat-value" id="est-cost">-</div>
|
||
<div class="stat-label" data-i18n="stats.estCost24h">Est. Cost (24h) 📊</div>
|
||
</div>
|
||
<div
|
||
class="stat"
|
||
title="Click for cost breakdown"
|
||
id="savings-stat"
|
||
style="display: none; cursor: pointer"
|
||
>
|
||
<div
|
||
class="stat-value"
|
||
id="est-savings"
|
||
style="color: var(--green)"
|
||
onclick="openCostModal()"
|
||
>
|
||
-
|
||
</div>
|
||
<div class="stat-label" style="display: flex; align-items: center; gap: 4px">
|
||
<span onclick="openCostModal()" data-i18n="stats.estMonthlySavings"
|
||
>Est. Monthly Savings</span
|
||
>
|
||
<select
|
||
id="savings-window-select"
|
||
style="
|
||
font-size: 0.7rem;
|
||
padding: 1px 2px;
|
||
background: var(--card-bg);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
"
|
||
onclick="event.stopPropagation()"
|
||
>
|
||
<option value="24h">24h</option>
|
||
<option value="3dma">3dma</option>
|
||
<option value="7dma" selected>7dma</option>
|
||
</select>
|
||
💰
|
||
</div>
|
||
</div>
|
||
<div class="stat" title="Main session capacity">
|
||
<div class="stat-value" id="main-capacity">-</div>
|
||
<div class="stat-label" data-i18n="stats.main">Main</div>
|
||
</div>
|
||
<div class="stat" title="Sub-agent capacity">
|
||
<div class="stat-value" id="subagent-capacity">-</div>
|
||
<div class="stat-label" data-i18n="stats.subagents">Sub-agents</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Legend & Filters -->
|
||
<main>
|
||
<!-- System Vitals Section -->
|
||
<div class="section" id="vitals-section">
|
||
<div class="vitals-panel">
|
||
<div class="vitals-header">
|
||
<div class="vitals-title">
|
||
🖥️ System Vitals
|
||
<span class="vitals-hostname" id="vitals-hostname">-</span>
|
||
</div>
|
||
<div class="vitals-uptime">Uptime: <span id="vitals-uptime">-</span></div>
|
||
</div>
|
||
<div class="vitals-grid">
|
||
<!-- CPU -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">⚡ CPU</span>
|
||
<span class="vital-value" id="cpu-percent">-%</span>
|
||
</div>
|
||
<div
|
||
id="cpu-chip"
|
||
style="font-size: 0.7rem; color: var(--accent); margin-bottom: 8px"
|
||
></div>
|
||
<div class="vital-bar">
|
||
<div class="vital-bar-fill blue" id="cpu-bar" style="width: 0%"></div>
|
||
</div>
|
||
<div class="vital-detail" style="margin-bottom: 8px">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cpu-user">-%</span>
|
||
<span class="vital-detail-label">user</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cpu-sys">-%</span>
|
||
<span class="vital-detail-label">sys</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cpu-idle">-%</span>
|
||
<span class="vital-detail-label">idle</span>
|
||
</div>
|
||
</div>
|
||
<div class="vital-detail">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cpu-load-1">-</span>
|
||
<span class="vital-detail-label">1m avg</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cpu-load-5">-</span>
|
||
<span class="vital-detail-label">5m avg</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cpu-load-15">-</span>
|
||
<span class="vital-detail-label">15m avg</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cpu-cores">-</span>
|
||
<span class="vital-detail-label">cores</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
id="cpu-topology"
|
||
style="
|
||
margin-top: 8px;
|
||
font-size: 0.7rem;
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
"
|
||
></div>
|
||
</div>
|
||
|
||
<!-- Memory -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">🧠 Memory</span>
|
||
<span class="vital-value" id="mem-percent"
|
||
>-% <small style="font-size: 0.6em; opacity: 0.7">used</small></span
|
||
>
|
||
</div>
|
||
<div class="vital-bar">
|
||
<div class="vital-bar-fill green" id="mem-bar" style="width: 0%"></div>
|
||
</div>
|
||
<div class="vital-detail">
|
||
<div class="vital-detail-item" style="flex: 2">
|
||
<span class="vital-detail-value" id="mem-summary">-</span>
|
||
<span class="vital-detail-label">used of total</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="mem-free">-</span>
|
||
<span class="vital-detail-label">available</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="vital-detail"
|
||
style="margin-top: 8px; border-top: 1px solid var(--border); padding-top: 8px"
|
||
>
|
||
<div class="vital-detail-item" title="Memory actively used by apps">
|
||
<span class="vital-detail-value" id="mem-active">-</span>
|
||
<span class="vital-detail-label">App</span>
|
||
</div>
|
||
<div
|
||
class="vital-detail-item"
|
||
title="Memory that can't be swapped (kernel, drivers)"
|
||
>
|
||
<span class="vital-detail-value" id="mem-wired">-</span>
|
||
<span class="vital-detail-label">Wired</span>
|
||
</div>
|
||
<div class="vital-detail-item" title="Memory compressed to save space">
|
||
<span class="vital-detail-value" id="mem-compressed">-</span>
|
||
<span class="vital-detail-label">Compressed</span>
|
||
</div>
|
||
<div class="vital-detail-item" title="Recently-used data, can be reclaimed">
|
||
<span class="vital-detail-value" id="mem-cached">-</span>
|
||
<span class="vital-detail-label">Cached</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 8px; text-align: center">
|
||
<span class="pressure-indicator normal" id="mem-pressure">Normal</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Disk -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">💾 Disk</span>
|
||
<span class="vital-value" id="disk-percent"
|
||
>-% <small style="font-size: 0.6em; opacity: 0.7">used</small></span
|
||
>
|
||
</div>
|
||
<div class="vital-bar">
|
||
<div class="vital-bar-fill green" id="disk-bar" style="width: 0%"></div>
|
||
</div>
|
||
<div class="vital-detail">
|
||
<div class="vital-detail-item" style="flex: 2">
|
||
<span class="vital-detail-value" id="disk-summary">-</span>
|
||
<span class="vital-detail-label">used of total</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="disk-free">-</span>
|
||
<span class="vital-detail-label">available</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="vital-detail"
|
||
style="margin-top: 8px; border-top: 1px solid var(--border); padding-top: 8px"
|
||
>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="disk-iops">-</span>
|
||
<span class="vital-detail-label">IOPS</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="disk-throughput">-</span>
|
||
<span class="vital-detail-label">MB/s</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="disk-kbt">-</span>
|
||
<span class="vital-detail-label">KB/t</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Temperature -->
|
||
<div class="vital-card" id="temp-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">🌡️ Temperature</span>
|
||
</div>
|
||
<div class="temp-display">
|
||
<span class="temp-value" id="temp-value">-</span>
|
||
<span class="temp-unit">°C</span>
|
||
</div>
|
||
<div class="vital-detail" style="margin-top: 8px">
|
||
<span id="temp-status" style="font-size: 0.75rem; color: var(--text-muted)"
|
||
>Checking...</span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token Utilization Widget -->
|
||
<div class="utilization-section">
|
||
<div class="utilization-card" id="anthropic-quota">
|
||
<div class="utilization-header">
|
||
<div class="utilization-icon anthropic">🧠</div>
|
||
<div>
|
||
<div class="utilization-title">Claude (Anthropic)</div>
|
||
<div class="utilization-subtitle">API Usage</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="quota-stats">
|
||
<span>Session Usage</span>
|
||
<span
|
||
><span class="quota-value" id="claude-compact-session-pct">-%</span> used</span
|
||
>
|
||
</div>
|
||
<div class="quota-bar">
|
||
<div
|
||
class="quota-bar-fill low"
|
||
id="claude-compact-session-bar"
|
||
style="width: 0%"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 8px">
|
||
<div class="quota-stats">
|
||
<span>Weekly Usage</span>
|
||
<span><span class="quota-value" id="claude-compact-week-pct">-%</span> used</span>
|
||
</div>
|
||
<div class="quota-bar">
|
||
<div
|
||
class="quota-bar-fill low"
|
||
id="claude-compact-week-bar"
|
||
style="width: 0%"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="utilization-card" id="openai-quota">
|
||
<div class="utilization-header">
|
||
<div class="utilization-icon openai">⚡</div>
|
||
<div>
|
||
<div class="utilization-title">Codex (OpenAI)</div>
|
||
<div class="utilization-subtitle">ChatGPT Plus</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="quota-stats">
|
||
<span>5-Hour Usage</span>
|
||
<span><span class="quota-value" id="codex-5h-pct">0%</span> used</span>
|
||
</div>
|
||
<div class="quota-bar">
|
||
<div class="quota-bar-fill low" id="codex-5h-bar" style="width: 0%"></div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 8px">
|
||
<div class="quota-stats">
|
||
<span>Daily Usage</span>
|
||
<span><span class="quota-value" id="codex-day-pct">0%</span> used</span>
|
||
</div>
|
||
<div class="quota-bar">
|
||
<div class="quota-bar-fill low" id="codex-day-bar" style="width: 0%"></div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
style="
|
||
margin-top: 16px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--border);
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
"
|
||
>
|
||
<div style="display: flex; justify-content: space-between">
|
||
<span>Tasks Today</span>
|
||
<span id="codex-tasks">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Actions -->
|
||
<div class="quick-actions">
|
||
<div class="quick-actions-title" data-i18n="quickActions.title">⚡ Quick Actions</div>
|
||
<button
|
||
class="quick-action-btn"
|
||
onclick="runHealthCheck()"
|
||
data-i18n="quickActions.healthCheck"
|
||
>
|
||
🔍 Health Check
|
||
</button>
|
||
<button
|
||
class="quick-action-btn"
|
||
onclick="getGatewayStatus()"
|
||
data-i18n="quickActions.gatewayStatus"
|
||
>
|
||
🚪 Gateway Status
|
||
</button>
|
||
<button class="quick-action-btn" onclick="pruneStalesSessions()">
|
||
<span data-i18n="quickActions.cleanStale">🧹 Clean Stale Sessions</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Sub-agent Status Panel -->
|
||
<div class="section" id="subagents-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
🦞 Active Sub-agents
|
||
<span class="section-count" id="subagent-count">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="subagent-grid" id="subagent-grid">
|
||
<!-- Sub-agents populated by JavaScript -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LLM Usage Section -->
|
||
<div class="section" id="llm-section">
|
||
<div class="vitals-panel">
|
||
<div class="vitals-header">
|
||
<div class="vitals-title">
|
||
⛽ LLM Fuel Gauges
|
||
<span class="vitals-hostname" id="llm-sync-time">-</span>
|
||
</div>
|
||
<div class="vitals-uptime">Routing: <span id="llm-routing-summary">-</span></div>
|
||
</div>
|
||
<div class="vitals-grid">
|
||
<!-- Claude Session -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">🔮 Session Limit</span>
|
||
<span class="vital-value" id="claude-session-pct"
|
||
>-% <small style="font-size: 0.6em; opacity: 0.7">used</small></span
|
||
>
|
||
</div>
|
||
<div class="vital-bar">
|
||
<div
|
||
class="vital-bar-fill purple"
|
||
id="claude-session-bar"
|
||
style="width: 0%"
|
||
></div>
|
||
</div>
|
||
<div class="vital-detail">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="claude-session-remaining">-%</span>
|
||
<span class="vital-detail-label">remaining</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="claude-session-reset">-</span>
|
||
<span class="vital-detail-label">resets in</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Claude Weekly -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">📅 Weekly (All Models)</span>
|
||
<span class="vital-value" id="claude-weekly-pct"
|
||
>-% <small style="font-size: 0.6em; opacity: 0.7">used</small></span
|
||
>
|
||
</div>
|
||
<div class="vital-bar">
|
||
<div class="vital-bar-fill blue" id="claude-weekly-bar" style="width: 0%"></div>
|
||
</div>
|
||
<div class="vital-detail">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="claude-weekly-remaining">-%</span>
|
||
<span class="vital-detail-label">remaining</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="claude-weekly-reset">-</span>
|
||
<span class="vital-detail-label">resets in</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sonnet Weekly -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">✨ Sonnet Weekly</span>
|
||
<span class="vital-value" id="sonnet-weekly-pct"
|
||
>-% <small style="font-size: 0.6em; opacity: 0.7">used</small></span
|
||
>
|
||
</div>
|
||
<div class="vital-bar">
|
||
<div class="vital-bar-fill green" id="sonnet-weekly-bar" style="width: 0%"></div>
|
||
</div>
|
||
<div class="vital-detail">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="sonnet-weekly-remaining">-%</span>
|
||
<span class="vital-detail-label">remaining</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="sonnet-weekly-reset">-</span>
|
||
<span class="vital-detail-label">resets</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LLM Routing -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">🦞 Task Routing</span>
|
||
<span class="vital-value" id="total-routed-tasks">-</span>
|
||
</div>
|
||
<div class="vital-detail" style="margin-top: 12px">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="claude-task-count">-</span>
|
||
<span class="vital-detail-label">Claude</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="codex-task-count">-</span>
|
||
<span class="vital-detail-label">Codex</span>
|
||
</div>
|
||
</div>
|
||
<div class="vital-detail" style="margin-top: 8px">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="llama-task-count">-</span>
|
||
<span class="vital-detail-label">🦙 Llama</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="qwen-task-count">-</span>
|
||
<span class="vital-detail-label">🐱 Qwen</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
style="margin-top: 8px; text-align: center"
|
||
title="Average routing latency (classification + execution)"
|
||
>
|
||
<span class="pressure-indicator" id="routing-latency">Avg latency: -</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sessions Section -->
|
||
<div class="section" id="sessions-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
📡 Sessions <span class="section-count" id="session-count">0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Session Filters -->
|
||
<div class="section-filters">
|
||
<div class="filter-group">
|
||
<span class="filter-label">Status:</span>
|
||
<button
|
||
class="filter-btn active"
|
||
data-filter="all"
|
||
data-group="session-status"
|
||
onclick="setSessionFilter('status', 'all')"
|
||
>
|
||
All <span class="filter-count" id="filter-all-count">0</span>
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="live"
|
||
data-group="session-status"
|
||
onclick="setSessionFilter('status', 'live')"
|
||
>
|
||
<span class="filter-dot live"></span>
|
||
Live <span class="filter-count" id="filter-live-count">0</span>
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="recent"
|
||
data-group="session-status"
|
||
onclick="setSessionFilter('status', 'recent')"
|
||
>
|
||
<span class="filter-dot recent"></span>
|
||
Recent <span class="filter-count" id="filter-recent-count">0</span>
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="idle"
|
||
data-group="session-status"
|
||
onclick="setSessionFilter('status', 'idle')"
|
||
>
|
||
<span class="filter-dot idle"></span>
|
||
Idle <span class="filter-count" id="filter-idle-count">0</span>
|
||
</button>
|
||
</div>
|
||
<div class="filter-group">
|
||
<span class="filter-label">Channel:</span>
|
||
<button
|
||
class="filter-btn active"
|
||
data-filter="all"
|
||
data-group="session-channel"
|
||
onclick="setSessionFilter('channel', 'all')"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="slack"
|
||
data-group="session-channel"
|
||
onclick="setSessionFilter('channel', 'slack')"
|
||
>
|
||
💬 Slack
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="telegram"
|
||
data-group="session-channel"
|
||
onclick="setSessionFilter('channel', 'telegram')"
|
||
>
|
||
📱 Telegram
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="discord"
|
||
data-group="session-channel"
|
||
onclick="setSessionFilter('channel', 'discord')"
|
||
>
|
||
🎮 Discord
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="signal"
|
||
data-group="session-channel"
|
||
onclick="setSessionFilter('channel', 'signal')"
|
||
>
|
||
🔒 Signal
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="whatsapp"
|
||
data-group="session-channel"
|
||
onclick="setSessionFilter('channel', 'whatsapp')"
|
||
>
|
||
📲 WhatsApp
|
||
</button>
|
||
</div>
|
||
<div class="filter-group">
|
||
<span class="filter-label">Kind:</span>
|
||
<button
|
||
class="filter-btn active"
|
||
data-filter="all"
|
||
data-group="session-kind"
|
||
onclick="setSessionFilter('kind', 'all')"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="main"
|
||
data-group="session-kind"
|
||
onclick="setSessionFilter('kind', 'main')"
|
||
>
|
||
Main
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="subagent"
|
||
data-group="session-kind"
|
||
onclick="setSessionFilter('kind', 'subagent')"
|
||
>
|
||
Subagent
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="cron"
|
||
data-group="session-kind"
|
||
onclick="setSessionFilter('kind', 'cron')"
|
||
>
|
||
⏰ Cron
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-grid" id="sessions">
|
||
<!-- Skeleton loaders (replaced when data loads) -->
|
||
<div class="skeleton-card">
|
||
<div class="skeleton-header">
|
||
<div class="skeleton-circle"></div>
|
||
<div style="flex: 1">
|
||
<div class="skeleton-bar title"></div>
|
||
<div class="skeleton-bar subtitle"></div>
|
||
</div>
|
||
<div class="skeleton-badge"></div>
|
||
</div>
|
||
<div class="skeleton-bar text"></div>
|
||
<div class="skeleton-bar text short"></div>
|
||
</div>
|
||
<div class="skeleton-card">
|
||
<div class="skeleton-header">
|
||
<div class="skeleton-circle"></div>
|
||
<div style="flex: 1">
|
||
<div class="skeleton-bar title"></div>
|
||
<div class="skeleton-bar subtitle"></div>
|
||
</div>
|
||
<div class="skeleton-badge"></div>
|
||
</div>
|
||
<div class="skeleton-bar text"></div>
|
||
<div class="skeleton-bar text short"></div>
|
||
</div>
|
||
<div class="skeleton-card">
|
||
<div class="skeleton-header">
|
||
<div class="skeleton-circle"></div>
|
||
<div style="flex: 1">
|
||
<div class="skeleton-bar title"></div>
|
||
<div class="skeleton-bar subtitle"></div>
|
||
</div>
|
||
<div class="skeleton-badge"></div>
|
||
</div>
|
||
<div class="skeleton-bar text"></div>
|
||
<div class="skeleton-bar text short"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pagination Controls -->
|
||
<div class="pagination" id="sessions-pagination" style="display: none">
|
||
<button class="pagination-btn" id="pagination-prev" onclick="changePage(-1)" disabled>
|
||
← Prev
|
||
</button>
|
||
<div class="pagination-pages" id="pagination-pages"></div>
|
||
<button class="pagination-btn" id="pagination-next" onclick="changePage(1)">
|
||
Next →
|
||
</button>
|
||
<div class="pagination-info" id="pagination-info"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cron Jobs Section -->
|
||
<div class="section" id="cron-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
⏰ Cron Jobs <span class="section-count" id="cron-count">0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cron Filters -->
|
||
<div class="section-filters">
|
||
<div class="filter-group">
|
||
<span class="filter-label">Status:</span>
|
||
<button
|
||
class="filter-btn active"
|
||
data-filter="all"
|
||
data-group="cron-status"
|
||
onclick="setCronFilter('status', 'all')"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="enabled"
|
||
data-group="cron-status"
|
||
onclick="setCronFilter('status', 'enabled')"
|
||
>
|
||
✅ Enabled
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="disabled"
|
||
data-group="cron-status"
|
||
onclick="setCronFilter('status', 'disabled')"
|
||
>
|
||
⏸️ Disabled
|
||
</button>
|
||
</div>
|
||
<div class="filter-group">
|
||
<span class="filter-label">Schedule:</span>
|
||
<button
|
||
class="filter-btn active"
|
||
data-filter="all"
|
||
data-group="cron-schedule"
|
||
onclick="setCronFilter('schedule', 'all')"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="frequent"
|
||
data-group="cron-schedule"
|
||
onclick="setCronFilter('schedule', 'frequent')"
|
||
>
|
||
⚡ Frequent (<1h)
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="daily"
|
||
data-group="cron-schedule"
|
||
onclick="setCronFilter('schedule', 'daily')"
|
||
>
|
||
📅 Daily
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="weekly"
|
||
data-group="cron-schedule"
|
||
onclick="setCronFilter('schedule', 'weekly')"
|
||
>
|
||
📆 Weekly
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-grid" id="cron-jobs">
|
||
<!-- Skeleton loaders (replaced when data loads) -->
|
||
<div class="skeleton-card" style="display: flex; gap: 12px; align-items: center">
|
||
<div class="skeleton-circle"></div>
|
||
<div style="flex: 1">
|
||
<div class="skeleton-bar title" style="width: 50%"></div>
|
||
<div class="skeleton-bar subtitle" style="width: 70%"></div>
|
||
</div>
|
||
<div class="skeleton-bar short" style="width: 60px"></div>
|
||
</div>
|
||
<div class="skeleton-card" style="display: flex; gap: 12px; align-items: center">
|
||
<div class="skeleton-circle"></div>
|
||
<div style="flex: 1">
|
||
<div class="skeleton-bar title" style="width: 50%"></div>
|
||
<div class="skeleton-bar subtitle" style="width: 70%"></div>
|
||
</div>
|
||
<div class="skeleton-bar short" style="width: 60px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Memory Section -->
|
||
<div class="section" id="memory-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
🧠 Memory <span class="section-count" id="memory-count">0 files</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Memory Filters -->
|
||
<div class="section-filters">
|
||
<div class="filter-group">
|
||
<span class="filter-label">Type:</span>
|
||
<button
|
||
class="filter-btn active"
|
||
data-filter="all"
|
||
data-group="memory-type"
|
||
onclick="setMemoryFilter('type', 'all')"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="daily"
|
||
data-group="memory-type"
|
||
onclick="setMemoryFilter('type', 'daily')"
|
||
>
|
||
📅 Daily Logs
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="state"
|
||
data-group="memory-type"
|
||
onclick="setMemoryFilter('type', 'state')"
|
||
>
|
||
📊 State Files
|
||
</button>
|
||
</div>
|
||
<div class="filter-group">
|
||
<span class="filter-label">Age:</span>
|
||
<button
|
||
class="filter-btn active"
|
||
data-filter="all"
|
||
data-group="memory-age"
|
||
onclick="setMemoryFilter('age', 'all')"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="today"
|
||
data-group="memory-age"
|
||
onclick="setMemoryFilter('age', 'today')"
|
||
>
|
||
Today
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="week"
|
||
data-group="memory-age"
|
||
onclick="setMemoryFilter('age', 'week')"
|
||
>
|
||
This Week
|
||
</button>
|
||
<button
|
||
class="filter-btn"
|
||
data-filter="older"
|
||
data-group="memory-age"
|
||
onclick="setMemoryFilter('age', 'older')"
|
||
>
|
||
Older
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vitals-panel">
|
||
<div class="vitals-grid">
|
||
<!-- MEMORY.md -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">📜 MEMORY.md</span>
|
||
</div>
|
||
<div class="vital-detail" style="margin-top: 8px">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="memory-md-size">-</span>
|
||
<span class="vital-detail-label">size</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="memory-md-lines">-</span>
|
||
<span class="vital-detail-label">lines</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 12px; font-size: 0.75rem; color: var(--text-muted)">
|
||
Long-term curated memories
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Daily Files -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">📅 Daily Notes</span>
|
||
</div>
|
||
<div class="vital-detail" style="margin-top: 8px">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="memory-total-files">-</span>
|
||
<span class="vital-detail-label">files</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="memory-total-size">-</span>
|
||
<span class="vital-detail-label">total</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 12px; font-size: 0.75rem; color: var(--text-muted)">
|
||
Raw logs by date
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent Files -->
|
||
<div class="vital-card" style="grid-column: span 2">
|
||
<div class="vital-header">
|
||
<span class="vital-label">🕐 Recent Memory Files</span>
|
||
</div>
|
||
<div id="memory-recent-files" style="margin-top: 8px">
|
||
<em style="color: var(--text-muted); font-size: 0.8rem">Loading...</em>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style="
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border);
|
||
text-align: center;
|
||
"
|
||
>
|
||
<span style="font-size: 0.75rem; color: var(--text-muted)">
|
||
🔒 Memory editing UI coming soon (Inside Out style!)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cerebro Section -->
|
||
<div class="section" id="cerebro-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
🔮 Cerebro <span class="section-count" id="cerebro-count">0 topics</span>
|
||
</div>
|
||
<!-- SSE provides live updates -->
|
||
</div>
|
||
|
||
<div id="cerebro-content">
|
||
<!-- Not Initialized State -->
|
||
<div id="cerebro-not-initialized" class="vitals-panel" style="display: none">
|
||
<div style="text-align: center; padding: 40px 20px">
|
||
<div style="font-size: 3rem; margin-bottom: 16px">🔮</div>
|
||
<h3 style="font-size: 1.2rem; font-weight: 600; margin-bottom: 8px">
|
||
Cerebro Not Initialized
|
||
</h3>
|
||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 20px">
|
||
Cerebro tracks conversation topics and threads across sessions.
|
||
</p>
|
||
<div
|
||
id="cerebro-init-commands"
|
||
style="
|
||
background: var(--bg);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
font-family: monospace;
|
||
"
|
||
>
|
||
<div style="color: var(--text-muted); margin-bottom: 8px">
|
||
# To initialize Cerebro:
|
||
</div>
|
||
<div style="color: var(--accent)" id="cerebro-init-topics-cmd">
|
||
mkdir -p ~/cerebro/topics
|
||
</div>
|
||
<div style="color: var(--accent)" id="cerebro-init-orphans-cmd">
|
||
mkdir -p ~/cerebro/orphans
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Initialized State -->
|
||
<div id="cerebro-initialized" class="vitals-panel" style="display: none">
|
||
<div class="vitals-grid">
|
||
<!-- Topic Counts -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">📊 Topics by Status</span>
|
||
</div>
|
||
<div class="vital-detail" style="margin-top: 8px">
|
||
<div class="vital-detail-item">
|
||
<span
|
||
class="vital-detail-value"
|
||
id="cerebro-active"
|
||
style="color: var(--green)"
|
||
>0</span
|
||
>
|
||
<span class="vital-detail-label">active</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span
|
||
class="vital-detail-value"
|
||
id="cerebro-resolved"
|
||
style="color: var(--accent)"
|
||
>0</span
|
||
>
|
||
<span class="vital-detail-label">resolved</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span
|
||
class="vital-detail-value"
|
||
id="cerebro-parked"
|
||
style="color: var(--text-muted)"
|
||
>0</span
|
||
>
|
||
<span class="vital-detail-label">parked</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 12px; font-size: 0.75rem; color: var(--text-muted)">
|
||
<span id="cerebro-total-topics">0</span> total topics
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Thread Stats -->
|
||
<div class="vital-card">
|
||
<div class="vital-header">
|
||
<span class="vital-label">🧵 Threads</span>
|
||
</div>
|
||
<div class="vital-detail" style="margin-top: 8px">
|
||
<div class="vital-detail-item">
|
||
<span class="vital-detail-value" id="cerebro-threads">0</span>
|
||
<span class="vital-detail-label">tracked</span>
|
||
</div>
|
||
<div class="vital-detail-item">
|
||
<span
|
||
class="vital-detail-value"
|
||
id="cerebro-orphans"
|
||
style="color: var(--yellow)"
|
||
>0</span
|
||
>
|
||
<span class="vital-detail-label">orphans</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 12px; font-size: 0.75rem; color: var(--text-muted)">
|
||
Threads linked to topics
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent Topics -->
|
||
<div class="vital-card" style="grid-column: span 2">
|
||
<div class="vital-header">
|
||
<span class="vital-label">🕐 Recent Active Topics</span>
|
||
</div>
|
||
<div id="cerebro-recent-topics" style="margin-top: 8px">
|
||
<em style="color: var(--text-muted); font-size: 0.8rem"
|
||
>No active topics yet</em
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style="
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
"
|
||
>
|
||
<span style="font-size: 0.75rem; color: var(--text-muted)">
|
||
Last updated: <span id="cerebro-last-updated">-</span>
|
||
</span>
|
||
<a
|
||
href="file://#"
|
||
style="font-size: 0.75rem; color: var(--accent); text-decoration: none"
|
||
>
|
||
📁 Open cerebro/
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operators Section -->
|
||
<div class="section" id="operators-section">
|
||
<div class="section-header">
|
||
<div class="section-title">👥 Operators</div>
|
||
<div class="section-actions">
|
||
<button class="btn-secondary" onclick="refreshOperators()" title="Refresh">🔄</button>
|
||
</div>
|
||
</div>
|
||
<div class="operators-grid" id="operators-grid">
|
||
<div class="loading-state">Loading operators...</div>
|
||
</div>
|
||
|
||
<div
|
||
class="roles-legend"
|
||
style="margin-top: 20px; padding: 16px; background: var(--bg); border-radius: 8px"
|
||
>
|
||
<div
|
||
style="
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
margin-bottom: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
"
|
||
>
|
||
Permission Levels
|
||
</div>
|
||
<div style="display: flex; gap: 24px; flex-wrap: wrap">
|
||
<div style="display: flex; align-items: center; gap: 8px">
|
||
<span
|
||
style="
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #ffd700, #ff8c00);
|
||
"
|
||
></span>
|
||
<span style="font-size: 0.85rem">Owner</span>
|
||
<span style="font-size: 0.7rem; color: var(--text-muted)">Full control</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px">
|
||
<span
|
||
style="
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #a371f7, #8957e5);
|
||
"
|
||
></span>
|
||
<span style="font-size: 0.85rem">Admin</span>
|
||
<span style="font-size: 0.7rem; color: var(--text-muted)"
|
||
>Manage users & settings</span
|
||
>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px">
|
||
<span
|
||
style="
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #58a6ff, #388bfd);
|
||
"
|
||
></span>
|
||
<span style="font-size: 0.85rem">User</span>
|
||
<span style="font-size: 0.7rem; color: var(--text-muted)">Dashboard access</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- About Section -->
|
||
<div class="section" id="about-section">
|
||
<div class="section-header">
|
||
<div class="section-title">ℹ️ About</div>
|
||
</div>
|
||
<div class="vitals-panel">
|
||
<div style="text-align: center; padding: 20px 0">
|
||
<div style="font-size: 3rem; margin-bottom: 12px">🦞</div>
|
||
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px">
|
||
OpenClaw Command Center
|
||
</h2>
|
||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 20px">
|
||
A Starcraft-inspired dashboard for OpenClaw orchestration
|
||
</p>
|
||
|
||
<div style="display: flex; justify-content: center; gap: 12px; margin-bottom: 24px">
|
||
<span
|
||
style="
|
||
padding: 4px 12px;
|
||
background: var(--bg);
|
||
border-radius: 12px;
|
||
font-size: 0.75rem;
|
||
"
|
||
id="app-version"
|
||
>v...</span
|
||
>
|
||
<span
|
||
style="
|
||
padding: 4px 12px;
|
||
background: rgba(63, 185, 80, 0.15);
|
||
color: var(--green);
|
||
border-radius: 12px;
|
||
font-size: 0.75rem;
|
||
"
|
||
>MIT License</span
|
||
>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 24px">
|
||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 8px">
|
||
BUILT BY
|
||
</div>
|
||
<a
|
||
href="https://github.com/jontsai"
|
||
target="_blank"
|
||
style="color: var(--accent); text-decoration: none; font-size: 1rem"
|
||
>
|
||
Jonathan Tsai (@jontsai)
|
||
</a>
|
||
</div>
|
||
|
||
<div style="display: flex; justify-content: center; gap: 16px; flex-wrap: wrap">
|
||
<a
|
||
href="https://github.com/jontsai/openclaw-command-center"
|
||
target="_blank"
|
||
class="link-tag github"
|
||
style="padding: 8px 16px"
|
||
>
|
||
🐙 GitHub
|
||
</a>
|
||
<a
|
||
href="https://x.com/jontsai"
|
||
target="_blank"
|
||
class="link-tag"
|
||
style="padding: 8px 16px"
|
||
>
|
||
🐦 Twitter
|
||
</a>
|
||
<a
|
||
href="https://openclaw.ai"
|
||
target="_blank"
|
||
class="link-tag"
|
||
style="padding: 8px 16px"
|
||
>
|
||
🦞 OpenClaw
|
||
</a>
|
||
<a
|
||
href="https://clawhub.ai/jontsai/command-center"
|
||
target="_blank"
|
||
class="link-tag"
|
||
style="padding: 8px 16px"
|
||
>
|
||
🔗 ClawHub
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="border-top: 1px solid var(--border); padding-top: 20px; margin-top: 20px">
|
||
<div
|
||
style="
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
margin-bottom: 12px;
|
||
text-align: center;
|
||
"
|
||
>
|
||
INSPIRED BY
|
||
</div>
|
||
<div
|
||
style="
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
"
|
||
>
|
||
<span>🎮 Starcraft</span>
|
||
<span>📊 iStatMenus</span>
|
||
<span>💾 DaisyDisk</span>
|
||
<span>📧 Gmail</span>
|
||
<span>🧠 Inside Out</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<div class="refresh-bar">
|
||
<span id="refresh-mode" data-i18n="connection.realtime">Real-time updates via SSE ⚡</span>
|
||
• Last updated:
|
||
<span id="last-updated"
|
||
><span class="loading-dots"><span></span><span></span><span></span></span
|
||
></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail Panel -->
|
||
<div id="detail-overlay" class="detail-overlay hidden" onclick="closeDetail()"></div>
|
||
<div id="detail-panel" class="detail-panel hidden">
|
||
<div class="detail-header">
|
||
<h2 id="detail-title">Session Details</h2>
|
||
<button class="close-btn" onclick="closeDetail()">✕</button>
|
||
</div>
|
||
<div class="detail-content">
|
||
<div class="detail-section">
|
||
<h3>📊 Overview</h3>
|
||
<div class="detail-box" id="detail-overview"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>📝 Summary</h3>
|
||
<div class="detail-box" id="detail-summary"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>🔗 References</h3>
|
||
<div class="detail-box" id="detail-links"></div>
|
||
</div>
|
||
<div class="detail-section attention">
|
||
<h3>⚠️ Needs Attention</h3>
|
||
<div class="detail-box" id="detail-attention"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>🎯 Key Facts</h3>
|
||
<div class="detail-box" id="detail-facts"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>🔧 Tools Used</h3>
|
||
<div class="detail-box" id="detail-tools"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>💬 Recent Messages</h3>
|
||
<div class="detail-box" id="detail-messages"></div>
|
||
</div>
|
||
<!-- Future: Read-Write Controls -->
|
||
<div class="detail-actions" id="detail-actions" style="display: none">
|
||
<button class="action-btn" disabled title="Coming soon">💬 Send Message</button>
|
||
<button class="action-btn" disabled title="Coming soon">🔄 Refresh</button>
|
||
<button class="action-btn danger" disabled title="Coming soon">🗑️ Clear Session</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Auth Fix Modal -->
|
||
<div id="auth-modal-overlay" class="detail-overlay hidden" onclick="closeAuthModal()"></div>
|
||
<div id="auth-modal" class="detail-panel hidden" style="max-width: 550px">
|
||
<div class="detail-header">
|
||
<h2>🔑 Fix Claude Authentication</h2>
|
||
<button class="close-btn" onclick="closeAuthModal()">✕</button>
|
||
</div>
|
||
<div class="detail-content" id="auth-modal-content">
|
||
<div
|
||
class="detail-section"
|
||
style="
|
||
background: rgba(245, 158, 11, 0.1);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 16px;
|
||
"
|
||
>
|
||
<p style="margin: 0; color: #f59e0b">
|
||
⚠️ Your Claude OAuth token has expired or lacks the required permissions.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h3>Step 1: Refresh Claude Token</h3>
|
||
<div class="detail-box">
|
||
<p>Open Terminal and run:</p>
|
||
<pre
|
||
style="
|
||
background: #1e1e2e;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
font-size: 13px;
|
||
"
|
||
><code>claude setup-token</code></pre>
|
||
<p style="color: #888; font-size: 12px; margin-top: 8px">
|
||
Follow the prompts to authenticate with your Claude account.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h3>Step 2: Update OpenClaw Agent</h3>
|
||
<div class="detail-box">
|
||
<p>Run the onboard wizard to update your agent credentials:</p>
|
||
<pre
|
||
style="
|
||
background: #1e1e2e;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
font-size: 13px;
|
||
"
|
||
><code>openclaw onboard</code></pre>
|
||
<p style="color: #888; font-size: 12px; margin-top: 8px">
|
||
Or manually update the main agent:
|
||
</p>
|
||
<pre
|
||
style="
|
||
background: #1e1e2e;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
font-size: 13px;
|
||
"
|
||
><code>openclaw agents add main</code></pre>
|
||
<p style="color: #888; font-size: 12px; margin-top: 8px">
|
||
Select "Claude Code CLI" when prompted for the auth source.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h3>Step 3: Verify</h3>
|
||
<div class="detail-box">
|
||
<p>Refresh this dashboard or run:</p>
|
||
<pre
|
||
style="
|
||
background: #1e1e2e;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
font-size: 13px;
|
||
"
|
||
><code>openclaw status --usage</code></pre>
|
||
<p style="color: #888; font-size: 12px; margin-top: 8px">
|
||
You should see your usage percentages instead of an auth error.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cost Breakdown Modal -->
|
||
<div id="cost-modal-overlay" class="detail-overlay hidden" onclick="closeCostModal()"></div>
|
||
<div id="cost-modal" class="detail-panel hidden" style="max-width: 500px">
|
||
<div class="detail-header">
|
||
<h2>💰 Cost Breakdown (24h)</h2>
|
||
<button class="close-btn" onclick="closeCostModal()">✕</button>
|
||
</div>
|
||
<div class="detail-content" id="cost-modal-content">
|
||
<div class="detail-section">
|
||
<h3>📊 Token Usage</h3>
|
||
<div class="detail-box" id="cost-tokens"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>💵 Pricing Rates (Claude Opus)</h3>
|
||
<div class="detail-box" id="cost-rates"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>🧮 Calculation</h3>
|
||
<div class="detail-box" id="cost-calculation"></div>
|
||
</div>
|
||
<div
|
||
class="detail-section"
|
||
style="background: rgba(63, 185, 80, 0.1); border-radius: 8px; padding: 16px"
|
||
>
|
||
<h3>✨ Est. Savings</h3>
|
||
<div class="detail-box" id="cost-savings"></div>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>🔥 Top Sessions by Tokens (24h)</h3>
|
||
<div class="detail-box" id="cost-top-sessions" style="font-size: 0.85rem"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operator Modal -->
|
||
<div
|
||
id="operator-modal-overlay"
|
||
class="detail-overlay hidden"
|
||
onclick="closeOperatorModal()"
|
||
></div>
|
||
<div id="operator-modal" class="detail-panel hidden" style="max-width: 500px">
|
||
<div class="detail-header">
|
||
<h2>👤 <span id="operator-modal-name">User Stats</span></h2>
|
||
<button class="close-btn" onclick="closeOperatorModal()">✕</button>
|
||
</div>
|
||
<div class="detail-content" id="operator-modal-content">
|
||
<div class="loading-state">Loading user stats...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Privacy Settings Modal -->
|
||
<div
|
||
id="privacy-modal-overlay"
|
||
class="detail-overlay hidden"
|
||
onclick="closePrivacyModal()"
|
||
></div>
|
||
<div id="privacy-modal" class="detail-panel hidden" style="max-width: 550px">
|
||
<div class="detail-header">
|
||
<h2>🔒 Privacy Settings</h2>
|
||
<button class="close-btn" onclick="closePrivacyModal()">✕</button>
|
||
</div>
|
||
<div class="detail-content" id="privacy-modal-content">
|
||
<div style="margin-bottom: 16px">
|
||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 12px">
|
||
Hide topics and sessions from display for privacy during demos/screenshots. Settings are
|
||
stored in your browser only.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Hidden Topics Section -->
|
||
<div
|
||
style="margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border)"
|
||
>
|
||
<label style="font-size: 0.95rem; font-weight: 600; display: block; margin-bottom: 12px"
|
||
>🏷️ Hidden Topics
|
||
<span id="privacy-topics-count" style="color: var(--text-muted); font-weight: normal"
|
||
>(0)</span
|
||
></label
|
||
>
|
||
<div style="display: flex; gap: 8px; margin-bottom: 12px">
|
||
<input
|
||
type="text"
|
||
id="privacy-add-topic"
|
||
placeholder="Topic name..."
|
||
style="
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--text);
|
||
font-size: 0.9rem;
|
||
"
|
||
/>
|
||
<button class="btn-primary" onclick="addHiddenTopic()">Add</button>
|
||
</div>
|
||
<div id="hidden-topics-list" style="min-height: 30px">
|
||
<em style="color: var(--text-muted); font-size: 0.85rem">No topics hidden</em>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hidden Sessions Section -->
|
||
<div
|
||
style="margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border)"
|
||
>
|
||
<label style="font-size: 0.95rem; font-weight: 600; display: block; margin-bottom: 12px"
|
||
>📋 Hidden Sessions
|
||
<span id="privacy-sessions-count" style="color: var(--text-muted); font-weight: normal"
|
||
>(0)</span
|
||
></label
|
||
>
|
||
<div style="display: flex; gap: 8px; margin-bottom: 12px">
|
||
<input
|
||
type="text"
|
||
id="privacy-add-session"
|
||
placeholder="Session label or key..."
|
||
style="
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--text);
|
||
font-size: 0.9rem;
|
||
"
|
||
/>
|
||
<button class="btn-primary" onclick="addHiddenSession()">Add</button>
|
||
</div>
|
||
<div id="hidden-sessions-list" style="min-height: 30px">
|
||
<em style="color: var(--text-muted); font-size: 0.85rem">No sessions hidden</em>
|
||
</div>
|
||
<p style="color: var(--text-muted); font-size: 0.75rem; margin-top: 8px">
|
||
💡 Tip: You can also click the 👁️ icon on any session card to hide it quickly.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Hidden Cron Jobs Section -->
|
||
<div
|
||
style="margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border)"
|
||
>
|
||
<label style="font-size: 0.95rem; font-weight: 600; display: block; margin-bottom: 12px"
|
||
>⏰ Hidden Cron Jobs
|
||
<span id="privacy-crons-count" style="color: var(--text-muted); font-weight: normal"
|
||
>(0)</span
|
||
></label
|
||
>
|
||
<div style="display: flex; gap: 8px; margin-bottom: 12px">
|
||
<input
|
||
type="text"
|
||
id="privacy-add-cron"
|
||
placeholder="Cron job name..."
|
||
style="
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--text);
|
||
font-size: 0.9rem;
|
||
"
|
||
/>
|
||
<button class="btn-primary" onclick="addHiddenCron()">Add</button>
|
||
</div>
|
||
<div id="hidden-crons-list" style="min-height: 30px">
|
||
<em
|
||
style="color: var(--text-muted); font-size: 0.85rem"
|
||
data-i18n="privacy.noCronHidden"
|
||
>No cron jobs hidden</em
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Display Options Section -->
|
||
<div
|
||
style="margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border)"
|
||
>
|
||
<label style="font-size: 0.95rem; font-weight: 600; display: block; margin-bottom: 12px"
|
||
>🖥️ Display Options</label
|
||
>
|
||
<label
|
||
style="
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
id="privacy-hide-hostname"
|
||
onchange="toggleHideHostname()"
|
||
style="width: 18px; height: 18px; cursor: pointer"
|
||
/>
|
||
<span>Hide system hostname</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div>
|
||
<button class="btn-secondary" onclick="clearAllPrivacySettings()" style="width: 100%">
|
||
Clear All Privacy Settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// State
|
||
// Filter state for each section
|
||
let sessionFilters = { status: "all", channel: "all", kind: "all" };
|
||
let cronFilters = { status: "all", schedule: "all" };
|
||
let memoryFilters = { type: "all", age: "all" };
|
||
let currentFilter = "all"; // Legacy, for backwards compat
|
||
let sessionsData = [];
|
||
let cronData = [];
|
||
let sidebarCollapsed = false;
|
||
let paginationState = {
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: 0,
|
||
totalPages: 0,
|
||
hasNext: false,
|
||
hasPrev: false,
|
||
};
|
||
|
||
const i18nText = (key, params = {}, fallback = key) =>
|
||
window.I18N?.t ? window.I18N.t(key, params, fallback) : fallback;
|
||
|
||
// Utility functions
|
||
function formatTimeAgo(mins) {
|
||
if (mins < 1) return i18nText("time.now", {}, "now");
|
||
if (mins < 60) return `${mins}m`;
|
||
if (mins < 1440) return `${Math.round(mins / 60)}h`;
|
||
return `${Math.round(mins / 1440)}d`;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement("div");
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Safe DOM utilities - defensive wrappers that won't crash on missing elements
|
||
function $safe(id) {
|
||
const el = document.getElementById(id);
|
||
return {
|
||
el,
|
||
exists: !!el,
|
||
text: (val) => {
|
||
if (el) el.textContent = val;
|
||
return this;
|
||
},
|
||
html: (val) => {
|
||
if (el) el.innerHTML = val;
|
||
return this;
|
||
},
|
||
style: (prop, val) => {
|
||
if (el) el.style[prop] = val;
|
||
return this;
|
||
},
|
||
class: (val) => {
|
||
if (el) el.className = val;
|
||
return this;
|
||
},
|
||
addClass: (val) => {
|
||
if (el) el.classList.add(val);
|
||
return this;
|
||
},
|
||
removeClass: (val) => {
|
||
if (el) el.classList.remove(val);
|
||
return this;
|
||
},
|
||
};
|
||
}
|
||
|
||
// Batch safe DOM updates - silently skips missing elements
|
||
function $batch(updates) {
|
||
for (const [id, ops] of Object.entries(updates)) {
|
||
const el = document.getElementById(id);
|
||
if (!el) continue;
|
||
for (const [op, val] of Object.entries(ops)) {
|
||
switch (op) {
|
||
case "text":
|
||
el.textContent = val;
|
||
break;
|
||
case "html":
|
||
el.innerHTML = val;
|
||
break;
|
||
case "class":
|
||
el.className = val;
|
||
break;
|
||
case "width":
|
||
el.style.width = val;
|
||
break;
|
||
default:
|
||
if (op.startsWith("style.")) el.style[op.slice(6)] = val;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Smart DOM update using morphdom - only patches what changed
|
||
function smartUpdate(targetEl, newHtml) {
|
||
if (typeof morphdom === "undefined") {
|
||
// Fallback if morphdom not loaded
|
||
targetEl.innerHTML = newHtml;
|
||
return;
|
||
}
|
||
// Create a temporary container with the new content
|
||
const temp = document.createElement("div");
|
||
temp.innerHTML = newHtml;
|
||
// If target has single child and temp has single child, morph directly
|
||
if (targetEl.children.length === 1 && temp.children.length === 1) {
|
||
morphdom(targetEl.firstElementChild, temp.firstElementChild);
|
||
} else {
|
||
// Otherwise morph the container itself
|
||
morphdom(targetEl, temp, { childrenOnly: true });
|
||
}
|
||
}
|
||
|
||
// Sidebar toggle
|
||
function toggleSidebar() {
|
||
sidebarCollapsed = !sidebarCollapsed;
|
||
document.getElementById("sidebar").classList.toggle("collapsed", sidebarCollapsed);
|
||
document
|
||
.getElementById("main-wrapper")
|
||
.classList.toggle("sidebar-collapsed", sidebarCollapsed);
|
||
$set("sidebar-toggle-btn", "textContent", sidebarCollapsed ? "▶" : "◀");
|
||
}
|
||
|
||
// Navigation
|
||
document.querySelectorAll(".nav-item[data-section]").forEach((item) => {
|
||
item.addEventListener("click", function (e) {
|
||
document.querySelectorAll(".nav-item").forEach((i) => i.classList.remove("active"));
|
||
this.classList.add("active");
|
||
});
|
||
});
|
||
|
||
// Filter functions - Session filters
|
||
function setSessionFilter(group, value) {
|
||
sessionFilters[group] = value;
|
||
currentFilter = value; // Legacy compat
|
||
|
||
// Update button states for this group
|
||
document.querySelectorAll(`[data-group="session-${group}"]`).forEach((btn) => {
|
||
btn.classList.toggle("active", btn.dataset.filter === value);
|
||
});
|
||
|
||
// Status filter is server-side, others are client-side
|
||
if (group === "status") {
|
||
// Reset to page 1 and fetch with new filter (server-side)
|
||
paginationState.page = 1;
|
||
fetchSessions(1);
|
||
} else {
|
||
// Channel/kind filters remain client-side for now
|
||
applySessionFilters();
|
||
}
|
||
}
|
||
|
||
function applySessionFilters() {
|
||
// Client-side filtering for channel and kind only
|
||
// Status is now handled server-side
|
||
document.querySelectorAll(".session-card").forEach((card) => {
|
||
const channel = card.dataset.channel;
|
||
const kind = card.dataset.kind;
|
||
|
||
let showChannel = sessionFilters.channel === "all" || channel === sessionFilters.channel;
|
||
let showKind = sessionFilters.kind === "all" || kind === sessionFilters.kind;
|
||
|
||
card.classList.toggle("hidden-by-filter", !(showChannel && showKind));
|
||
});
|
||
}
|
||
|
||
// Cron filters
|
||
function setCronFilter(group, value) {
|
||
cronFilters[group] = value;
|
||
|
||
document.querySelectorAll(`[data-group="cron-${group}"]`).forEach((btn) => {
|
||
btn.classList.toggle("active", btn.dataset.filter === value);
|
||
});
|
||
applyCronFilters();
|
||
}
|
||
|
||
function applyCronFilters() {
|
||
document.querySelectorAll(".cron-item").forEach((item) => {
|
||
const enabled = item.dataset.enabled === "true";
|
||
const schedule = item.dataset.schedule;
|
||
|
||
let showStatus =
|
||
cronFilters.status === "all" ||
|
||
(cronFilters.status === "enabled" && enabled) ||
|
||
(cronFilters.status === "disabled" && !enabled);
|
||
let showSchedule = cronFilters.schedule === "all" || schedule === cronFilters.schedule;
|
||
|
||
item.classList.toggle("hidden-by-filter", !(showStatus && showSchedule));
|
||
});
|
||
}
|
||
|
||
// Memory filters
|
||
function setMemoryFilter(group, value) {
|
||
memoryFilters[group] = value;
|
||
|
||
document.querySelectorAll(`[data-group="memory-${group}"]`).forEach((btn) => {
|
||
btn.classList.toggle("active", btn.dataset.filter === value);
|
||
});
|
||
applyMemoryFilters();
|
||
}
|
||
|
||
function applyMemoryFilters() {
|
||
document.querySelectorAll(".memory-item").forEach((item) => {
|
||
const type = item.dataset.type;
|
||
const age = item.dataset.age;
|
||
|
||
let showType = memoryFilters.type === "all" || type === memoryFilters.type;
|
||
let showAge = memoryFilters.age === "all" || age === memoryFilters.age;
|
||
|
||
item.classList.toggle("hidden-by-filter", !(showType && showAge));
|
||
});
|
||
}
|
||
|
||
// Legacy filter function (backwards compat)
|
||
function setFilter(filter) {
|
||
setSessionFilter("status", filter);
|
||
}
|
||
|
||
function applyFilter() {
|
||
applySessionFilters();
|
||
}
|
||
|
||
function updateFilterCounts(sessions) {
|
||
const counts = { all: sessions.length, live: 0, recent: 0, idle: 0 };
|
||
sessions.forEach((s) => {
|
||
if (s.active) counts.live++;
|
||
else if (s.recentlyActive) counts.recent++;
|
||
else counts.idle++;
|
||
});
|
||
|
||
$set("filter-all-count", "textContent", counts.all);
|
||
$set("filter-live-count", "textContent", counts.live);
|
||
$set("filter-recent-count", "textContent", counts.recent);
|
||
$set("filter-idle-count", "textContent", counts.idle);
|
||
}
|
||
|
||
// Use server-provided counts for ALL sessions (across all pages)
|
||
function updateFilterCountsFromServer(statusCounts) {
|
||
$set("filter-all-count", "textContent", statusCounts.all || 0);
|
||
$set("filter-live-count", "textContent", statusCounts.live || 0);
|
||
$set("filter-recent-count", "textContent", statusCounts.recent || 0);
|
||
$set("filter-idle-count", "textContent", statusCounts.idle || 0);
|
||
}
|
||
|
||
// Extract links from text
|
||
function extractLinks(text) {
|
||
const links = [];
|
||
|
||
// Linear: JON-123
|
||
const linearMatches = text.match(/\b([A-Z]{2,5}-\d+)\b/g) || [];
|
||
linearMatches.forEach((m) => {
|
||
if (!links.find((l) => l.id === m)) {
|
||
links.push({ type: "linear", id: m, url: `https://linear.app/YOUR_TEAM/issue/${m}` });
|
||
}
|
||
});
|
||
|
||
// GitHub PRs/Issues: #123 or owner/repo#123
|
||
const ghMatches = text.match(/(?:[\w-]+\/[\w-]+)?#\d+/g) || [];
|
||
ghMatches.forEach((m) => {
|
||
if (!links.find((l) => l.id === m)) {
|
||
links.push({ type: "github", id: m, url: null });
|
||
}
|
||
});
|
||
|
||
// File paths: ~/path or /path
|
||
const fileMatches = text.match(/(?:~\/|\/Users\/|\/home\/)[^\s\)\"\']+/g) || [];
|
||
fileMatches.forEach((m) => {
|
||
const short = m.split("/").slice(-2).join("/");
|
||
if (!links.find((l) => l.id === short)) {
|
||
links.push({ type: "file", id: short, path: m });
|
||
}
|
||
});
|
||
|
||
// Slack threads
|
||
if (text.includes("slack") || text.includes("thread")) {
|
||
links.push({ type: "slack", id: "Slack thread", url: null });
|
||
}
|
||
|
||
return links.slice(0, 5);
|
||
}
|
||
|
||
// Fetch sessions with pagination and server-side filtering
|
||
async function fetchSessions(page = 1) {
|
||
// Build URL with status filter
|
||
const statusFilter = sessionFilters.status;
|
||
let url = `/api/sessions?page=${page}&pageSize=${paginationState.pageSize}`;
|
||
if (statusFilter && statusFilter !== "all") {
|
||
url += `&status=${statusFilter}`;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
if (data?.sessions) {
|
||
renderSessions(data.sessions, data.pagination, data.statusCounts);
|
||
if (data.tokenStats) renderTokenStats(data.tokenStats);
|
||
if (data.capacity) renderCapacity(data.capacity);
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
console.error("Failed to fetch sessions:", e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Change page
|
||
function changePage(delta) {
|
||
const newPage = paginationState.page + delta;
|
||
if (newPage < 1 || newPage > paginationState.totalPages) return;
|
||
paginationState.page = newPage;
|
||
fetchSessions(newPage);
|
||
}
|
||
|
||
// Go to specific page
|
||
function goToPage(page) {
|
||
if (page < 1 || page > paginationState.totalPages) return;
|
||
paginationState.page = page;
|
||
fetchSessions(page);
|
||
}
|
||
|
||
// Render pagination controls
|
||
function renderPagination(pagination) {
|
||
if (!pagination || pagination.total <= pagination.pageSize) {
|
||
document.getElementById("sessions-pagination").style.display = "none";
|
||
return;
|
||
}
|
||
|
||
paginationState = { ...paginationState, ...pagination };
|
||
|
||
const paginationEl = document.getElementById("sessions-pagination");
|
||
const pagesEl = document.getElementById("pagination-pages");
|
||
const prevBtn = document.getElementById("pagination-prev");
|
||
const nextBtn = document.getElementById("pagination-next");
|
||
const infoEl = document.getElementById("pagination-info");
|
||
|
||
paginationEl.style.display = "flex";
|
||
|
||
// Update prev/next buttons
|
||
prevBtn.disabled = !pagination.hasPrev;
|
||
nextBtn.disabled = !pagination.hasNext;
|
||
|
||
// Update info
|
||
const start = (pagination.page - 1) * pagination.pageSize + 1;
|
||
const end = Math.min(pagination.page * pagination.pageSize, pagination.total);
|
||
infoEl.textContent = `${start}-${end} of ${pagination.total}`;
|
||
|
||
// Build page buttons (show max 7 pages with ellipsis)
|
||
let pages = [];
|
||
const total = pagination.totalPages;
|
||
const current = pagination.page;
|
||
|
||
if (total <= 7) {
|
||
// Show all pages
|
||
for (let i = 1; i <= total; i++) pages.push(i);
|
||
} else {
|
||
// Show: 1 ... current-1 current current+1 ... total
|
||
pages.push(1);
|
||
if (current > 3) pages.push("...");
|
||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
||
pages.push(i);
|
||
}
|
||
if (current < total - 2) pages.push("...");
|
||
if (total > 1) pages.push(total);
|
||
}
|
||
|
||
smartUpdate(
|
||
pagesEl,
|
||
pages
|
||
.map((p) => {
|
||
if (p === "...") {
|
||
return '<span class="pagination-ellipsis">...</span>';
|
||
}
|
||
const activeClass = p === current ? "active" : "";
|
||
return `<button class="pagination-page ${activeClass}" onclick="goToPage(${p})">${p}</button>`;
|
||
})
|
||
.join(""),
|
||
);
|
||
}
|
||
|
||
// Fetch and render data (progressive loading)
|
||
// Unified state fetch - single API call for all dashboard data
|
||
async function fetchData() {
|
||
try {
|
||
const response = await fetch("/api/state");
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const state = await response.json();
|
||
renderFullState(state);
|
||
} catch (e) {
|
||
console.error("Failed to fetch state:", e);
|
||
// Fallback: try individual fetches if unified fails
|
||
fetchDataLegacy();
|
||
}
|
||
}
|
||
|
||
// Render all widgets from unified state
|
||
function renderFullState(state) {
|
||
if (!state) return;
|
||
|
||
// Update timestamp
|
||
const now = new Date().toLocaleTimeString();
|
||
$set("last-updated", "textContent", now);
|
||
$set(
|
||
"sidebar-updated",
|
||
"textContent",
|
||
i18nText("sidebar.updated", { time: now }, `Updated: ${now}`),
|
||
);
|
||
|
||
// Render each section
|
||
if (state.vitals) renderVitals(state.vitals);
|
||
if (state.sessions) renderSessions(state.sessions, state.pagination, state.statusCounts);
|
||
if (state.tokenStats) renderTokenStats(state.tokenStats);
|
||
if (state.capacity) renderCapacity(state.capacity);
|
||
if (state.operators) renderOperators(state.operators);
|
||
if (state.llmUsage) renderLlmUsage(state.llmUsage);
|
||
if (state.cron) renderCron(state.cron);
|
||
if (state.memory) renderMemory(state.memory);
|
||
if (state.cerebro) renderCerebro(state.cerebro);
|
||
if (state.subagents) renderSubagents(state.subagents);
|
||
}
|
||
|
||
// Legacy fallback - multiple API calls (used if /api/state fails)
|
||
async function fetchDataLegacy() {
|
||
console.log("[Legacy] Falling back to individual API calls");
|
||
|
||
// Fire off all requests in parallel
|
||
const promises = [
|
||
fetch("/api/vitals")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
fetch("/api/sessions")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
fetch("/api/operators")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
fetch("/api/llm-usage")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
fetch("/api/cron")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
fetch("/api/memory")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
fetch("/api/cerebro")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
fetch("/api/subagents")
|
||
.then((r) => r.json())
|
||
.catch(() => null),
|
||
];
|
||
|
||
const [vitals, sessions, operators, llm, cron, memory, cerebro, subagents] =
|
||
await Promise.all(promises);
|
||
|
||
if (vitals?.vitals) renderVitals(vitals.vitals);
|
||
if (sessions?.sessions)
|
||
renderSessions(sessions.sessions, sessions.pagination, sessions.statusCounts);
|
||
if (sessions?.tokenStats) renderTokenStats(sessions.tokenStats);
|
||
if (operators) renderOperators(operators);
|
||
if (llm) renderLlmUsage(llm);
|
||
if (cron?.cron) renderCron(cron.cron);
|
||
if (memory?.memory) renderMemory(memory.memory);
|
||
if (cerebro) renderCerebro(cerebro);
|
||
if (subagents?.subagents) renderSubagents(subagents.subagents);
|
||
|
||
const now = new Date().toLocaleTimeString();
|
||
$set("last-updated", "textContent", now);
|
||
$set(
|
||
"sidebar-updated",
|
||
"textContent",
|
||
i18nText("sidebar.updated", { time: now }, `Updated: ${now}`),
|
||
);
|
||
}
|
||
|
||
// Render just sessions (for progressive loading)
|
||
function renderSessions(sessions, pagination, statusCounts) {
|
||
const sessionsEl = document.getElementById("sessions");
|
||
sessionsData = sessions;
|
||
|
||
// Update pagination state if provided
|
||
if (pagination) {
|
||
paginationState = { ...paginationState, ...pagination };
|
||
renderPagination(pagination);
|
||
}
|
||
|
||
// Always use paginationState.total if we have it (survives SSE updates)
|
||
// Only fall back to sessions.length if we've never received pagination data
|
||
const displayCount = paginationState.total > 0 ? paginationState.total : sessions.length;
|
||
|
||
// Filter out hidden sessions
|
||
const visibleSessions = sessions.filter((s) => !isSessionHidden(s));
|
||
const hiddenSessionsCount = sessions.length - visibleSessions.length;
|
||
|
||
// Show hidden count breakdown if any hidden
|
||
const sessionCountText =
|
||
hiddenSessionsCount > 0
|
||
? `${displayCount} (${hiddenSessionsCount} hidden)`
|
||
: displayCount;
|
||
$set("session-count", "textContent", sessionCountText);
|
||
$set("nav-session-count", "textContent", displayCount);
|
||
|
||
// Use server-provided statusCounts for ALL sessions, not just current page
|
||
// If statusCounts not provided, keep previous values (don't overwrite with wrong counts)
|
||
if (statusCounts) {
|
||
updateFilterCountsFromServer(statusCounts);
|
||
}
|
||
// Removed: fallback to updateFilterCounts(sessions) which only counted current page
|
||
|
||
if (visibleSessions.length === 0) {
|
||
smartUpdate(
|
||
sessionsEl,
|
||
`
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">📡</div>
|
||
<div class="empty-state-text">No active sessions</div>
|
||
</div>
|
||
`,
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
sessionsEl,
|
||
visibleSessions
|
||
.sort((a, b) => a.minutesAgo - b.minutesAgo)
|
||
.map((s) => {
|
||
const iconClass =
|
||
s.channel === "slack" ? "slack" : s.channel === "telegram" ? "telegram" : "main";
|
||
const icon = s.channel === "slack" ? "💬" : s.channel === "telegram" ? "📱" : "🏠";
|
||
const badgeClass = s.active
|
||
? "badge-live"
|
||
: s.recentlyActive
|
||
? "badge-recent"
|
||
: "badge-idle";
|
||
const badgeText = s.active ? "● Live" : formatTimeAgo(s.minutesAgo);
|
||
const tokenClass = s.tokens > 100000 ? "high" : s.tokens > 50000 ? "med" : "";
|
||
const model = s.model?.replace("claude-", "")?.replace("anthropic/", "") || "";
|
||
const cardClass = s.active ? "active" : s.recentlyActive ? "recent-active" : "";
|
||
const status = s.active ? "live" : s.recentlyActive ? "recent" : "idle";
|
||
|
||
// Generate topic pills (filtered by privacy settings)
|
||
let topicHtml = "";
|
||
if (s.topic) {
|
||
const topics = s.topic
|
||
.split(", ")
|
||
.map((t) => t.trim())
|
||
.filter((t) => t && !isTopicHidden(t));
|
||
if (topics.length > 0) {
|
||
const pills = topics
|
||
.map((t) => {
|
||
const className = t.toLowerCase().replace(/[^a-z]/g, "");
|
||
return `<span class="topic-pill ${className}">${escapeHtml(t)}</span>`;
|
||
})
|
||
.join("");
|
||
topicHtml = `<div class="card-topics">${pills}</div>`;
|
||
}
|
||
}
|
||
const previewHtml = s.preview
|
||
? `<div class="card-preview">${escapeHtml(s.preview)}</div>`
|
||
: "";
|
||
// Cerebro tag (filtered by privacy settings)
|
||
const cerebroTagHtml =
|
||
s.cerebroTopic && !isTopicHidden(s.cerebroTopic)
|
||
? `<span class="cerebro-tag" title="Cerebro topic: ${escapeHtml(s.cerebroTopic)}">🏷️ ${escapeHtml(s.cerebroTopic)}</span>`
|
||
: "";
|
||
|
||
// Originator info
|
||
let originatorHtml = "";
|
||
if (s.originator) {
|
||
const displayName =
|
||
s.originator.displayName || s.originator.username || "Unknown";
|
||
const initial = displayName.charAt(0).toUpperCase();
|
||
originatorHtml = `
|
||
<div class="session-originator">
|
||
<span class="originator-avatar">${initial}</span>
|
||
<span>${escapeHtml(displayName)}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Activity state indicator
|
||
const activityState = s.activityState || {
|
||
state: "idle",
|
||
icon: "💤",
|
||
label: "Idle",
|
||
};
|
||
const activityHtml = `
|
||
<div class="activity-wrapper" title="${activityState.label}">
|
||
<span class="activity-indicator ${activityState.state}">${activityState.icon}</span>
|
||
<span class="activity-label">${activityState.label}</span>
|
||
</div>
|
||
`;
|
||
|
||
// Build metrics bar (fitness tracker style)
|
||
const m = s.metrics || { burnRate: 0, toolCalls: 0, minutesActive: 0 };
|
||
const burnRateClass = m.burnRate > 5000 ? "hot" : "";
|
||
const burnRateFormatted =
|
||
m.burnRate > 1000 ? (m.burnRate / 1000).toFixed(1) + "k" : m.burnRate;
|
||
const timeActiveFormatted =
|
||
m.minutesActive > 60
|
||
? Math.floor(m.minutesActive / 60) + "h " + (m.minutesActive % 60) + "m"
|
||
: m.minutesActive + "m";
|
||
|
||
// Operator metric (clickable to show user stats)
|
||
const operatorName = s.originator?.displayName || s.originator?.username || null;
|
||
const operatorId = s.originator?.userId || s.originator?.id || null;
|
||
const operatorMetricHtml = operatorName
|
||
? `<div class="metric-ring operator" title="User: ${escapeHtml(operatorName)}" onclick="event.stopPropagation(); openOperatorModal('${escapeHtml(operatorId || operatorName)}')">
|
||
<span class="metric-icon">👤</span>
|
||
<span class="metric-value" style="font-size: 0.7rem; max-width: 50px; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(operatorName.split(" ")[0])}</span>
|
||
<span class="metric-label">user</span>
|
||
</div>`
|
||
: `<div class="metric-ring operator" title="Unknown user">
|
||
<span class="metric-icon">👤</span>
|
||
<span class="metric-value">—</span>
|
||
<span class="metric-label">user</span>
|
||
</div>`;
|
||
|
||
const metricsHtml = `
|
||
<div class="metrics-bar">
|
||
<div class="metric-ring burn ${burnRateClass}" title="Token burn rate: ${m.burnRate} tokens/min">
|
||
<span class="metric-icon">🔥</span>
|
||
<span class="metric-value">${burnRateFormatted}</span>
|
||
<span class="metric-label">tok/min</span>
|
||
</div>
|
||
${operatorMetricHtml}
|
||
<div class="metric-ring time" title="Time active: ${m.minutesActive} minutes">
|
||
<span class="metric-icon">⏱️</span>
|
||
<span class="metric-value">${timeActiveFormatted}</span>
|
||
<span class="metric-label">active</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return `
|
||
<div class="session-card ${cardClass}" data-status="${status}" data-channel="${s.channel}" data-kind="${s.sessionType || s.kind}" onclick="openDetail('${escapeHtml(s.sessionKey)}', '${escapeHtml(s.label)}')">
|
||
<div class="card-header">
|
||
<div class="card-icon ${iconClass}">${icon}</div>
|
||
<div class="card-title-area">
|
||
<div class="card-title">${escapeHtml(s.label)}</div>
|
||
<div class="card-subtitle">${s.kind} • ${model}</div>
|
||
${originatorHtml}
|
||
</div>
|
||
${activityHtml}
|
||
<span class="card-badge ${badgeClass}">${badgeText}</span>
|
||
<button class="hide-btn" onclick="event.stopPropagation(); quickHideSession('${escapeHtml(s.sessionKey).replace(/'/g, "\\'")}', '${escapeHtml(s.label).replace(/'/g, "\\'")}');" title="Hide session">👁️</button>
|
||
</div>
|
||
${topicHtml}
|
||
${previewHtml}
|
||
<div class="card-stats">
|
||
${cerebroTagHtml}
|
||
<div class="card-stat ${tokenClass}">
|
||
<span>🎫</span>
|
||
<span class="card-stat-value">${(s.tokens / 1000).toFixed(1)}k</span>
|
||
</div>
|
||
</div>
|
||
${metricsHtml}
|
||
</div>
|
||
`;
|
||
})
|
||
.join(""),
|
||
);
|
||
}
|
||
|
||
applyFilter();
|
||
}
|
||
|
||
// Render just cron jobs (for progressive loading)
|
||
function renderCron(cron) {
|
||
const cronEl = document.getElementById("cron-jobs");
|
||
cronData = cron; // Store for re-rendering after privacy changes
|
||
|
||
// Filter out hidden crons
|
||
const visibleCrons = cron.filter((c) => !isCronHidden(c));
|
||
const hiddenCronsCount = cron.length - visibleCrons.length;
|
||
|
||
// Show hidden count breakdown if any are hidden
|
||
const cronCountText =
|
||
hiddenCronsCount > 0
|
||
? `${cron.length} (${visibleCrons.length} visible, ${hiddenCronsCount} hidden)`
|
||
: cron.length;
|
||
$set("cron-count", "textContent", cronCountText);
|
||
$set("nav-cron-count", "textContent", cron.length);
|
||
|
||
if (visibleCrons.length === 0) {
|
||
smartUpdate(
|
||
cronEl,
|
||
`
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">⏰</div>
|
||
<div class="empty-state-text">No scheduled jobs</div>
|
||
</div>
|
||
`,
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
cronEl,
|
||
visibleCrons
|
||
.map((c) => {
|
||
const humanSchedule = c.scheduleHuman
|
||
? `<div class="cron-schedule-human">${escapeHtml(c.scheduleHuman)}</div>`
|
||
: "";
|
||
return `
|
||
<div class="cron-card ${c.enabled === false ? "disabled" : ""}">
|
||
<div class="cron-icon">⏰</div>
|
||
<div class="cron-info">
|
||
<div class="cron-name">${escapeHtml(c.name)}</div>
|
||
<div class="cron-schedule">${c.schedule}</div>
|
||
${humanSchedule}
|
||
</div>
|
||
<div class="cron-meta">
|
||
<span class="cron-status ${c.enabled !== false ? "enabled" : "disabled"}">
|
||
${c.enabled !== false ? "✓ Enabled" : "○ Disabled"}
|
||
</span>
|
||
<div class="cron-next">${c.nextRun}</div>
|
||
<button class="hide-btn" onclick="event.stopPropagation(); quickHideCron('${escapeHtml(c.id || c.name).replace(/'/g, "\\'")}', '${escapeHtml(c.name).replace(/'/g, "\\'")}');" title="Hide cron job">👁️</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})
|
||
.join(""),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Operators data store
|
||
let operatorsData = [];
|
||
|
||
// Render operators
|
||
function renderOperators(data) {
|
||
const operatorsEl = document.getElementById("operators-grid");
|
||
operatorsData = data.operators || [];
|
||
|
||
$set("nav-operator-count", "textContent", operatorsData.length);
|
||
|
||
if (operatorsData.length === 0) {
|
||
smartUpdate(
|
||
operatorsEl,
|
||
`
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">👥</div>
|
||
<div class="empty-state-text">No operators configured</div>
|
||
<div class="empty-state-hint" style="margin-top: 8px; font-size: 0.8rem; color: var(--text-muted);">
|
||
Operators are auto-detected from session activity
|
||
</div>
|
||
</div>
|
||
`,
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
operatorsEl,
|
||
operatorsData
|
||
.map((op) => {
|
||
const roleClass = op.role || "user";
|
||
const initial = (op.name || op.username || "?").charAt(0).toUpperCase();
|
||
const avatarHtml = op.avatar
|
||
? `<img src="${escapeHtml(op.avatar)}" alt="${escapeHtml(op.name)}">`
|
||
: initial;
|
||
|
||
const activeSessions = op.stats?.activeSessions || 0;
|
||
const totalSessions = op.stats?.totalSessions || 0;
|
||
|
||
let lastSeenText = "Never";
|
||
if (op.stats?.lastSeen) {
|
||
const lastSeen = new Date(op.stats.lastSeen);
|
||
const minutesAgo = Math.floor((Date.now() - lastSeen.getTime()) / 60000);
|
||
lastSeenText =
|
||
minutesAgo < 1
|
||
? i18nText("time.justNow", {}, "Just now")
|
||
: formatTimeAgo(minutesAgo);
|
||
}
|
||
|
||
return `
|
||
<div class="operator-card role-${roleClass}" style="cursor: pointer;" onclick="openOperatorModal('${escapeHtml(op.id || op.username)}')">
|
||
<div class="operator-header">
|
||
<div class="operator-avatar">${avatarHtml}</div>
|
||
<div class="operator-info">
|
||
<div class="operator-name">${escapeHtml(op.name || op.username || "Unknown")}</div>
|
||
<div class="operator-username">@${escapeHtml(op.username || op.id)}</div>
|
||
</div>
|
||
<span class="operator-role ${roleClass}">${roleClass}</span>
|
||
</div>
|
||
<div class="operator-stats">
|
||
<div class="operator-stat">
|
||
<div class="operator-stat-value" style="color: ${activeSessions > 0 ? "var(--green)" : "var(--text-muted)"}">${activeSessions}</div>
|
||
<div class="operator-stat-label">Active</div>
|
||
</div>
|
||
<div class="operator-stat">
|
||
<div class="operator-stat-value">${totalSessions}</div>
|
||
<div class="operator-stat-label">Sessions</div>
|
||
</div>
|
||
<div class="operator-stat">
|
||
<div class="operator-stat-value" style="font-size: 0.9rem;">${lastSeenText}</div>
|
||
<div class="operator-stat-label">Last Seen</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})
|
||
.join(""),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Refresh operators data
|
||
async function refreshOperators() {
|
||
try {
|
||
const res = await fetch("/api/operators");
|
||
const data = await res.json();
|
||
renderOperators(data);
|
||
} catch (e) {
|
||
console.error("Failed to fetch operators:", e);
|
||
}
|
||
}
|
||
|
||
// Store current token stats for window switching
|
||
let currentTokenStats = null;
|
||
let optionalDeps = null;
|
||
|
||
// Get saved savings window preference (default to 7dma for stability)
|
||
function getSavingsWindowPref() {
|
||
return localStorage.getItem("savingsWindow") || "7dma";
|
||
}
|
||
|
||
// Save savings window preference
|
||
function setSavingsWindowPref(window) {
|
||
localStorage.setItem("savingsWindow", window);
|
||
}
|
||
|
||
// Update savings display based on selected window
|
||
function updateSavingsDisplay(stats, windowKey) {
|
||
const savingsStat = document.getElementById("savings-stat");
|
||
const savingsEl = document.getElementById("est-savings");
|
||
|
||
if (!stats || !savingsStat || !savingsEl) return;
|
||
|
||
// Get the window-specific data or fall back to 24h
|
||
const windowData = stats.savingsWindows?.[windowKey] || stats.savingsWindows?.["24h"];
|
||
|
||
if (windowData?.estSavings) {
|
||
savingsEl.textContent = windowData.estSavings;
|
||
savingsEl.title = `${windowData.savingsPercent}% saved vs ${stats.planName} ($${stats.planCost}/mo) [${windowData.label}]`;
|
||
savingsStat.style.display = "block";
|
||
} else if (stats.estSavings) {
|
||
// Fallback to legacy 24h savings
|
||
savingsEl.textContent = stats.estSavings;
|
||
savingsEl.title = `${stats.savingsPercent}% saved vs ${stats.planName} ($${stats.planCost}/mo)`;
|
||
savingsStat.style.display = "block";
|
||
} else {
|
||
savingsStat.style.display = "none";
|
||
}
|
||
}
|
||
|
||
// Render token stats
|
||
function renderTokenStats(stats) {
|
||
if (!stats) return;
|
||
currentTokenStats = stats; // Store for window switching
|
||
|
||
$set("total-tokens", "textContent", stats.total || "0");
|
||
$set("active-sessions", "textContent", stats.activeCount || 0);
|
||
$set("input-tokens", "textContent", stats.input || "0");
|
||
$set("output-tokens", "textContent", stats.output || "0");
|
||
$set("est-cost", "textContent", stats.estCost || "$0.00");
|
||
$set("nav-tokens", "textContent", stats.total || "0");
|
||
$set("nav-cost", "textContent", stats.estCost || "$0.00");
|
||
$set("nav-monthly-cost", "textContent", stats.estMonthlyCost || "-");
|
||
$set("nav-avg-tokens", "textContent", stats.avgTokensPerSession || "-");
|
||
$set("nav-avg-cost", "textContent", stats.avgCostPerSession || "-");
|
||
|
||
// Load preference and update selector
|
||
const savedWindow = getSavingsWindowPref();
|
||
const windowSelect = document.getElementById("savings-window-select");
|
||
if (windowSelect) {
|
||
windowSelect.value = savedWindow;
|
||
}
|
||
|
||
// Show savings for selected window
|
||
updateSavingsDisplay(stats, savedWindow);
|
||
}
|
||
|
||
// Render capacity indicators
|
||
function renderCapacity(capacity) {
|
||
if (!capacity) return;
|
||
const mainEl = document.getElementById("main-capacity");
|
||
const subEl = document.getElementById("subagent-capacity");
|
||
|
||
if (mainEl && capacity.main) {
|
||
const mainPct =
|
||
capacity.main.max > 0 ? (capacity.main.active / capacity.main.max) * 100 : 0;
|
||
const mainColor =
|
||
mainPct > 80 ? "var(--red)" : mainPct > 50 ? "var(--yellow)" : "var(--green)";
|
||
mainEl.innerHTML = `<span style="color:${mainColor}">${capacity.main.active}</span>/${capacity.main.max}`;
|
||
}
|
||
|
||
if (subEl && capacity.subagent) {
|
||
const subPct =
|
||
capacity.subagent.max > 0
|
||
? (capacity.subagent.active / capacity.subagent.max) * 100
|
||
: 0;
|
||
const subColor =
|
||
subPct > 80 ? "var(--red)" : subPct > 50 ? "var(--yellow)" : "var(--green)";
|
||
subEl.innerHTML = `<span style="color:${subColor}">${capacity.subagent.active}</span>/${capacity.subagent.max}`;
|
||
}
|
||
}
|
||
|
||
// Render system info (placeholder)
|
||
function renderSystem(system) {
|
||
// System rendering is handled by full status
|
||
}
|
||
|
||
// Render activity (placeholder)
|
||
function renderActivity(activity) {
|
||
// Activity rendering is handled by full status
|
||
}
|
||
|
||
function renderVitals(vitals) {
|
||
if (!vitals) return;
|
||
|
||
// Hostname & uptime
|
||
$set("vitals-hostname", "textContent", vitals.hostname || "-");
|
||
$set("vitals-uptime", "textContent", vitals.uptime || "-");
|
||
|
||
// CPU
|
||
const cpuPercent = vitals.cpu?.usage || 0;
|
||
$set("cpu-percent", "textContent", cpuPercent + "%");
|
||
$set("cpu-bar", "style.width", cpuPercent + "%");
|
||
|
||
// Color based on load
|
||
const cpuBar = document.getElementById("cpu-bar");
|
||
if (cpuBar) {
|
||
cpuBar.className =
|
||
"vital-bar-fill " + (cpuPercent > 80 ? "red" : cpuPercent > 50 ? "yellow" : "blue");
|
||
}
|
||
|
||
// Chip name
|
||
const chipEl = document.getElementById("cpu-chip");
|
||
if (vitals.cpu?.chip) {
|
||
chipEl.textContent = vitals.cpu.chip;
|
||
} else if (vitals.cpu?.brand) {
|
||
chipEl.textContent = vitals.cpu.brand;
|
||
} else {
|
||
chipEl.textContent = "";
|
||
}
|
||
|
||
// User/sys/idle breakdown
|
||
// Avoid showing "undefined%" when fields are absent.
|
||
const cpuUser = vitals.cpu?.userPercent;
|
||
const cpuSys = vitals.cpu?.sysPercent;
|
||
const cpuIdle = vitals.cpu?.idlePercent;
|
||
$set("cpu-user", "textContent", Number.isFinite(cpuUser) ? cpuUser.toFixed(1) + "%" : "-");
|
||
$set("cpu-sys", "textContent", Number.isFinite(cpuSys) ? cpuSys.toFixed(1) + "%" : "-");
|
||
$set("cpu-idle", "textContent", Number.isFinite(cpuIdle) ? cpuIdle.toFixed(1) + "%" : "-");
|
||
|
||
const loadAvg = vitals.cpu?.loadAvg || [0, 0, 0];
|
||
$set("cpu-load-1", "textContent", loadAvg[0]?.toFixed(2) || "-");
|
||
$set("cpu-load-5", "textContent", loadAvg[1]?.toFixed(2) || "-");
|
||
$set("cpu-load-15", "textContent", loadAvg[2]?.toFixed(2) || "-");
|
||
$set("cpu-cores", "textContent", vitals.cpu?.cores || "-");
|
||
|
||
// Core topology (P-cores + E-cores)
|
||
const topologyEl = document.getElementById("cpu-topology");
|
||
if (vitals.cpu?.pCores && vitals.cpu?.eCores) {
|
||
topologyEl.textContent = `${vitals.cpu.pCores}P + ${vitals.cpu.eCores}E cores`;
|
||
} else {
|
||
topologyEl.textContent = "";
|
||
}
|
||
|
||
// Memory - Show "X% used" with "X of Y" summary
|
||
const memPercent = vitals.memory?.percent || 0;
|
||
$set(
|
||
"mem-percent",
|
||
"innerHTML",
|
||
memPercent + '% <small style="font-size:0.6em;opacity:0.7">used</small>',
|
||
);
|
||
$set("mem-bar", "style.width", memPercent + "%");
|
||
|
||
const memBar = document.getElementById("mem-bar");
|
||
if (memBar) {
|
||
memBar.className =
|
||
"vital-bar-fill " + (memPercent > 90 ? "red" : memPercent > 75 ? "yellow" : "green");
|
||
}
|
||
|
||
// Show "68.8GB of 128GB" format
|
||
const memUsed = vitals.memory?.usedFormatted || "-";
|
||
const memTotal = vitals.memory?.totalFormatted || "-";
|
||
$set("mem-summary", "textContent", memUsed + " of " + memTotal);
|
||
$set("mem-free", "textContent", vitals.memory?.freeFormatted || "-");
|
||
|
||
// Memory pressure
|
||
const pressure = vitals.memory?.pressure || "normal";
|
||
const pressureEl = document.getElementById("mem-pressure");
|
||
if (pressureEl) {
|
||
pressureEl.textContent = pressure.charAt(0).toUpperCase() + pressure.slice(1);
|
||
pressureEl.className = "pressure-indicator " + pressure;
|
||
}
|
||
|
||
// Memory breakdown (active, wired, compressed, cached)
|
||
const formatMemBytes = (bytes) => {
|
||
if (!bytes) return "-";
|
||
const gb = bytes / (1024 * 1024 * 1024);
|
||
return gb >= 1 ? gb.toFixed(1) + " GB" : (gb * 1024).toFixed(0) + " MB";
|
||
};
|
||
$set("mem-active", "textContent", formatMemBytes(vitals.memory?.active));
|
||
$set("mem-wired", "textContent", formatMemBytes(vitals.memory?.wired));
|
||
$set("mem-compressed", "textContent", formatMemBytes(vitals.memory?.compressed));
|
||
$set("mem-cached", "textContent", formatMemBytes(vitals.memory?.cached));
|
||
|
||
// Disk - Show "X% used" with "X of Y" summary
|
||
const diskPercent = vitals.disk?.percent || 0;
|
||
$set(
|
||
"disk-percent",
|
||
"innerHTML",
|
||
diskPercent + '% <small style="font-size:0.6em;opacity:0.7">used</small>',
|
||
);
|
||
$set("disk-bar", "style.width", diskPercent + "%");
|
||
|
||
const diskBar = document.getElementById("disk-bar");
|
||
if (diskBar) {
|
||
diskBar.className =
|
||
"vital-bar-fill " + (diskPercent > 90 ? "red" : diskPercent > 75 ? "yellow" : "green");
|
||
}
|
||
|
||
// Show "700GB of 1.8TB" format
|
||
const diskUsed = vitals.disk?.usedFormatted || "-";
|
||
const diskTotal = vitals.disk?.totalFormatted || "-";
|
||
$set("disk-summary", "textContent", diskUsed + " of " + diskTotal);
|
||
$set("disk-free", "textContent", vitals.disk?.freeFormatted || "-");
|
||
|
||
// Disk I/O stats
|
||
$set("disk-iops", "textContent", vitals.disk?.iops?.toFixed(0) || "0");
|
||
$set("disk-throughput", "textContent", vitals.disk?.throughputMBps?.toFixed(2) || "0.00");
|
||
$set("disk-kbt", "textContent", vitals.disk?.kbPerTransfer?.toFixed(1) || "0.0");
|
||
|
||
// Show dep hint for disk I/O if all zeros and dep missing
|
||
renderDepHint(
|
||
"disk-io-hint",
|
||
"disk-io",
|
||
!vitals.disk?.iops && !vitals.disk?.throughputMBps,
|
||
);
|
||
|
||
// Temperature
|
||
const temp = vitals.temperature;
|
||
const tempNote = vitals.temperatureNote;
|
||
const tempEl = document.getElementById("temp-value");
|
||
const tempStatusEl = document.getElementById("temp-status");
|
||
if (temp !== null && temp !== undefined && temp > 0) {
|
||
if (tempEl) {
|
||
tempEl.textContent = temp;
|
||
const tempColor =
|
||
temp < 50
|
||
? "var(--green)"
|
||
: temp < 70
|
||
? "var(--text)"
|
||
: temp < 85
|
||
? "var(--yellow)"
|
||
: "var(--red)";
|
||
tempEl.style.color = tempColor;
|
||
}
|
||
const tempStatus =
|
||
temp < 50 ? "Cool" : temp < 70 ? "Normal" : temp < 85 ? "Warm" : "Hot!";
|
||
if (tempStatusEl) tempStatusEl.textContent = tempStatus;
|
||
} else {
|
||
if (tempEl) {
|
||
tempEl.textContent = "-";
|
||
tempEl.style.color = "var(--text-muted)";
|
||
}
|
||
if (tempStatusEl) {
|
||
tempStatusEl.textContent = tempNote || "Unavailable";
|
||
}
|
||
// Show dep hint for temperature if unavailable and dep missing
|
||
renderDepHint("temp-hint", "temperature", true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render an inline hint when an optional dependency is missing.
|
||
* Creates/updates a small element showing install instructions.
|
||
*/
|
||
function renderDepHint(elementId, affects, show) {
|
||
let el = document.getElementById(elementId);
|
||
if (!show || !optionalDeps) {
|
||
if (el) el.style.display = "none";
|
||
return;
|
||
}
|
||
const dep = optionalDeps.find((d) => d.affects === affects && !d.installed);
|
||
if (!dep) {
|
||
if (el) el.style.display = "none";
|
||
return;
|
||
}
|
||
if (!el) {
|
||
// Find parent card based on the hint id
|
||
const parentMap = { "disk-io-hint": "disk-iops", "temp-hint": "temp-status" };
|
||
const anchor = document.getElementById(parentMap[elementId]);
|
||
if (!anchor) return;
|
||
const card = anchor.closest(".vital-card");
|
||
if (!card) return;
|
||
el = document.createElement("div");
|
||
el.id = elementId;
|
||
el.style.cssText =
|
||
"margin-top:8px;padding:6px 10px;background:rgba(136,192,208,0.08);border:1px solid rgba(136,192,208,0.2);border-radius:6px;font-size:0.7rem;color:var(--text-muted);line-height:1.4";
|
||
card.appendChild(el);
|
||
}
|
||
el.style.display = "block";
|
||
const action = dep.installCmd
|
||
? '<code style="background:rgba(136,192,208,0.15);padding:1px 5px;border-radius:3px;font-size:0.65rem">' +
|
||
dep.installCmd +
|
||
"</code>"
|
||
: dep.url
|
||
? '<a href="' +
|
||
dep.url +
|
||
'" target="_blank" style="color:var(--accent)">' +
|
||
dep.name +
|
||
"</a>"
|
||
: "see docs";
|
||
el.innerHTML =
|
||
"\u{1F4A1} Install <strong>" +
|
||
dep.name +
|
||
"</strong> for " +
|
||
dep.purpose.toLowerCase() +
|
||
": " +
|
||
action;
|
||
}
|
||
|
||
// Safe DOM helper - sets property only if element exists
|
||
function $set(id, prop, value) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return null;
|
||
if (prop === "innerHTML") el.innerHTML = value;
|
||
else if (prop === "textContent") el.textContent = value;
|
||
else if (prop === "className") el.className = value;
|
||
else if (prop.startsWith("style.")) el.style[prop.slice(6)] = value;
|
||
return el;
|
||
}
|
||
|
||
function renderLlmUsage(data) {
|
||
// Wrap in try-catch to prevent crashes from blocking other renders
|
||
try {
|
||
// Handle auth errors - show clear error state instead of 0%
|
||
if (data?.errorType === "auth" || data?.claude?.session?.error) {
|
||
const errorMsg = "⚠️ Auth Error";
|
||
const clickableError =
|
||
'<a href="#" onclick="openAuthModal(); return false;" style="color:#f59e0b; text-decoration: underline; cursor: pointer;">Auth Error - Click to Fix</a>';
|
||
|
||
// Session card
|
||
$set("claude-session-pct", "innerHTML", '<span style="color:#f59e0b">N/A</span>');
|
||
$set("claude-session-bar", "style.width", "0%");
|
||
$set("claude-session-bar", "className", "vital-bar-fill gray");
|
||
$set("claude-session-remaining", "textContent", "N/A");
|
||
$set("claude-session-reset", "innerHTML", clickableError);
|
||
|
||
// Weekly card
|
||
$set("claude-weekly-pct", "innerHTML", '<span style="color:#f59e0b">N/A</span>');
|
||
$set("claude-weekly-bar", "style.width", "0%");
|
||
$set("claude-weekly-bar", "className", "vital-bar-fill gray");
|
||
$set("claude-weekly-remaining", "textContent", "N/A");
|
||
$set("claude-weekly-reset", "innerHTML", clickableError);
|
||
|
||
// Sonnet card
|
||
$set("sonnet-weekly-pct", "innerHTML", '<span style="color:#f59e0b">N/A</span>');
|
||
$set("sonnet-weekly-bar", "style.width", "0%");
|
||
$set("sonnet-weekly-bar", "className", "vital-bar-fill gray");
|
||
$set("sonnet-weekly-remaining", "textContent", "N/A");
|
||
$set("sonnet-weekly-reset", "innerHTML", clickableError);
|
||
|
||
// Compact cards - Claude
|
||
const compactSessionEl = document.getElementById("claude-compact-session-pct");
|
||
const compactSessionBar = document.getElementById("claude-compact-session-bar");
|
||
if (compactSessionEl) compactSessionEl.textContent = "N/A";
|
||
if (compactSessionBar) {
|
||
compactSessionBar.style.width = "0%";
|
||
compactSessionBar.className = "quota-bar-fill gray";
|
||
}
|
||
const compactWeekEl = document.getElementById("claude-compact-week-pct");
|
||
const compactWeekBar = document.getElementById("claude-compact-week-bar");
|
||
if (compactWeekEl) compactWeekEl.textContent = "N/A";
|
||
if (compactWeekBar) {
|
||
compactWeekBar.style.width = "0%";
|
||
compactWeekBar.className = "quota-bar-fill gray";
|
||
}
|
||
|
||
// Compact cards - Codex (show 0% but with gray bars since we don't track Codex via API)
|
||
const codex5hEl = document.getElementById("codex-5h-pct");
|
||
const codex5hBar = document.getElementById("codex-5h-bar");
|
||
const codexDayEl = document.getElementById("codex-day-pct");
|
||
const codexDayBar = document.getElementById("codex-day-bar");
|
||
if (codex5hEl) codex5hEl.textContent = "0%";
|
||
if (codex5hBar) {
|
||
codex5hBar.style.width = "0%";
|
||
codex5hBar.className = "quota-bar-fill low";
|
||
}
|
||
if (codexDayEl) codexDayEl.textContent = "0%";
|
||
if (codexDayBar) {
|
||
codexDayBar.style.width = "0%";
|
||
codexDayBar.className = "quota-bar-fill low";
|
||
}
|
||
$set("codex-tasks", "textContent", "0 total");
|
||
$set("llm-sync-time", "textContent", errorMsg);
|
||
|
||
// Still render routing data if available
|
||
if (data.routing) {
|
||
const ct = data.routing.claudeTasks || 0;
|
||
const cx = data.routing.codexTasks || 0;
|
||
const total = data.routing.total || 0;
|
||
$set(
|
||
"llm-routing-summary",
|
||
"textContent",
|
||
total > 0 ? `${ct} Claude / ${cx} Codex (${total} tasks)` : "No coding tasks yet",
|
||
);
|
||
|
||
// Task routing counts
|
||
$set("codex-tasks", "textContent", total + " total");
|
||
$set("claude-task-count", "textContent", ct);
|
||
$set("codex-task-count", "textContent", cx);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!data || data.error) {
|
||
$set("llm-sync-time", "textContent", data?.needsSync ? "Needs sync" : "Error");
|
||
return;
|
||
}
|
||
|
||
// Sync time
|
||
if (data.claude?.lastSynced) {
|
||
const synced = new Date(data.claude.lastSynced);
|
||
const ago = Math.round((Date.now() - synced) / 60000);
|
||
$set(
|
||
"llm-sync-time",
|
||
"textContent",
|
||
ago < 60 ? `${ago}m ago` : `${Math.round(ago / 60)}h ago`,
|
||
);
|
||
} else if (data.source === "live") {
|
||
$set("llm-sync-time", "textContent", "Live");
|
||
}
|
||
|
||
// Routing summary - show task counts clearly
|
||
if (data.routing) {
|
||
const ct = data.routing.claudeTasks || 0;
|
||
const cx = data.routing.codexTasks || 0;
|
||
const total = data.routing.total || 0;
|
||
$set(
|
||
"llm-routing-summary",
|
||
"textContent",
|
||
total > 0 ? `${ct} Claude / ${cx} Codex (${total} tasks)` : "No coding tasks yet",
|
||
);
|
||
}
|
||
|
||
// Claude Session - Show USED as primary (bar fills with consumption)
|
||
const sessionRemaining = data.claude?.session?.remainingPct || 0;
|
||
const sessionUsed = data.claude?.session?.usedPct || 0;
|
||
$set(
|
||
"claude-session-pct",
|
||
"innerHTML",
|
||
sessionUsed + '% <small style="font-size:0.6em;opacity:0.7">used</small>',
|
||
);
|
||
$set("claude-session-bar", "style.width", sessionUsed + "%");
|
||
$set("claude-session-remaining", "textContent", sessionRemaining + "%");
|
||
$set("claude-session-reset", "textContent", data.claude?.session?.resetsIn || "-");
|
||
|
||
// Color based on used - red when nearly depleted (high usage)
|
||
const sessionBar = document.getElementById("claude-session-bar");
|
||
if (sessionBar) {
|
||
sessionBar.className =
|
||
"vital-bar-fill " +
|
||
(sessionUsed > 80 ? "red" : sessionUsed > 50 ? "yellow" : "green");
|
||
}
|
||
|
||
// Claude Weekly - Show USED as primary
|
||
const weeklyRemaining = data.claude?.weekly?.remainingPct || 0;
|
||
const weeklyUsed = data.claude?.weekly?.usedPct || 0;
|
||
$set(
|
||
"claude-weekly-pct",
|
||
"innerHTML",
|
||
weeklyUsed + '% <small style="font-size:0.6em;opacity:0.7">used</small>',
|
||
);
|
||
$set("claude-weekly-bar", "style.width", weeklyUsed + "%");
|
||
$set("claude-weekly-remaining", "textContent", weeklyRemaining + "%");
|
||
$set("claude-weekly-reset", "textContent", data.claude?.weekly?.resets || "-");
|
||
|
||
const weeklyBar = document.getElementById("claude-weekly-bar");
|
||
if (weeklyBar) {
|
||
weeklyBar.className =
|
||
"vital-bar-fill " + (weeklyUsed > 80 ? "red" : weeklyUsed > 50 ? "yellow" : "green");
|
||
}
|
||
|
||
// Also update the compact quota card (uses different element IDs)
|
||
const compactSessionEl = document.getElementById("claude-compact-session-pct");
|
||
const compactSessionBar = document.getElementById("claude-compact-session-bar");
|
||
if (compactSessionEl) compactSessionEl.textContent = sessionUsed + "%";
|
||
if (compactSessionBar) {
|
||
compactSessionBar.style.width = sessionUsed + "%";
|
||
compactSessionBar.className =
|
||
"quota-bar-fill " + (sessionUsed < 50 ? "low" : sessionUsed < 80 ? "medium" : "high");
|
||
}
|
||
|
||
const compactWeekEl = document.getElementById("claude-compact-week-pct");
|
||
const compactWeekBar = document.getElementById("claude-compact-week-bar");
|
||
if (compactWeekEl) compactWeekEl.textContent = weeklyUsed + "%";
|
||
if (compactWeekBar) {
|
||
compactWeekBar.style.width = weeklyUsed + "%";
|
||
compactWeekBar.className =
|
||
"quota-bar-fill " + (weeklyUsed < 50 ? "low" : weeklyUsed < 80 ? "medium" : "high");
|
||
}
|
||
|
||
// Sonnet Weekly - Show USED as primary
|
||
const sonnetRemaining = data.claude?.sonnet?.remainingPct || 0;
|
||
const sonnetUsed = data.claude?.sonnet?.usedPct || 0;
|
||
$set(
|
||
"sonnet-weekly-pct",
|
||
"innerHTML",
|
||
sonnetUsed + '% <small style="font-size:0.6em;opacity:0.7">used</small>',
|
||
);
|
||
$set("sonnet-weekly-bar", "style.width", sonnetUsed + "%");
|
||
$set("sonnet-weekly-remaining", "textContent", sonnetRemaining + "%");
|
||
$set("sonnet-weekly-reset", "textContent", data.claude?.sonnet?.resets || "-");
|
||
|
||
const sonnetBar = document.getElementById("sonnet-weekly-bar");
|
||
if (sonnetBar) {
|
||
sonnetBar.className =
|
||
"vital-bar-fill " + (sonnetUsed > 80 ? "red" : sonnetUsed > 50 ? "yellow" : "green");
|
||
}
|
||
|
||
// Task Routing
|
||
const totalTasks = data.routing?.total || 0;
|
||
const claudeTasks = data.routing?.claudeTasks || 0;
|
||
const codexTasks = data.routing?.codexTasks || 0;
|
||
$set("codex-tasks", "textContent", totalTasks + " total");
|
||
$set("claude-task-count", "textContent", claudeTasks);
|
||
$set("codex-task-count", "textContent", codexTasks);
|
||
|
||
// Codex floor status - shows if we're meeting the 20% minimum
|
||
const floorStatus = document.getElementById("codex-floor-status");
|
||
const codexPct = data.routing?.codexPct || 0;
|
||
const codexFloor = data.routing?.codexFloor || 20;
|
||
if (floorStatus) {
|
||
if (totalTasks === 0) {
|
||
floorStatus.className = "pressure-indicator";
|
||
floorStatus.textContent = `Codex ≥20%: No tasks yet`;
|
||
} else if (codexPct >= codexFloor) {
|
||
floorStatus.className = "pressure-indicator normal";
|
||
floorStatus.textContent = `Codex ≥20%: ✓ ${codexPct}%`;
|
||
} else {
|
||
floorStatus.className = "pressure-indicator warning";
|
||
floorStatus.textContent = `Codex ≥20%: ${codexPct}% (need more)`;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[renderLlmUsage] Error:", e.message);
|
||
}
|
||
}
|
||
|
||
async function fetchRoutingStats() {
|
||
try {
|
||
const res = await fetch("/api/routing-stats?hours=24");
|
||
const data = await res.json();
|
||
renderRoutingStats(data);
|
||
} catch (e) {
|
||
console.error("Failed to fetch routing stats:", e);
|
||
}
|
||
}
|
||
|
||
function renderRoutingStats(data) {
|
||
if (!data || data.error) return;
|
||
|
||
const total = data.total_requests || 0;
|
||
const byModel = data.by_model || {};
|
||
|
||
// Total tasks
|
||
$set("total-routed-tasks", "textContent", total + " (24h)");
|
||
|
||
// Count by model family
|
||
let claudeCount = 0,
|
||
codexCount = 0,
|
||
llamaCount = 0,
|
||
qwenCount = 0;
|
||
for (const [model, count] of Object.entries(byModel)) {
|
||
const m = model.toLowerCase();
|
||
if (m.includes("claude") || m.includes("opus") || m.includes("sonnet")) {
|
||
claudeCount += count;
|
||
} else if (m.includes("codex") || m.includes("gpt")) {
|
||
codexCount += count;
|
||
} else if (m.includes("llama")) {
|
||
llamaCount += count;
|
||
} else if (m.includes("qwen")) {
|
||
qwenCount += count;
|
||
}
|
||
}
|
||
|
||
$set("claude-task-count", "textContent", claudeCount);
|
||
$set("codex-task-count", "textContent", codexCount);
|
||
$set("llama-task-count", "textContent", llamaCount);
|
||
$set("qwen-task-count", "textContent", qwenCount);
|
||
|
||
// Latency
|
||
const avgLatency = data.avg_latency_ms || 0;
|
||
const latencyEl = document.getElementById("routing-latency");
|
||
if (latencyEl) {
|
||
if (avgLatency > 0) {
|
||
const latencySec = (avgLatency / 1000).toFixed(1);
|
||
latencyEl.textContent = `Avg latency: ${latencySec}s`;
|
||
latencyEl.className =
|
||
"pressure-indicator " + (avgLatency > 30000 ? "warning" : "normal");
|
||
} else {
|
||
latencyEl.textContent = "Avg latency: -";
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchLlmUsage() {
|
||
try {
|
||
const res = await fetch("/api/llm-usage");
|
||
const data = await res.json();
|
||
renderLlmUsage(data);
|
||
// Also fetch detailed routing stats
|
||
fetchRoutingStats();
|
||
} catch (e) {
|
||
console.error("Failed to fetch LLM usage:", e);
|
||
}
|
||
}
|
||
|
||
function renderDashboard(data) {
|
||
// Use the split render functions (avoids code duplication)
|
||
if (data.vitals) renderVitals(data.vitals);
|
||
if (data.sessions) renderSessions(data.sessions, data.pagination, data.statusCounts);
|
||
if (data.cron) renderCron(data.cron);
|
||
if (data.tokenStats) {
|
||
renderTokenStats(data.tokenStats);
|
||
// Also update the detailed stats
|
||
$set("total-tokens", "textContent", data.tokenStats.total);
|
||
$set("input-tokens", "textContent", data.tokenStats.input);
|
||
$set("output-tokens", "textContent", data.tokenStats.output);
|
||
$set("active-sessions", "textContent", data.tokenStats.activeCount);
|
||
$set("est-cost", "textContent", data.tokenStats.estCost);
|
||
$set("nav-tokens", "textContent", data.tokenStats.total);
|
||
$set("nav-cost", "textContent", data.tokenStats.estCost);
|
||
$set("nav-monthly-cost", "textContent", data.tokenStats.estMonthlyCost || "-");
|
||
$set("nav-avg-tokens", "textContent", data.tokenStats.avgTokensPerSession || "-");
|
||
$set("nav-avg-cost", "textContent", data.tokenStats.avgCostPerSession || "-");
|
||
|
||
// Show savings if positive
|
||
const savingsStat = document.getElementById("savings-stat");
|
||
const savingsEl = document.getElementById("est-savings");
|
||
if (data.tokenStats.estSavings && savingsStat && savingsEl) {
|
||
savingsEl.textContent = data.tokenStats.estSavings;
|
||
savingsStat.style.display = "block";
|
||
} else if (savingsStat) {
|
||
savingsStat.style.display = "none";
|
||
}
|
||
}
|
||
|
||
// Capacity
|
||
if (data.capacity) renderCapacity(data.capacity);
|
||
|
||
// Memory
|
||
if (data.memory) {
|
||
renderMemory(data.memory);
|
||
}
|
||
|
||
// Cerebro
|
||
if (data.cerebro) {
|
||
renderCerebro(data.cerebro);
|
||
}
|
||
}
|
||
|
||
function renderMemory(memory) {
|
||
$set("memory-count", "textContent", `${memory.totalFiles} files`);
|
||
$set("nav-memory-count", "textContent", memory.totalFiles);
|
||
|
||
$set("memory-md-size", "textContent", memory.memoryMdSizeFormatted || "-");
|
||
$set("memory-md-lines", "textContent", memory.memoryMdLines || "-");
|
||
|
||
$set("memory-total-files", "textContent", memory.totalFiles || "-");
|
||
$set("memory-total-size", "textContent", memory.totalSizeFormatted || "-");
|
||
|
||
// Recent files
|
||
const recentEl = document.getElementById("memory-recent-files");
|
||
if (memory.recentFiles && memory.recentFiles.length > 0) {
|
||
smartUpdate(
|
||
recentEl,
|
||
memory.recentFiles
|
||
.map(
|
||
(f) => `
|
||
<div style="display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 0.8rem;">
|
||
<span style="color: var(--accent);">📄 ${escapeHtml(f.name)}</span>
|
||
<span style="color: var(--text-muted);">${f.sizeFormatted} • ${f.age}</span>
|
||
</div>
|
||
`,
|
||
)
|
||
.join(""),
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
recentEl,
|
||
'<em style="color: var(--text-muted); font-size: 0.8rem;">No memory files yet</em>',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Cerebro rendering
|
||
function renderCerebro(cerebro) {
|
||
// Store for re-rendering when privacy settings change
|
||
window.lastCerebroData = cerebro;
|
||
|
||
const notInitEl = document.getElementById("cerebro-not-initialized");
|
||
const initEl = document.getElementById("cerebro-initialized");
|
||
|
||
if (!cerebro || !cerebro.initialized) {
|
||
// Show not initialized state with dynamic path
|
||
if (notInitEl) notInitEl.style.display = "block";
|
||
if (initEl) initEl.style.display = "none";
|
||
$set("cerebro-count", "textContent", "not initialized");
|
||
$set("nav-cerebro-count", "textContent", "-");
|
||
|
||
// Update init commands with actual configured path
|
||
if (cerebro && cerebro.cerebroPath) {
|
||
const basePath = cerebro.cerebroPath.replace(/^\/Users\/[^/]+/, "~");
|
||
$set("cerebro-init-topics-cmd", "textContent", `mkdir -p ${basePath}/topics`);
|
||
$set("cerebro-init-orphans-cmd", "textContent", `mkdir -p ${basePath}/orphans`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Show initialized state
|
||
if (notInitEl) notInitEl.style.display = "none";
|
||
if (initEl) initEl.style.display = "block";
|
||
|
||
// Calculate hidden topics count from current view
|
||
const allTopics = cerebro.recentTopics || [];
|
||
const hiddenTopicsCount = allTopics.filter((t) => isTopicHidden(t.name)).length;
|
||
const visibleTopicsCount = allTopics.length - hiddenTopicsCount;
|
||
|
||
// Update counts with hidden breakdown (updates dynamically on hide/unhide)
|
||
const countText =
|
||
hiddenTopicsCount > 0
|
||
? `${cerebro.topics.total} topics (${visibleTopicsCount} visible, ${hiddenTopicsCount} hidden)`
|
||
: `${cerebro.topics.total} topics`;
|
||
$set("cerebro-count", "textContent", countText);
|
||
$set("nav-cerebro-count", "textContent", cerebro.topics.total);
|
||
|
||
$set("cerebro-active", "textContent", cerebro.topics.active || 0);
|
||
$set("cerebro-resolved", "textContent", cerebro.topics.resolved || 0);
|
||
$set("cerebro-parked", "textContent", cerebro.topics.parked || 0);
|
||
$set("cerebro-total-topics", "textContent", cerebro.topics.total || 0);
|
||
|
||
$set("cerebro-threads", "textContent", cerebro.threads || 0);
|
||
$set("cerebro-orphans", "textContent", cerebro.orphans || 0);
|
||
|
||
// Recent topics with action buttons (filtered by privacy settings)
|
||
const recentEl = document.getElementById("cerebro-recent-topics");
|
||
const visibleTopics = allTopics.filter((t) => !isTopicHidden(t.name));
|
||
|
||
if (visibleTopics.length > 0) {
|
||
smartUpdate(
|
||
recentEl,
|
||
visibleTopics
|
||
.map((t) => {
|
||
const statusIcon =
|
||
t.status === "active" ? "🟢" : t.status === "resolved" ? "✅" : "⏸️";
|
||
const statusColor =
|
||
t.status === "active"
|
||
? "var(--green)"
|
||
: t.status === "resolved"
|
||
? "var(--accent)"
|
||
: "var(--text-muted)";
|
||
|
||
// Action buttons based on status (always include hide button)
|
||
const hideBtn = `<button class="topic-action-btn hide" onclick="quickHideTopic('${escapeHtml(t.name).replace(/'/g, "\\'")}', '${escapeHtml(t.title || t.name).replace(/'/g, "\\'")}'); event.stopPropagation();" title="Hide topic">👁️</button>`;
|
||
let actionButtons = "";
|
||
if (t.status === "active") {
|
||
actionButtons = `
|
||
<button class="topic-action-btn resolve" onclick="updateTopicStatus('${escapeHtml(t.name)}', 'resolved')" title="Mark as resolved">✓</button>
|
||
<button class="topic-action-btn park" onclick="updateTopicStatus('${escapeHtml(t.name)}', 'parked')" title="Park topic">⏸</button>
|
||
${hideBtn}
|
||
`;
|
||
} else {
|
||
actionButtons = `
|
||
<button class="topic-action-btn reactivate" onclick="updateTopicStatus('${escapeHtml(t.name)}', 'active')" title="Reactivate topic">↩</button>
|
||
${hideBtn}
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 0.8rem;">
|
||
<div style="display: flex; align-items: center; gap: 8px; flex: 1;">
|
||
<span>${statusIcon}</span>
|
||
<span style="color: ${statusColor};">${escapeHtml(t.title || t.name)}</span>
|
||
${t.threads > 0 ? `<span style="color: var(--text-muted); font-size: 0.7rem;">(${t.threads} threads)</span>` : ""}
|
||
</div>
|
||
<div class="topic-actions">
|
||
${actionButtons}
|
||
</div>
|
||
<span style="color: var(--text-muted); margin-left: 8px;">${t.age || "-"}</span>
|
||
</div>
|
||
`;
|
||
})
|
||
.join(""),
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
recentEl,
|
||
'<em style="color: var(--text-muted); font-size: 0.8rem;">No active topics yet</em>',
|
||
);
|
||
}
|
||
|
||
// Last updated
|
||
const lastUpdatedEl = document.getElementById("cerebro-last-updated");
|
||
if (cerebro.lastUpdated) {
|
||
const date = new Date(cerebro.lastUpdated);
|
||
const now = new Date();
|
||
const diffMs = now - date;
|
||
const diffMins = Math.round(diffMs / 60000);
|
||
if (diffMins < 1) lastUpdatedEl.textContent = "just now";
|
||
else if (diffMins < 60) lastUpdatedEl.textContent = `${diffMins}m ago`;
|
||
else if (diffMins < 1440) lastUpdatedEl.textContent = `${Math.round(diffMins / 60)}h ago`;
|
||
else lastUpdatedEl.textContent = `${Math.round(diffMins / 1440)}d ago`;
|
||
} else {
|
||
lastUpdatedEl.textContent = "-";
|
||
}
|
||
}
|
||
|
||
// Detail panel
|
||
function openDetail(sessionKey, label) {
|
||
document.getElementById("detail-title").textContent = label;
|
||
document.getElementById("detail-panel").classList.remove("hidden");
|
||
document.getElementById("detail-panel").classList.add("visible");
|
||
document.getElementById("detail-overlay").classList.remove("hidden");
|
||
document.getElementById("detail-overlay").classList.add("visible");
|
||
|
||
// Loading state
|
||
["overview", "summary", "links", "attention", "facts", "tools", "messages"].forEach(
|
||
(id) => {
|
||
document.getElementById(`detail-${id}`).innerHTML =
|
||
'<em style="color: var(--text-muted)">Loading...</em>';
|
||
},
|
||
);
|
||
|
||
fetchSessionDetail(sessionKey);
|
||
}
|
||
|
||
function closeDetail() {
|
||
document.getElementById("detail-panel").classList.remove("visible");
|
||
document.getElementById("detail-overlay").classList.remove("visible");
|
||
setTimeout(() => {
|
||
document.getElementById("detail-panel").classList.add("hidden");
|
||
document.getElementById("detail-overlay").classList.add("hidden");
|
||
}, 200);
|
||
}
|
||
|
||
// Cost breakdown modal
|
||
let cachedCostData = null;
|
||
|
||
async function openCostModal() {
|
||
document.getElementById("cost-modal").classList.remove("hidden");
|
||
document.getElementById("cost-modal").classList.add("visible");
|
||
document.getElementById("cost-modal-overlay").classList.remove("hidden");
|
||
document.getElementById("cost-modal-overlay").classList.add("visible");
|
||
|
||
// Fetch cost breakdown data
|
||
try {
|
||
const res = await fetch("/api/cost-breakdown");
|
||
const data = await res.json();
|
||
cachedCostData = data;
|
||
renderCostBreakdown(data);
|
||
} catch (e) {
|
||
document.getElementById("cost-tokens").innerHTML =
|
||
'<em style="color:var(--red)">Failed to load cost data</em>';
|
||
}
|
||
}
|
||
|
||
function closeCostModal() {
|
||
document.getElementById("cost-modal").classList.remove("visible");
|
||
document.getElementById("cost-modal-overlay").classList.remove("visible");
|
||
setTimeout(() => {
|
||
document.getElementById("cost-modal").classList.add("hidden");
|
||
document.getElementById("cost-modal-overlay").classList.add("hidden");
|
||
}, 200);
|
||
}
|
||
|
||
// Auth fix modal
|
||
function openAuthModal() {
|
||
document.getElementById("auth-modal").classList.remove("hidden");
|
||
document.getElementById("auth-modal").classList.add("visible");
|
||
document.getElementById("auth-modal-overlay").classList.remove("hidden");
|
||
document.getElementById("auth-modal-overlay").classList.add("visible");
|
||
}
|
||
|
||
function closeAuthModal() {
|
||
document.getElementById("auth-modal").classList.remove("visible");
|
||
document.getElementById("auth-modal-overlay").classList.remove("visible");
|
||
setTimeout(() => {
|
||
document.getElementById("auth-modal").classList.add("hidden");
|
||
document.getElementById("auth-modal-overlay").classList.add("hidden");
|
||
}, 200);
|
||
}
|
||
|
||
// Operator modal
|
||
async function openOperatorModal(operatorId) {
|
||
document.getElementById("operator-modal").classList.remove("hidden");
|
||
document.getElementById("operator-modal").classList.add("visible");
|
||
document.getElementById("operator-modal-overlay").classList.remove("hidden");
|
||
document.getElementById("operator-modal-overlay").classList.add("visible");
|
||
|
||
// Show loading state
|
||
document.getElementById("operator-modal-content").innerHTML =
|
||
'<div class="loading-state">Loading user stats...</div>';
|
||
|
||
try {
|
||
// Fetch operator data from API
|
||
const res = await fetch("/api/operators");
|
||
const data = await res.json();
|
||
|
||
// Find the operator by ID or name
|
||
const operator = data.operators?.find(
|
||
(op) =>
|
||
op.id === operatorId ||
|
||
op.metadata?.slackId === operatorId ||
|
||
op.displayName === operatorId ||
|
||
op.username === operatorId,
|
||
);
|
||
|
||
if (operator) {
|
||
renderOperatorModal(operator, data.sessions || []);
|
||
} else {
|
||
document.getElementById("operator-modal-content").innerHTML = `
|
||
<div class="detail-section">
|
||
<p style="color: var(--text-muted)">Operator not found: ${escapeHtml(operatorId)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load operator:", e);
|
||
document.getElementById("operator-modal-content").innerHTML = `
|
||
<div class="detail-section">
|
||
<p style="color: var(--error)">Failed to load user data</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function renderOperatorModal(operator, allSessions) {
|
||
const displayName = operator.displayName || operator.username || "Unknown";
|
||
document.getElementById("operator-modal-name").textContent = displayName;
|
||
|
||
// Calculate user stats
|
||
const userSessions = sessionsData.filter(
|
||
(s) =>
|
||
s.originator?.userId === operator.id ||
|
||
s.originator?.userId === operator.metadata?.slackId ||
|
||
s.originator?.displayName === displayName,
|
||
);
|
||
|
||
const activeSessions = userSessions.filter((s) => s.active).length;
|
||
const totalTokens = userSessions.reduce((sum, s) => sum + (s.tokens || 0), 0);
|
||
const tokensFormatted =
|
||
totalTokens > 1000000
|
||
? (totalTokens / 1000000).toFixed(1) + "M"
|
||
: totalTokens > 1000
|
||
? (totalTokens / 1000).toFixed(1) + "k"
|
||
: totalTokens;
|
||
|
||
const initial = displayName.charAt(0).toUpperCase();
|
||
const roleLabel =
|
||
operator.role === "owner"
|
||
? "👑 Owner"
|
||
: operator.role === "admin"
|
||
? "⭐ Admin"
|
||
: "👤 User";
|
||
|
||
document.getElementById("operator-modal-content").innerHTML = `
|
||
<div class="detail-section">
|
||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
|
||
<div style="width: 60px; height: 60px; border-radius: 50%; background: var(--accent); display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold;">
|
||
${initial}
|
||
</div>
|
||
<div>
|
||
<div style="font-size: 1.2rem; font-weight: 600;">${escapeHtml(displayName)}</div>
|
||
<div style="color: var(--text-muted); font-size: 0.85rem;">${roleLabel}</div>
|
||
${operator.source ? `<div style="color: var(--text-muted); font-size: 0.75rem;">via ${operator.source}</div>` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h3>📊 Stats</h3>
|
||
<div class="detail-box">
|
||
<div class="detail-row"><span class="detail-label">Active Sessions</span><span class="detail-value">${activeSessions}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Total Sessions</span><span class="detail-value">${userSessions.length}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Total Tokens</span><span class="detail-value">${tokensFormatted}</span></div>
|
||
<div class="detail-row"><span class="detail-label">First Seen</span><span class="detail-value">${operator.firstSeen ? new Date(operator.firstSeen).toLocaleDateString() : "—"}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h3>💬 Recent Sessions</h3>
|
||
<div class="detail-box" style="font-size: 0.85rem;">
|
||
${
|
||
userSessions
|
||
.slice(0, 5)
|
||
.map(
|
||
(s) => `
|
||
<div class="detail-row" style="cursor: pointer;" onclick="closeOperatorModal(); openDetail('${escapeHtml(s.sessionKey)}', '${escapeHtml(s.label)}')">
|
||
<span class="detail-label">${s.active ? "🟢" : "⚪"} ${escapeHtml(s.label?.substring(0, 30) || "Unknown")}${(s.label?.length || 0) > 30 ? "..." : ""}</span>
|
||
<span class="detail-value">${((s.tokens || 0) / 1000).toFixed(1)}k</span>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("") || '<em style="color: var(--text-muted)">No sessions found</em>'
|
||
}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function closeOperatorModal() {
|
||
document.getElementById("operator-modal").classList.remove("visible");
|
||
document.getElementById("operator-modal-overlay").classList.remove("visible");
|
||
setTimeout(() => {
|
||
document.getElementById("operator-modal").classList.add("hidden");
|
||
document.getElementById("operator-modal-overlay").classList.add("hidden");
|
||
}, 200);
|
||
}
|
||
|
||
// Privacy Settings - Topics, Sessions, Crons, Display Options
|
||
// Server-side storage with localStorage as cache/fallback
|
||
const HIDDEN_TOPICS_KEY = "openclawHiddenTopics";
|
||
const HIDDEN_SESSIONS_KEY = "openclawHiddenSessions";
|
||
const HIDDEN_CRONS_KEY = "openclawHiddenCrons";
|
||
const HIDE_HOSTNAME_KEY = "openclawHideHostname";
|
||
const PRIVACY_CACHE_KEY = "openclawPrivacyCache";
|
||
|
||
// In-memory cache of privacy settings (populated from server on load)
|
||
let privacySettingsCache = null;
|
||
|
||
// Load privacy settings from server (called on page load)
|
||
async function loadPrivacyFromServer() {
|
||
try {
|
||
const res = await fetch("/api/privacy");
|
||
if (res.ok) {
|
||
const settings = await res.json();
|
||
privacySettingsCache = settings;
|
||
// Update localStorage cache
|
||
localStorage.setItem(PRIVACY_CACHE_KEY, JSON.stringify(settings));
|
||
// Migrate old localStorage data if server is empty
|
||
if (
|
||
(!settings.hiddenTopics || settings.hiddenTopics.length === 0) &&
|
||
(!settings.hiddenSessions || settings.hiddenSessions.length === 0) &&
|
||
(!settings.hiddenCrons || settings.hiddenCrons.length === 0)
|
||
) {
|
||
await migrateLocalStorageToServer();
|
||
}
|
||
return settings;
|
||
}
|
||
} catch (e) {
|
||
console.warn("Failed to load privacy from server, using localStorage cache:", e.message);
|
||
}
|
||
// Fallback to localStorage cache
|
||
try {
|
||
const cached = localStorage.getItem(PRIVACY_CACHE_KEY);
|
||
if (cached) {
|
||
privacySettingsCache = JSON.parse(cached);
|
||
return privacySettingsCache;
|
||
}
|
||
} catch (e) {}
|
||
// Final fallback - empty settings
|
||
privacySettingsCache = {
|
||
hiddenTopics: [],
|
||
hiddenSessions: [],
|
||
hiddenCrons: [],
|
||
hideHostname: false,
|
||
};
|
||
return privacySettingsCache;
|
||
}
|
||
|
||
// Save privacy settings to server (debounced)
|
||
let privacySaveTimeout = null;
|
||
async function savePrivacyToServer() {
|
||
// Debounce saves to avoid excessive API calls
|
||
if (privacySaveTimeout) clearTimeout(privacySaveTimeout);
|
||
privacySaveTimeout = setTimeout(async () => {
|
||
try {
|
||
const res = await fetch("/api/privacy", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(privacySettingsCache),
|
||
});
|
||
if (res.ok) {
|
||
// Update localStorage cache
|
||
localStorage.setItem(PRIVACY_CACHE_KEY, JSON.stringify(privacySettingsCache));
|
||
console.log("[Privacy] Settings saved to server");
|
||
} else {
|
||
console.warn("[Privacy] Failed to save to server:", await res.text());
|
||
}
|
||
} catch (e) {
|
||
console.warn("[Privacy] Failed to save to server:", e.message);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
// Migrate old localStorage data to server (one-time migration)
|
||
async function migrateLocalStorageToServer() {
|
||
const oldTopics = getLocalHiddenItems(HIDDEN_TOPICS_KEY);
|
||
const oldSessions = getLocalHiddenItems(HIDDEN_SESSIONS_KEY);
|
||
const oldCrons = getLocalHiddenItems(HIDDEN_CRONS_KEY);
|
||
const oldHostname = localStorage.getItem(HIDE_HOSTNAME_KEY) === "true";
|
||
|
||
if (oldTopics.length || oldSessions.length || oldCrons.length || oldHostname) {
|
||
console.log("[Privacy] Migrating localStorage data to server...");
|
||
privacySettingsCache = {
|
||
hiddenTopics: oldTopics,
|
||
hiddenSessions: oldSessions,
|
||
hiddenCrons: oldCrons,
|
||
hideHostname: oldHostname,
|
||
};
|
||
await savePrivacyToServer();
|
||
// Clear old localStorage keys after migration
|
||
localStorage.removeItem(HIDDEN_TOPICS_KEY);
|
||
localStorage.removeItem(HIDDEN_SESSIONS_KEY);
|
||
localStorage.removeItem(HIDDEN_CRONS_KEY);
|
||
localStorage.removeItem(HIDE_HOSTNAME_KEY);
|
||
}
|
||
}
|
||
|
||
// Generic localStorage helpers (for migration/fallback only)
|
||
function getLocalHiddenItems(key) {
|
||
try {
|
||
const stored = localStorage.getItem(key);
|
||
if (!stored) return [];
|
||
const parsed = JSON.parse(stored);
|
||
return parsed.map((item) => (typeof item === "string" ? { id: item, name: item } : item));
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Topic-specific functions (use in-memory cache backed by server)
|
||
function getHiddenTopics() {
|
||
return privacySettingsCache?.hiddenTopics || [];
|
||
}
|
||
function setHiddenTopics(topics) {
|
||
if (!privacySettingsCache) privacySettingsCache = {};
|
||
privacySettingsCache.hiddenTopics = topics;
|
||
savePrivacyToServer();
|
||
}
|
||
function isTopicHidden(topicId) {
|
||
const hidden = getHiddenTopics();
|
||
const normalized = (topicId || "").toLowerCase().trim();
|
||
return hidden.some((h) => (h.id || "").toLowerCase().trim() === normalized);
|
||
}
|
||
|
||
// Session-specific functions
|
||
function getHiddenSessions() {
|
||
return privacySettingsCache?.hiddenSessions || [];
|
||
}
|
||
function setHiddenSessions(sessions) {
|
||
if (!privacySettingsCache) privacySettingsCache = {};
|
||
privacySettingsCache.hiddenSessions = sessions;
|
||
savePrivacyToServer();
|
||
}
|
||
function isSessionHidden(session) {
|
||
const hidden = getHiddenSessions();
|
||
const key = (session.sessionKey || session.key || "").toLowerCase().trim();
|
||
return hidden.some((h) => (h.id || "").toLowerCase().trim() === key);
|
||
}
|
||
|
||
// Cron-specific functions
|
||
function getHiddenCrons() {
|
||
return privacySettingsCache?.hiddenCrons || [];
|
||
}
|
||
function setHiddenCrons(crons) {
|
||
if (!privacySettingsCache) privacySettingsCache = {};
|
||
privacySettingsCache.hiddenCrons = crons;
|
||
savePrivacyToServer();
|
||
}
|
||
function isCronHidden(cron) {
|
||
const hidden = getHiddenCrons();
|
||
const id = (cron.id || cron.jobId || "").toLowerCase().trim();
|
||
// Match on id only (exact match)
|
||
return hidden.some((h) => (h.id || "").toLowerCase().trim() === id);
|
||
}
|
||
|
||
// Add functions (manual entry - uses input as both id and name)
|
||
function addHiddenTopic() {
|
||
const input = document.getElementById("privacy-add-topic");
|
||
const topic = (input.value || "").trim();
|
||
if (!topic) return;
|
||
|
||
const hidden = getHiddenTopics();
|
||
if (!hidden.some((h) => (h.id || "").toLowerCase() === topic.toLowerCase())) {
|
||
hidden.push({ id: topic, name: topic });
|
||
setHiddenTopics(hidden);
|
||
}
|
||
input.value = "";
|
||
renderPrivacyLists();
|
||
refreshAllViews();
|
||
}
|
||
|
||
function addHiddenSession() {
|
||
const input = document.getElementById("privacy-add-session");
|
||
const session = (input.value || "").trim();
|
||
if (!session) return;
|
||
|
||
const hidden = getHiddenSessions();
|
||
if (!hidden.some((h) => (h.id || "").toLowerCase() === session.toLowerCase())) {
|
||
hidden.push({ id: session, name: session });
|
||
setHiddenSessions(hidden);
|
||
}
|
||
input.value = "";
|
||
renderPrivacyLists();
|
||
refreshAllViews();
|
||
}
|
||
|
||
function addHiddenCron() {
|
||
const input = document.getElementById("privacy-add-cron");
|
||
const cron = (input.value || "").trim();
|
||
if (!cron) return;
|
||
|
||
const hidden = getHiddenCrons();
|
||
if (!hidden.some((h) => (h.id || "").toLowerCase() === cron.toLowerCase())) {
|
||
hidden.push({ id: cron, name: cron });
|
||
setHiddenCrons(hidden);
|
||
}
|
||
input.value = "";
|
||
renderPrivacyLists();
|
||
refreshAllViews();
|
||
}
|
||
|
||
// Quick-hide from card (called by hide icon on cards)
|
||
// Stores id for matching, name for display
|
||
function quickHideTopic(id, name) {
|
||
const hidden = getHiddenTopics();
|
||
if (!hidden.some((h) => (h.id || "").toLowerCase() === id.toLowerCase())) {
|
||
hidden.push({ id: id, name: name || id });
|
||
setHiddenTopics(hidden);
|
||
}
|
||
refreshAllViews();
|
||
showToast(`Topic "${name || id}" hidden. Open Privacy Settings to unhide.`);
|
||
}
|
||
|
||
function quickHideSession(id, name) {
|
||
const hidden = getHiddenSessions();
|
||
if (!hidden.some((h) => (h.id || "").toLowerCase() === id.toLowerCase())) {
|
||
hidden.push({ id: id, name: name || id });
|
||
setHiddenSessions(hidden);
|
||
}
|
||
refreshAllViews();
|
||
showToast(`Session "${name || id}" hidden. Open Privacy Settings to unhide.`);
|
||
}
|
||
|
||
function quickHideCron(id, name) {
|
||
const hidden = getHiddenCrons();
|
||
if (!hidden.some((h) => (h.id || "").toLowerCase() === id.toLowerCase())) {
|
||
hidden.push({ id: id, name: name || id });
|
||
setHiddenCrons(hidden);
|
||
}
|
||
refreshAllViews();
|
||
showToast(`Cron job "${name || id}" hidden. Open Privacy Settings to unhide.`);
|
||
}
|
||
|
||
// Remove functions (by id)
|
||
function removeHiddenTopic(id) {
|
||
const hidden = getHiddenTopics();
|
||
const filtered = hidden.filter((h) => (h.id || "").toLowerCase() !== id.toLowerCase());
|
||
setHiddenTopics(filtered);
|
||
renderPrivacyLists();
|
||
refreshAllViews();
|
||
}
|
||
|
||
function removeHiddenSession(id) {
|
||
const hidden = getHiddenSessions();
|
||
const filtered = hidden.filter((h) => (h.id || "").toLowerCase() !== id.toLowerCase());
|
||
setHiddenSessions(filtered);
|
||
renderPrivacyLists();
|
||
refreshAllViews();
|
||
}
|
||
|
||
function removeHiddenCron(id) {
|
||
const hidden = getHiddenCrons();
|
||
const filtered = hidden.filter((h) => (h.id || "").toLowerCase() !== id.toLowerCase());
|
||
setHiddenCrons(filtered);
|
||
renderPrivacyLists();
|
||
refreshAllViews();
|
||
}
|
||
|
||
// Clear all
|
||
function clearAllPrivacySettings() {
|
||
setHiddenTopics([]);
|
||
setHiddenSessions([]);
|
||
setHiddenCrons([]);
|
||
setHideHostname(false);
|
||
renderPrivacyLists();
|
||
// Update checkbox
|
||
const hostnameCheckbox = document.getElementById("privacy-hide-hostname");
|
||
if (hostnameCheckbox) hostnameCheckbox.checked = false;
|
||
refreshAllViews();
|
||
}
|
||
|
||
// Hostname privacy (use server-backed cache)
|
||
function isHostnameHidden() {
|
||
return privacySettingsCache?.hideHostname || false;
|
||
}
|
||
|
||
function setHideHostname(hide) {
|
||
if (!privacySettingsCache) privacySettingsCache = {};
|
||
privacySettingsCache.hideHostname = hide;
|
||
savePrivacyToServer();
|
||
}
|
||
|
||
function toggleHideHostname() {
|
||
const checkbox = document.getElementById("privacy-hide-hostname");
|
||
setHideHostname(checkbox.checked);
|
||
updateHostnameDisplay();
|
||
}
|
||
|
||
function updateHostnameDisplay() {
|
||
const hostnameEl = document.getElementById("vitals-hostname");
|
||
if (hostnameEl) {
|
||
if (isHostnameHidden()) {
|
||
hostnameEl.style.filter = "blur(8px)";
|
||
hostnameEl.style.userSelect = "none";
|
||
hostnameEl.title = "Hidden for privacy";
|
||
} else {
|
||
hostnameEl.style.filter = "";
|
||
hostnameEl.style.userSelect = "";
|
||
hostnameEl.title = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
// Refresh all views after privacy change
|
||
function refreshAllViews() {
|
||
if (window.lastCerebroData) renderCerebro(window.lastCerebroData);
|
||
renderSessions(sessionsData);
|
||
if (cronData && cronData.length > 0) renderCron(cronData);
|
||
updateHostnameDisplay();
|
||
}
|
||
|
||
// Render all privacy lists
|
||
function renderPrivacyLists() {
|
||
renderHiddenTopicsList();
|
||
renderHiddenSessionsList();
|
||
renderHiddenCronsList();
|
||
}
|
||
|
||
function renderHiddenTopicsList() {
|
||
const container = document.getElementById("hidden-topics-list");
|
||
if (!container) return;
|
||
const hidden = getHiddenTopics();
|
||
|
||
// Update count in heading
|
||
const countEl = document.getElementById("privacy-topics-count");
|
||
if (countEl) countEl.textContent = `(${hidden.length})`;
|
||
|
||
if (hidden.length === 0) {
|
||
container.innerHTML =
|
||
'<em style="color: var(--text-muted); font-size: 0.85rem;">No topics hidden</em>';
|
||
return;
|
||
}
|
||
|
||
// Display name, but remove by id
|
||
container.innerHTML = hidden
|
||
.map(
|
||
(item) => `
|
||
<div style="display: inline-flex; align-items: center; padding: 4px 10px; background: var(--bg); border-radius: 12px; margin: 2px; font-size: 0.85rem;">
|
||
<span>${escapeHtml(item.name || item.id)}</span>
|
||
<button onclick="removeHiddenTopic('${escapeHtml(item.id).replace(/'/g, "\\'")}')" style="background: none; border: none; color: var(--error); cursor: pointer; margin-left: 6px; font-size: 0.9rem;" title="${i18nText("actions.remove", {}, "Remove")}">✕</button>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function renderHiddenSessionsList() {
|
||
const container = document.getElementById("hidden-sessions-list");
|
||
if (!container) return;
|
||
const hidden = getHiddenSessions();
|
||
|
||
// Update count in heading
|
||
const countEl = document.getElementById("privacy-sessions-count");
|
||
if (countEl) countEl.textContent = `(${hidden.length})`;
|
||
|
||
if (hidden.length === 0) {
|
||
container.innerHTML =
|
||
'<em style="color: var(--text-muted); font-size: 0.85rem;">No sessions hidden</em>';
|
||
return;
|
||
}
|
||
|
||
// Display name, but remove by id
|
||
container.innerHTML = hidden
|
||
.map(
|
||
(item) => `
|
||
<div style="display: inline-flex; align-items: center; padding: 4px 10px; background: var(--bg); border-radius: 12px; margin: 2px; font-size: 0.85rem;">
|
||
<span>${escapeHtml(item.name || item.id)}</span>
|
||
<button onclick="removeHiddenSession('${escapeHtml(item.id).replace(/'/g, "\\'")}')" style="background: none; border: none; color: var(--error); cursor: pointer; margin-left: 6px; font-size: 0.9rem;" title="${i18nText("actions.remove", {}, "Remove")}">✕</button>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function renderHiddenCronsList() {
|
||
const container = document.getElementById("hidden-crons-list");
|
||
if (!container) return;
|
||
const hidden = getHiddenCrons();
|
||
|
||
// Update count in heading
|
||
const countEl = document.getElementById("privacy-crons-count");
|
||
if (countEl) countEl.textContent = `(${hidden.length})`;
|
||
|
||
if (hidden.length === 0) {
|
||
container.innerHTML = `<em style="color: var(--text-muted); font-size: 0.85rem;">${i18nText("privacy.noCronHidden", {}, "No cron jobs hidden")}</em>`;
|
||
return;
|
||
}
|
||
|
||
// Display name, but remove by id
|
||
container.innerHTML = hidden
|
||
.map(
|
||
(item) => `
|
||
<div style="display: inline-flex; align-items: center; padding: 4px 10px; background: var(--bg); border-radius: 12px; margin: 2px; font-size: 0.85rem;">
|
||
<span>${escapeHtml(item.name || item.id)}</span>
|
||
<button onclick="removeHiddenCron('${escapeHtml(item.id).replace(/'/g, "\\'")}')" style="background: none; border: none; color: var(--error); cursor: pointer; margin-left: 6px; font-size: 0.9rem;" title="${i18nText("actions.remove", {}, "Remove")}">✕</button>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function openPrivacyModal() {
|
||
document.getElementById("privacy-modal").classList.remove("hidden");
|
||
document.getElementById("privacy-modal").classList.add("visible");
|
||
document.getElementById("privacy-modal-overlay").classList.remove("hidden");
|
||
document.getElementById("privacy-modal-overlay").classList.add("visible");
|
||
renderPrivacyLists();
|
||
// Set checkbox state
|
||
const hostnameCheckbox = document.getElementById("privacy-hide-hostname");
|
||
if (hostnameCheckbox) hostnameCheckbox.checked = isHostnameHidden();
|
||
}
|
||
|
||
function closePrivacyModal() {
|
||
document.getElementById("privacy-modal").classList.remove("visible");
|
||
document.getElementById("privacy-modal-overlay").classList.remove("visible");
|
||
setTimeout(() => {
|
||
document.getElementById("privacy-modal").classList.add("hidden");
|
||
document.getElementById("privacy-modal-overlay").classList.add("hidden");
|
||
}, 200);
|
||
}
|
||
|
||
// Format currency with commas
|
||
function formatCurrency(n, decimals = 2) {
|
||
return (n || 0).toLocaleString("en-US", {
|
||
minimumFractionDigits: decimals,
|
||
maximumFractionDigits: decimals,
|
||
});
|
||
}
|
||
|
||
function renderCostBreakdown(data) {
|
||
// Token usage
|
||
document.getElementById("cost-tokens").innerHTML = `
|
||
<div class="detail-row"><span class="detail-label">Input Tokens</span><span class="detail-value">${(data.inputTokens || 0).toLocaleString()}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Output Tokens</span><span class="detail-value">${(data.outputTokens || 0).toLocaleString()}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Cache Read</span><span class="detail-value">${(data.cacheRead || 0).toLocaleString()}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Cache Write</span><span class="detail-value">${(data.cacheWrite || 0).toLocaleString()}</span></div>
|
||
<div class="detail-row"><span class="detail-label">API Requests</span><span class="detail-value">${(data.requests || 0).toLocaleString()}</span></div>
|
||
`;
|
||
|
||
// Pricing rates
|
||
document.getElementById("cost-rates").innerHTML = `
|
||
<div class="detail-row"><span class="detail-label">Input</span><span class="detail-value">$${data.rates?.input || "15.00"}/1M tokens</span></div>
|
||
<div class="detail-row"><span class="detail-label">Output</span><span class="detail-value">$${data.rates?.output || "75.00"}/1M tokens</span></div>
|
||
<div class="detail-row"><span class="detail-label">Cache Read</span><span class="detail-value">$${data.rates?.cacheRead || "1.50"}/1M tokens (90% discount)</span></div>
|
||
<div class="detail-row"><span class="detail-label">Cache Write</span><span class="detail-value">$${data.rates?.cacheWrite || "18.75"}/1M tokens (25% premium)</span></div>
|
||
`;
|
||
|
||
// Calculation breakdown
|
||
const calc = data.calculation || {};
|
||
document.getElementById("cost-calculation").innerHTML = `
|
||
<div class="detail-row"><span class="detail-label">Input Cost</span><span class="detail-value">$${formatCurrency(calc.inputCost, 2)}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Output Cost</span><span class="detail-value">$${formatCurrency(calc.outputCost, 2)}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Cache Read Cost</span><span class="detail-value">$${formatCurrency(calc.cacheReadCost, 2)}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Cache Write Cost</span><span class="detail-value">$${formatCurrency(calc.cacheWriteCost, 2)}</span></div>
|
||
<div class="detail-row" style="border-top: 1px solid var(--border); padding-top: 8px; margin-top: 8px;">
|
||
<span class="detail-label"><strong>Est. API Cost</strong></span>
|
||
<span class="detail-value"><strong>$${formatCurrency(data.totalCost)}</strong></span>
|
||
</div>
|
||
`;
|
||
|
||
// Savings - show all three windows (24h, 3da, 7da)
|
||
const planCost = data.planCost || 200;
|
||
const planName = data.planName || "Claude Code Max";
|
||
const windows = data.windows || {};
|
||
|
||
// Build savings rows for each window
|
||
const windowOrder = ["24h", "3d", "7d"];
|
||
const windowLabels = { "24h": "24h", "3d": "3dma", "7d": "7dma" };
|
||
|
||
let savingsHtml = `
|
||
<div class="detail-row"><span class="detail-label">${planName}</span><span class="detail-value">$${planCost}/month</span></div>
|
||
<div style="margin-top: 12px; margin-bottom: 8px; font-size: 0.85rem; color: var(--text-muted);">Projected Monthly Cost by Window:</div>
|
||
`;
|
||
|
||
let hasSavings = false;
|
||
for (const key of windowOrder) {
|
||
const w = windows[key];
|
||
if (!w) continue;
|
||
|
||
const monthlyProjected = w.monthlyProjected || 0;
|
||
const savings = w.monthlySavings || 0;
|
||
const pct = w.savingsPercent || 0;
|
||
|
||
if (savings > 0) hasSavings = true;
|
||
|
||
const savingsStr =
|
||
savings > 0
|
||
? `<span style="color: var(--green);">↓$${Math.round(savings).toLocaleString()}/mo (${pct}%)</span>`
|
||
: `<span style="color: var(--text-muted);">-</span>`;
|
||
|
||
savingsHtml += `
|
||
<div class="detail-row">
|
||
<span class="detail-label">${windowLabels[key]}</span>
|
||
<span class="detail-value">$${Math.round(monthlyProjected).toLocaleString()}/mo ${savingsStr}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (!hasSavings) {
|
||
savingsHtml += `
|
||
<p style="margin-top: 12px; font-size: 0.85rem; color: var(--text-muted);">
|
||
📈 Keep going! Once your monthly cost exceeds $${planCost}, you'll start seeing savings.
|
||
</p>
|
||
`;
|
||
}
|
||
|
||
document.getElementById("cost-savings").innerHTML = savingsHtml;
|
||
|
||
// Top sessions by tokens
|
||
const topSessions = data.topSessions || [];
|
||
if (topSessions.length > 0) {
|
||
document.getElementById("cost-top-sessions").innerHTML = topSessions
|
||
.map((s, i) => {
|
||
const tokensFormatted =
|
||
s.tokens >= 1000000
|
||
? (s.tokens / 1000000).toFixed(1) + "M"
|
||
: s.tokens >= 1000
|
||
? (s.tokens / 1000).toFixed(1) + "k"
|
||
: s.tokens;
|
||
const statusIcon = s.active ? "🟢" : "⚪";
|
||
return `<div class="detail-row">
|
||
<span class="detail-label">${statusIcon} ${s.label.substring(0, 30)}${s.label.length > 30 ? "..." : ""}</span>
|
||
<span class="detail-value">${tokensFormatted}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
} else {
|
||
document.getElementById("cost-top-sessions").innerHTML =
|
||
'<em style="color: var(--text-muted)">No session data available</em>';
|
||
}
|
||
}
|
||
|
||
async function fetchSessionDetail(key) {
|
||
// Handle null/undefined sessionKey
|
||
if (!key) {
|
||
console.error("fetchSessionDetail: no session key provided");
|
||
renderDetailError("No session key provided");
|
||
return;
|
||
}
|
||
|
||
// Add timeout to prevent hanging forever
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||
|
||
try {
|
||
const res = await fetch(`/api/session?key=${encodeURIComponent(key)}`, {
|
||
signal: controller.signal,
|
||
});
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Check for API error response
|
||
if (data.error) {
|
||
console.error("API error:", data.error);
|
||
renderDetailError(data.error);
|
||
return;
|
||
}
|
||
|
||
renderDetail(data);
|
||
} catch (e) {
|
||
clearTimeout(timeoutId);
|
||
console.error("Failed to fetch detail:", e);
|
||
const msg =
|
||
e.name === "AbortError" ? "Request timed out" : e.message || "Failed to load session";
|
||
renderDetailError(msg);
|
||
}
|
||
}
|
||
|
||
function renderDetailError(message) {
|
||
const errorHtml = `<em style="color: var(--red, #ff6b6b)">⚠️ ${escapeHtml(message)}</em>`;
|
||
["overview", "summary", "links", "attention", "facts", "tools", "messages"].forEach(
|
||
(id) => {
|
||
const el = document.getElementById(`detail-${id}`);
|
||
if (el) el.innerHTML = errorHtml;
|
||
},
|
||
);
|
||
}
|
||
|
||
function renderDetail(data) {
|
||
// Update the title with the best available channel name
|
||
const titleEl = document.getElementById("detail-title");
|
||
const channelName = data.groupChannel || data.channel || "Session Details";
|
||
titleEl.textContent = channelName;
|
||
|
||
// Build cost display if available
|
||
const costDisplay = data.estCost
|
||
? `<div class="detail-row"><span class="detail-label">Est. Cost</span><span class="detail-value">${data.estCost}</span></div>`
|
||
: "";
|
||
|
||
// Build cache display if we have cache data
|
||
const cacheDisplay =
|
||
data.cacheRead || data.cacheWrite
|
||
? `<div class="detail-row"><span class="detail-label">Cache (R/W)</span><span class="detail-value">${(data.cacheRead || 0).toLocaleString()} / ${(data.cacheWrite || 0).toLocaleString()}</span></div>`
|
||
: "";
|
||
|
||
// Overview
|
||
smartUpdate(
|
||
document.getElementById("detail-overview"),
|
||
`
|
||
<div class="detail-row"><span class="detail-label">Channel</span><span class="detail-value">${data.channel || "-"}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value">${data.model || "-"}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Total Tokens</span><span class="detail-value">${data.tokens?.toLocaleString() || "-"}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Input / Output</span><span class="detail-value">${(data.inputTokens || 0).toLocaleString()} / ${(data.outputTokens || 0).toLocaleString()}</span></div>
|
||
${cacheDisplay}
|
||
${costDisplay}
|
||
<div class="detail-row"><span class="detail-label">Last Active</span><span class="detail-value">${data.lastActive || "-"}</span></div>
|
||
`,
|
||
);
|
||
|
||
// Summary
|
||
smartUpdate(
|
||
document.getElementById("detail-summary"),
|
||
data.summary || "<em>No summary</em>",
|
||
);
|
||
|
||
// Links/References
|
||
const allText = (data.messages || []).map((m) => m.text).join(" ");
|
||
const links = extractLinks(allText);
|
||
if (data.links) links.push(...data.links);
|
||
|
||
if (links.length > 0) {
|
||
smartUpdate(
|
||
document.getElementById("detail-links"),
|
||
links
|
||
.map((l) => {
|
||
const cls =
|
||
l.type === "linear"
|
||
? "linear"
|
||
: l.type === "github"
|
||
? "github"
|
||
: l.type === "file"
|
||
? "file"
|
||
: "slack";
|
||
const icon =
|
||
l.type === "linear"
|
||
? "📋"
|
||
: l.type === "github"
|
||
? "🐙"
|
||
: l.type === "file"
|
||
? "📁"
|
||
: "💬";
|
||
if (l.url) {
|
||
return `<a href="${l.url}" target="_blank" class="link-tag ${cls}">${icon} ${l.id}</a>`;
|
||
}
|
||
return `<span class="link-tag ${cls}">${icon} ${l.id}</span>`;
|
||
})
|
||
.join(" "),
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
document.getElementById("detail-links"),
|
||
'<em style="color: var(--text-muted)">No references detected</em>',
|
||
);
|
||
}
|
||
|
||
// Needs Attention
|
||
if (data.needsAttention?.length > 0) {
|
||
smartUpdate(
|
||
document.getElementById("detail-attention"),
|
||
data.needsAttention
|
||
.map((a) => `<div class="attention-item">${escapeHtml(a)}</div>`)
|
||
.join(""),
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
document.getElementById("detail-attention"),
|
||
'<em style="color: var(--text-muted)">Nothing needs attention</em>',
|
||
);
|
||
}
|
||
|
||
// Facts
|
||
if (data.facts?.length > 0) {
|
||
smartUpdate(
|
||
document.getElementById("detail-facts"),
|
||
data.facts.map((f) => `<div class="attention-item">✅ ${escapeHtml(f)}</div>`).join(""),
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
document.getElementById("detail-facts"),
|
||
'<em style="color: var(--text-muted)">No key facts</em>',
|
||
);
|
||
}
|
||
|
||
// Tools
|
||
if (data.tools?.length > 0) {
|
||
smartUpdate(
|
||
document.getElementById("detail-tools"),
|
||
data.tools
|
||
.map(
|
||
(t) =>
|
||
`<span class="tool-tag"><code>${t.name}</code> <span class="tool-count">×${t.count}</span></span>`,
|
||
)
|
||
.join(""),
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
document.getElementById("detail-tools"),
|
||
'<em style="color: var(--text-muted)">No tools used</em>',
|
||
);
|
||
}
|
||
|
||
// Messages
|
||
if (data.messages?.length > 0) {
|
||
smartUpdate(
|
||
document.getElementById("detail-messages"),
|
||
data.messages
|
||
.map(
|
||
(m) => `
|
||
<div class="message-item">
|
||
<div class="message-role ${m.role}">${m.role === "user" ? "👤 User" : "🦞 Assistant"}</div>
|
||
<div class="message-text">${escapeHtml(m.text?.slice(0, 400) || "")}${m.text?.length > 400 ? "..." : ""}</div>
|
||
</div>
|
||
`,
|
||
)
|
||
.join(""),
|
||
);
|
||
} else {
|
||
smartUpdate(
|
||
document.getElementById("detail-messages"),
|
||
'<em style="color: var(--text-muted)">No messages</em>',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") closeDetail();
|
||
if (e.key === "b" && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
toggleSidebar();
|
||
}
|
||
});
|
||
|
||
// ============================================================================
|
||
// SSE (Server-Sent Events) for real-time updates
|
||
// ============================================================================
|
||
let eventSource = null;
|
||
let sseConnected = false;
|
||
let sseReconnectAttempts = 0;
|
||
const SSE_MAX_RECONNECT_DELAY = 30000; // Max 30s between reconnects
|
||
let pollInterval = null;
|
||
|
||
function setConnectionStatus(status, message) {
|
||
const pill = document.getElementById("connection-status");
|
||
const statusText = document.getElementById("gateway-status");
|
||
const refreshMode = document.getElementById("refresh-mode");
|
||
|
||
pill.classList.remove("connected", "disconnected", "connecting");
|
||
pill.classList.add(status);
|
||
|
||
// Update the data-i18n attribute so the i18n system doesn't overwrite our status.
|
||
// The i18n translateSubtree() re-applies data-i18n text, so we must keep the
|
||
// attribute in sync with the intended display state.
|
||
// Map status to i18n key, or remove data-i18n for custom messages
|
||
const i18nKeyMap = {
|
||
connected: "app.connected",
|
||
connecting: "app.connecting",
|
||
disconnected: "app.disconnected",
|
||
};
|
||
const i18nKey = i18nKeyMap[status];
|
||
if (i18nKey) {
|
||
statusText.setAttribute("data-i18n", i18nKey);
|
||
} else {
|
||
statusText.removeAttribute("data-i18n");
|
||
}
|
||
statusText.textContent = message;
|
||
|
||
// Update refresh bar mode indicator
|
||
if (status === "connected") {
|
||
refreshMode.textContent = i18nText(
|
||
"connection.realtime",
|
||
{},
|
||
"Real-time updates via SSE ⚡",
|
||
);
|
||
} else if (status === "disconnected") {
|
||
refreshMode.textContent = i18nText(
|
||
"connection.polling",
|
||
{},
|
||
"Polling mode (SSE disconnected)",
|
||
);
|
||
} else {
|
||
refreshMode.textContent = i18nText("app.connecting", {}, "Connecting...");
|
||
}
|
||
}
|
||
|
||
function connectSSE() {
|
||
// Check for SSE support
|
||
if (typeof EventSource === "undefined") {
|
||
console.warn("[SSE] EventSource not supported, using polling fallback");
|
||
setConnectionStatus("connected", i18nText("app.pollingMode", {}, "Polling Mode"));
|
||
// Override i18n key for polling mode specifically
|
||
document.getElementById("gateway-status")?.setAttribute("data-i18n", "app.pollingMode");
|
||
startPolling();
|
||
return;
|
||
}
|
||
|
||
setConnectionStatus("connecting", i18nText("app.connecting", {}, "Connecting..."));
|
||
|
||
try {
|
||
eventSource = new EventSource("/api/events");
|
||
|
||
eventSource.onopen = function () {
|
||
console.log("[SSE] Connected");
|
||
sseConnected = true;
|
||
sseReconnectAttempts = 0;
|
||
setConnectionStatus("connected", "🟢 Live");
|
||
stopPolling(); // Stop polling when SSE connects
|
||
};
|
||
|
||
eventSource.addEventListener("connected", function (e) {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
console.log("[SSE] Server greeting:", data.message);
|
||
} catch (err) {}
|
||
});
|
||
|
||
eventSource.addEventListener("update", function (e) {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
handleSSEUpdate(data);
|
||
} catch (err) {
|
||
console.error("[SSE] Failed to parse update:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("heartbeat", function (e) {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
console.log("[SSE] Heartbeat, clients:", data.clients);
|
||
// Update timestamp on heartbeat
|
||
const now = new Date().toLocaleTimeString();
|
||
document.getElementById("last-updated").textContent = now + " ⚡";
|
||
document.getElementById("sidebar-updated").textContent = i18nText(
|
||
"sidebar.live",
|
||
{ time: now },
|
||
`Live: ${now}`,
|
||
);
|
||
} catch (err) {}
|
||
});
|
||
|
||
eventSource.onerror = function (e) {
|
||
console.error("[SSE] Connection error");
|
||
sseConnected = false;
|
||
eventSource.close();
|
||
eventSource = null;
|
||
setConnectionStatus("disconnected", "🔴 Disconnected");
|
||
|
||
// Exponential backoff for reconnection
|
||
sseReconnectAttempts++;
|
||
const delay = Math.min(
|
||
1000 * Math.pow(2, sseReconnectAttempts - 1),
|
||
SSE_MAX_RECONNECT_DELAY,
|
||
);
|
||
console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${sseReconnectAttempts})`);
|
||
|
||
// Start polling as fallback while disconnected
|
||
startPolling();
|
||
|
||
setTimeout(connectSSE, delay);
|
||
};
|
||
} catch (err) {
|
||
console.error("[SSE] Failed to create EventSource:", err);
|
||
setConnectionStatus("disconnected", "🔴 Error");
|
||
startPolling();
|
||
}
|
||
}
|
||
|
||
function handleSSEUpdate(data) {
|
||
// Update timestamp with live indicator
|
||
const now = new Date().toLocaleTimeString();
|
||
document.getElementById("last-updated").textContent = now + " ⚡";
|
||
document.getElementById("sidebar-updated").textContent = i18nText(
|
||
"sidebar.live",
|
||
{ time: now },
|
||
`Live: ${now}`,
|
||
);
|
||
|
||
// Render full state (unified approach)
|
||
renderFullState(data);
|
||
}
|
||
|
||
// Polling fallback
|
||
function startPolling() {
|
||
if (pollInterval) return; // Already polling
|
||
console.log("[Polling] Starting fallback polling");
|
||
pollInterval = setInterval(fetchData, 2000); // Match SSE heartbeat
|
||
fetchData(); // Immediate fetch
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollInterval) {
|
||
console.log("[Polling] Stopping fallback polling (SSE connected)");
|
||
clearInterval(pollInterval);
|
||
pollInterval = null;
|
||
}
|
||
}
|
||
|
||
// Toast notifications
|
||
function showToast(message, type = "success") {
|
||
const container = document.getElementById("toast-container");
|
||
const toast = document.createElement("div");
|
||
toast.className = `toast ${type}`;
|
||
const icon = type === "success" ? "✅" : "❌";
|
||
toast.innerHTML = `<span>${icon}</span><span>${escapeHtml(message)}</span>`;
|
||
container.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
toast.style.animation = "toast-out 0.3s ease forwards";
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// Update topic status API call
|
||
async function updateTopicStatus(topicId, status) {
|
||
try {
|
||
const res = await fetch(`/api/cerebro/topic/${encodeURIComponent(topicId)}/status`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
throw new Error(data.error || "Failed to update status");
|
||
}
|
||
|
||
showToast(`Topic "${data.topic.title}" marked as ${status}`);
|
||
// Refresh Cerebro data
|
||
refreshCerebro();
|
||
} catch (e) {
|
||
showToast(`Error: ${e.message}`, "error");
|
||
}
|
||
}
|
||
|
||
// Sub-agent Status Rendering
|
||
function renderSubagents(subagents) {
|
||
const grid = document.getElementById("subagent-grid");
|
||
const countElement = document.getElementById("subagent-count");
|
||
|
||
countElement.textContent = subagents.length;
|
||
|
||
if (subagents.length === 0) {
|
||
smartUpdate(
|
||
grid,
|
||
'<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-muted); font-size: 0.9rem;">No active sub-agents</div>',
|
||
);
|
||
return;
|
||
}
|
||
|
||
smartUpdate(
|
||
grid,
|
||
subagents
|
||
.map((s) => {
|
||
const ageMinutes = Math.round(s.ageMs / 60000);
|
||
const ageHours = Math.round(s.ageMs / 3600000);
|
||
const ageDisplay = ageMinutes < 60 ? `${ageMinutes}m` : `${ageHours}h`;
|
||
const statusClass = ageHours > 2 ? "stale" : s.tokens > 1000 ? "active" : "idle";
|
||
const statusText = statusClass.charAt(0).toUpperCase() + statusClass.slice(1);
|
||
|
||
// Build the session key for detail panel
|
||
const sessionKey = `agent:main:subagent:${s.id}`;
|
||
const label = s.task || `Sub-agent ${s.shortId}`;
|
||
|
||
return `
|
||
<div class="subagent-card ${statusClass}" onclick="openDetail('${escapeHtml(sessionKey)}', '${escapeHtml(label.substring(0, 60))}')" style="cursor: pointer;">
|
||
<div class="subagent-header">
|
||
<div class="subagent-info">
|
||
<h4>${escapeHtml(s.task || s.id)}</h4>
|
||
<div class="subagent-meta">ID: ${s.shortId} • ${s.tokens} tokens • ${ageDisplay}</div>
|
||
</div>
|
||
<div class="subagent-status ${statusClass}">${statusText}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})
|
||
.join(""),
|
||
);
|
||
}
|
||
|
||
// Enhanced Token Stats with Quota Visualization
|
||
function renderTokenQuotas(tokenStats, quotaData) {
|
||
if (!quotaData) return;
|
||
|
||
// Claude quotas (compact card)
|
||
if (quotaData.claude) {
|
||
const sessionPct = Math.round((quotaData.claude.sessionUsage || 0) * 100);
|
||
const weekPct = Math.round((quotaData.claude.weekUsage || 0) * 100);
|
||
|
||
document.getElementById("claude-compact-session-pct").textContent = `${sessionPct}%`;
|
||
document.getElementById("claude-compact-week-pct").textContent = `${weekPct}%`;
|
||
|
||
const sessionBar = document.getElementById("claude-compact-session-bar");
|
||
const weekBar = document.getElementById("claude-compact-week-bar");
|
||
|
||
sessionBar.style.width = `${sessionPct}%`;
|
||
weekBar.style.width = `${weekPct}%`;
|
||
|
||
// Color coding
|
||
sessionBar.className = `quota-bar-fill ${sessionPct < 50 ? "low" : sessionPct < 80 ? "medium" : "high"}`;
|
||
weekBar.className = `quota-bar-fill ${weekPct < 50 ? "low" : weekPct < 80 ? "medium" : "high"}`;
|
||
}
|
||
|
||
// Codex quotas
|
||
if (quotaData.codex) {
|
||
const h5Pct = Math.round((quotaData.codex.usage5h || 0) * 100);
|
||
const dayPct = Math.round((quotaData.codex.usageDay || 0) * 100);
|
||
|
||
document.getElementById("codex-5h-pct").textContent = `${h5Pct}%`;
|
||
document.getElementById("codex-day-pct").textContent = `${dayPct}%`;
|
||
document.getElementById("codex-tasks").textContent = quotaData.codex.tasksToday || 0;
|
||
|
||
const h5Bar = document.getElementById("codex-5h-bar");
|
||
const dayBar = document.getElementById("codex-day-bar");
|
||
|
||
h5Bar.style.width = `${h5Pct}%`;
|
||
dayBar.style.width = `${dayPct}%`;
|
||
|
||
h5Bar.className = `quota-bar-fill ${h5Pct < 50 ? "low" : h5Pct < 80 ? "medium" : "high"}`;
|
||
dayBar.className = `quota-bar-fill ${dayPct < 50 ? "low" : dayPct < 80 ? "medium" : "high"}`;
|
||
}
|
||
}
|
||
|
||
// Quick Actions
|
||
async function runHealthCheck() {
|
||
try {
|
||
const response = await fetch("/api/action?action=health-check");
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast("✅ Health check passed", "success");
|
||
} else {
|
||
showToast("❌ Health check failed: " + (data.error || data.output), "error");
|
||
}
|
||
} catch (e) {
|
||
showToast("❌ Health check error: " + e.message, "error");
|
||
}
|
||
}
|
||
|
||
async function getGatewayStatus() {
|
||
try {
|
||
const response = await fetch("/api/action?action=gateway-status");
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast("🚪 Gateway: " + data.output, "success");
|
||
} else {
|
||
showToast("❌ Gateway status failed: " + (data.error || data.output), "error");
|
||
}
|
||
} catch (e) {
|
||
showToast("❌ Gateway error: " + e.message, "error");
|
||
}
|
||
}
|
||
|
||
async function pruneStalesSessions() {
|
||
try {
|
||
const response = await fetch("/api/action?action=prune-stale");
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast("🧹 " + data.output, "success");
|
||
} else {
|
||
showToast("❌ Cleanup failed: " + (data.error || data.output), "error");
|
||
}
|
||
} catch (e) {
|
||
showToast("❌ Cleanup error: " + e.message, "error");
|
||
}
|
||
}
|
||
|
||
// Toast notifications
|
||
function showToast(message, type = "success") {
|
||
const container = document.getElementById("toast-container");
|
||
if (!container) return;
|
||
|
||
const toast = document.createElement("div");
|
||
toast.className = `toast ${type}`;
|
||
const icon = type === "success" ? "✅" : "❌";
|
||
toast.innerHTML = `<span>${icon}</span><span>${escapeHtml(message)}</span>`;
|
||
container.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
toast.style.animation = "toast-out 0.3s ease forwards";
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// Init - try SSE first, fall back to polling
|
||
async function init() {
|
||
// Load privacy settings from server first (before rendering any data)
|
||
await loadPrivacyFromServer();
|
||
console.log("[Privacy] Settings loaded from server");
|
||
|
||
connectSSE();
|
||
// Populate version badge from server (single source of truth: package.json)
|
||
fetch("/api/about")
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
const el = document.getElementById("app-version");
|
||
if (el && d.version) el.textContent = "v" + d.version;
|
||
})
|
||
.catch(() => {});
|
||
// Fetch optional dependency status (once)
|
||
fetch("/api/vitals")
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (d.optionalDeps) optionalDeps = d.optionalDeps;
|
||
})
|
||
.catch(() => {});
|
||
// Fetch data after short delay to ensure DOM is ready
|
||
// This fixes a race condition on initial page load
|
||
setTimeout(fetchData, 100);
|
||
|
||
// Apply privacy settings on load
|
||
updateHostnameDisplay();
|
||
|
||
// Initialize privacy checkbox state
|
||
const hostnameCheckbox = document.getElementById("privacy-hide-hostname");
|
||
if (hostnameCheckbox) {
|
||
hostnameCheckbox.checked = isHostnameHidden();
|
||
}
|
||
|
||
// Set up savings window selector
|
||
const windowSelect = document.getElementById("savings-window-select");
|
||
if (windowSelect) {
|
||
// Load saved preference
|
||
windowSelect.value = getSavingsWindowPref();
|
||
|
||
// Handle changes
|
||
windowSelect.addEventListener("change", function (e) {
|
||
const newWindow = e.target.value;
|
||
setSavingsWindowPref(newWindow);
|
||
if (currentTokenStats) {
|
||
updateSavingsDisplay(currentTokenStats, newWindow);
|
||
}
|
||
});
|
||
}
|
||
|
||
window.addEventListener("i18n:updated", () => {
|
||
fetchData();
|
||
renderHiddenCronsList();
|
||
renderHiddenSessionsList();
|
||
renderHiddenTopicsList();
|
||
});
|
||
}
|
||
|
||
// Ensure DOM is ready before initializing
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
setTimeout(init, 0);
|
||
}
|
||
</script>
|
||
|
||
<!-- Toast notifications container -->
|
||
<div id="toast-container" class="toast-container"></div>
|
||
</body>
|
||
</html>
|