Files
jontsai_command-center/public/index.html

4662 lines
186 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>