Files
jontsai_command-center/public/index.html

4662 lines
186 KiB
HTML
Raw Normal View History

<!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 (&lt;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>