Initial commit with translated description

This commit is contained in:
2026-03-29 10:19:19 +08:00
commit 5aa1f324c6
81 changed files with 27526 additions and 0 deletions

1936
public/css/dashboard.css Normal file

File diff suppressed because it is too large Load Diff

29
public/data/AGENTS.md Normal file
View File

@@ -0,0 +1,29 @@
# public/data/ — User-Specific Data
**DO NOT COMMIT** the following files — they contain user-specific data:
| File | Purpose | Template |
| ----------------------- | -------------------------------- | ------------------------------- |
| `operators.json` | User/operator info from sessions | `operators.json.example` |
| `privacy-settings.json` | Hidden topics/sessions for demos | `privacy-settings.json.example` |
## Why?
These files are generated at runtime and contain:
- User IDs and usernames
- Session counts and activity
- Privacy preferences (what the user hides)
Committing them would leak user data to the public repo.
## For New Installations
Copy the `.example` files to get started:
```bash
cp operators.json.example operators.json
cp privacy-settings.json.example privacy-settings.json
```
The dashboard will populate these automatically on first run.

3
public/favicon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y=".9em" font-size="90">🦞</text>
</svg>

After

Width:  |  Height:  |  Size: 114 B

4661
public/index.html Normal file

File diff suppressed because it is too large Load Diff

1379
public/jobs.html Normal file

File diff suppressed because it is too large Load Diff

119
public/js/api.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* API and SSE connection management for Command Center
*/
// SSE connection state
let eventSource = null;
let sseConnected = false;
let sseReconnectAttempts = 0;
let pollInterval = null;
const SSE_MAX_RECONNECT_DELAY = 30000;
/**
* Fetch the unified state from the server
* @returns {Promise<Object>} Dashboard state
*/
export async function fetchState() {
const response = await fetch("/api/state");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
/**
* Connect to SSE for real-time updates
* @param {Function} onUpdate - Callback when state updates
* @param {Function} onStatusChange - Callback for connection status changes
*/
export function connectSSE(onUpdate, onStatusChange) {
if (typeof EventSource === "undefined") {
console.warn("[SSE] EventSource not supported, using polling fallback");
onStatusChange?.("polling", "Polling Mode");
startPolling(onUpdate);
return;
}
onStatusChange?.("connecting", "Connecting...");
try {
eventSource = new EventSource("/api/events");
eventSource.onopen = function () {
console.log("[SSE] Connected");
sseConnected = true;
sseReconnectAttempts = 0;
onStatusChange?.("connected", "🟢 Live");
stopPolling();
};
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);
onUpdate?.(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);
} catch (err) {}
});
eventSource.onerror = function (e) {
console.error("[SSE] Connection error");
sseConnected = false;
eventSource.close();
eventSource = null;
onStatusChange?.("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(onUpdate);
setTimeout(() => connectSSE(onUpdate, onStatusChange), delay);
};
} catch (err) {
console.error("[SSE] Failed to create EventSource:", err);
onStatusChange?.("disconnected", "🔴 Error");
startPolling(onUpdate);
}
}
function startPolling(onUpdate) {
if (pollInterval) return;
console.log("[Polling] Starting fallback polling");
pollInterval = setInterval(async () => {
try {
const state = await fetchState();
onUpdate?.(state);
} catch (err) {
console.error("[Polling] Failed:", err);
}
}, 2000);
}
function stopPolling() {
if (pollInterval) {
console.log("[Polling] Stopping fallback polling (SSE connected)");
clearInterval(pollInterval);
pollInterval = null;
}
}
export function isConnected() {
return sseConnected;
}

309
public/js/app.js Normal file
View File

@@ -0,0 +1,309 @@
/**
* OpenClaw Command Center - Main Application
*
* Uses morphdom for efficient DOM updates (only patches what changed).
*/
// Import morphdom (loaded as UMD, available as global `morphdom`)
// <script src="/js/lib/morphdom.min.js"></script> must be loaded first
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
const state = {
vitals: null,
sessions: [],
tokenStats: {},
statusCounts: { all: 0, live: 0, recent: 0, idle: 0 },
capacity: { main: { active: 0, max: 12 }, subagent: { active: 0, max: 24 } },
operators: { operators: [], roles: {} },
llmUsage: null,
cron: [],
memory: null,
cerebro: null,
subagents: [],
lastUpdated: null,
connected: false,
};
// ============================================================================
// SSE CONNECTION
// ============================================================================
let eventSource = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000;
function connectSSE() {
if (typeof EventSource === "undefined") {
console.warn("[SSE] Not supported, falling back to polling");
startPolling();
return;
}
updateConnectionStatus("connecting");
eventSource = new EventSource("/api/events");
eventSource.onopen = () => {
console.log("[SSE] Connected");
state.connected = true;
reconnectAttempts = 0;
updateConnectionStatus("connected");
};
eventSource.addEventListener("connected", (e) => {
const data = JSON.parse(e.data);
console.log("[SSE] Server greeting:", data.message);
});
eventSource.addEventListener("update", (e) => {
const data = JSON.parse(e.data);
handleStateUpdate(data);
});
eventSource.addEventListener("heartbeat", (e) => {
const data = JSON.parse(e.data);
state.lastUpdated = new Date();
updateTimestamp();
});
eventSource.onerror = () => {
console.error("[SSE] Connection error");
state.connected = false;
eventSource.close();
updateConnectionStatus("disconnected");
// Exponential backoff
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
console.log(`[SSE] Reconnecting in ${delay}ms`);
setTimeout(connectSSE, delay);
};
}
// ============================================================================
// STATE UPDATES
// ============================================================================
function handleStateUpdate(data) {
// Merge new data into state
if (data.vitals) state.vitals = data.vitals;
if (data.sessions) state.sessions = data.sessions;
if (data.tokenStats) state.tokenStats = data.tokenStats;
if (data.statusCounts) state.statusCounts = data.statusCounts;
if (data.capacity) state.capacity = data.capacity;
if (data.operators) state.operators = data.operators;
if (data.llmUsage) state.llmUsage = data.llmUsage;
if (data.cron) state.cron = data.cron;
if (data.memory) state.memory = data.memory;
if (data.cerebro) state.cerebro = data.cerebro;
if (data.subagents) state.subagents = data.subagents;
state.lastUpdated = new Date();
// Re-render affected components using morphdom
renderAll();
}
// ============================================================================
// RENDERING (with morphdom)
// ============================================================================
function renderAll() {
// Each render function generates HTML and morphdom patches the DOM
renderVitals();
renderTokenStats();
renderLlmUsage();
renderSessions();
renderCron();
renderMemory();
renderCerebro();
renderOperators();
updateTimestamp();
}
// Utility: safely patch a container using morphdom
function patchElement(containerId, newHtml) {
const container = document.getElementById(containerId);
if (!container) return;
// Create a temporary element with the new content
const temp = document.createElement("div");
temp.innerHTML = newHtml;
// Use morphdom to efficiently patch only what changed
if (typeof morphdom !== "undefined") {
// Patch each child
while (container.firstChild && temp.firstChild) {
morphdom(container.firstChild, temp.firstChild);
temp.removeChild(temp.firstChild);
}
// Add any new children
while (temp.firstChild) {
container.appendChild(temp.firstChild);
}
// Remove extra children
while (container.childNodes.length > temp.childNodes.length) {
container.removeChild(container.lastChild);
}
} else {
// Fallback: direct innerHTML replacement
container.innerHTML = newHtml;
}
}
// ============================================================================
// COMPONENT RENDERERS (to be extracted to separate files)
// ============================================================================
function renderVitals() {
if (!state.vitals) return;
const v = state.vitals;
// Update individual elements (simpler than full morphdom for now)
setText("vitals-hostname", v.hostname || "-");
setText("vitals-uptime", v.uptime || "-");
if (v.cpu) {
const cpuPct = v.cpu.usage || 0;
setText("cpu-percent", cpuPct + "%");
setWidth("cpu-bar", cpuPct + "%");
setText("cpu-user", (v.cpu.userPercent?.toFixed(1) || "-") + "%");
setText("cpu-sys", (v.cpu.sysPercent?.toFixed(1) || "-") + "%");
setText("cpu-idle", (v.cpu.idlePercent?.toFixed(1) || "-") + "%");
setText("cpu-chip", v.cpu.chip || v.cpu.brand || "");
}
if (v.memory) {
const memPct = v.memory.percent || 0;
setText("mem-percent", memPct + "% used");
setWidth("mem-bar", memPct + "%");
setText("mem-summary", `${v.memory.usedFormatted || "-"} of ${v.memory.totalFormatted || "-"}`);
}
if (v.disk) {
const diskPct = v.disk.percent || 0;
setText("disk-percent", diskPct + "% used");
setWidth("disk-bar", diskPct + "%");
setText("disk-summary", `${v.disk.usedFormatted || "-"} of ${v.disk.totalFormatted || "-"}`);
}
}
function renderTokenStats() {
if (!state.tokenStats) return;
const t = state.tokenStats;
setText("stat-total-tokens", t.total || "-");
setText("stat-input", t.input || "-");
setText("stat-output", t.output || "-");
setText("stat-active", t.activeCount || "0");
setText("stat-cost", t.estCost || "-");
setText("stat-main", `${t.activeMainCount || 0}/${t.mainLimit || 12}`);
setText("stat-subagents", `${t.activeSubagentCount || 0}/${t.subagentLimit || 24}`);
}
function renderLlmUsage() {
// Placeholder - will be extracted to component
}
function renderSessions() {
// Placeholder - will be extracted to component
}
function renderCron() {
// Placeholder - will be extracted to component
}
function renderMemory() {
// Placeholder - will be extracted to component
}
function renderCerebro() {
// Placeholder - will be extracted to component
}
function renderOperators() {
// Placeholder - will be extracted to component
}
// ============================================================================
// UTILITIES
// ============================================================================
function setText(id, text) {
const el = document.getElementById(id);
if (el && el.textContent !== text) {
el.textContent = text;
}
}
function setWidth(id, width) {
const el = document.getElementById(id);
if (el && el.style.width !== width) {
el.style.width = width;
}
}
function updateTimestamp() {
const now = state.lastUpdated || new Date();
const timeStr = now.toLocaleTimeString();
const indicator = state.connected ? " ⚡" : "";
setText("last-updated", timeStr + indicator);
setText("sidebar-updated", state.connected ? `Live: ${timeStr}` : `Updated: ${timeStr}`);
}
function updateConnectionStatus(status) {
const el = document.getElementById("connection-status");
if (!el) return;
el.className = "connection-status " + status;
el.textContent =
status === "connected"
? "🟢 Live"
: status === "connecting"
? "🟡 Connecting..."
: "🔴 Disconnected";
}
// ============================================================================
// POLLING FALLBACK
// ============================================================================
let pollInterval = null;
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(fetchState, 5000);
fetchState();
}
async function fetchState() {
try {
const response = await fetch("/api/state");
const data = await response.json();
handleStateUpdate(data);
} catch (e) {
console.error("[Polling] Failed:", e);
}
}
// ============================================================================
// INITIALIZATION
// ============================================================================
function init() {
console.log("[App] Initializing OpenClaw Command Center");
connectSSE();
// Initial fetch to populate immediately
setTimeout(fetchState, 100);
}
// Start when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
setTimeout(init, 0);
}

386
public/js/i18n.js Normal file
View File

@@ -0,0 +1,386 @@
(function () {
"use strict";
const DEFAULT_LOCALE = "en";
const SUPPORTED_LOCALES = ["en", "zh-CN"];
const STORAGE_KEY = "occ.locale";
const loadedMessages = new Map();
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "CODE", "PRE"]);
let currentLocale = DEFAULT_LOCALE;
let activeMessages = {};
let observer = null;
let isApplyingTranslations = false;
function normalizeLocale(input) {
if (!input || typeof input !== "string") return DEFAULT_LOCALE;
const lc = input.toLowerCase();
if (lc === "zh" || lc.startsWith("zh-")) return "zh-CN";
return "en";
}
function getInitialLocale() {
const fromQuery = new URLSearchParams(window.location.search).get("lang");
if (fromQuery) return normalizeLocale(fromQuery);
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return normalizeLocale(fromStorage);
return normalizeLocale(navigator.language || DEFAULT_LOCALE);
}
async function loadLocaleMessages(locale) {
const normalized = normalizeLocale(locale);
if (loadedMessages.has(normalized)) return loadedMessages.get(normalized);
const response = await fetch(`/locales/${normalized}.json`, { cache: "no-cache" });
if (!response.ok) throw new Error(`Failed to load locale: ${normalized}`);
const data = await response.json();
loadedMessages.set(normalized, data || {});
return data || {};
}
function getByPath(obj, path) {
return String(path)
.split(".")
.reduce(
(acc, key) =>
acc && Object.prototype.hasOwnProperty.call(acc, key) ? acc[key] : undefined,
obj,
);
}
function interpolate(template, params = {}) {
if (typeof template !== "string") return template;
return template.replace(/\{(\w+)\}/g, (_, key) => {
return Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : `{${key}}`;
});
}
function t(key, params = {}, fallback = undefined) {
const value = getByPath(activeMessages, key);
if (value === undefined || value === null) {
if (fallback !== undefined) return interpolate(fallback, params);
return String(key);
}
return interpolate(value, params);
}
function buildReverseMap(source = {}) {
const reversed = {};
for (const [from, to] of Object.entries(source)) {
if (typeof from !== "string" || typeof to !== "string") continue;
if (!Object.prototype.hasOwnProperty.call(reversed, to)) {
reversed[to] = from;
}
}
return reversed;
}
function getExactPhraseMap() {
const exact = getByPath(activeMessages, "phrases.exact") || {};
if (currentLocale !== DEFAULT_LOCALE) return exact;
const zh = loadedMessages.get("zh-CN") || {};
const zhExact = getByPath(zh, "phrases.exact") || {};
return buildReverseMap(zhExact);
}
function getPatternRules() {
const localeRules = getByPath(activeMessages, "phrases.patterns");
if (Array.isArray(localeRules)) return localeRules;
if (currentLocale !== DEFAULT_LOCALE) return [];
const zh = loadedMessages.get("zh-CN") || {};
const zhRules = getByPath(zh, "phrases.reversePatterns");
return Array.isArray(zhRules) ? zhRules : [];
}
function translateTextValue(input) {
if (typeof input !== "string") return input;
if (!input.trim()) return input;
const leading = input.match(/^\s*/)?.[0] || "";
const trailing = input.match(/\s*$/)?.[0] || "";
let core = input.trim();
const exactMap = getExactPhraseMap();
if (Object.prototype.hasOwnProperty.call(exactMap, core)) {
return `${leading}${exactMap[core]}${trailing}`;
}
for (const rule of getPatternRules()) {
if (!rule || typeof rule.pattern !== "string" || typeof rule.replace !== "string") continue;
try {
const regex = new RegExp(rule.pattern);
if (regex.test(core)) {
core = core.replace(regex, rule.replace);
return `${leading}${core}${trailing}`;
}
} catch {
continue;
}
}
return input;
}
function setAttrIfChanged(el, attr, value) {
if (!el || typeof value !== "string") return;
if (el.getAttribute(attr) !== value) {
el.setAttribute(attr, value);
}
}
function translateLooseAttributes(el) {
if (el.hasAttribute("data-i18n-title")) {
setAttrIfChanged(el, "title", t(el.getAttribute("data-i18n-title")));
} else if (el.hasAttribute("title")) {
setAttrIfChanged(el, "title", translateTextValue(el.getAttribute("title")));
}
if (el.hasAttribute("data-i18n-placeholder")) {
setAttrIfChanged(el, "placeholder", t(el.getAttribute("data-i18n-placeholder")));
} else if (el.hasAttribute("placeholder")) {
setAttrIfChanged(el, "placeholder", translateTextValue(el.getAttribute("placeholder")));
}
if (el.hasAttribute("data-i18n-aria-label")) {
setAttrIfChanged(el, "aria-label", t(el.getAttribute("data-i18n-aria-label")));
} else if (el.hasAttribute("aria-label")) {
setAttrIfChanged(el, "aria-label", translateTextValue(el.getAttribute("aria-label")));
}
if (el.hasAttribute("data-tooltip")) {
setAttrIfChanged(el, "data-tooltip", translateTextValue(el.getAttribute("data-tooltip")));
}
}
function translateTextNodes(root) {
if (!root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
let node;
while ((node = walker.nextNode())) {
nodes.push(node);
}
for (const textNode of nodes) {
const parent = textNode.parentElement;
if (!parent) continue;
if (SKIP_TAGS.has(parent.tagName)) continue;
if (parent.hasAttribute("data-i18n")) continue;
const translated = translateTextValue(textNode.nodeValue || "");
if (translated !== textNode.nodeValue) {
textNode.nodeValue = translated;
}
}
}
function translateElement(el) {
const textKey = el.getAttribute("data-i18n");
if (textKey) {
const translatedText = t(textKey);
if (el.textContent !== translatedText) {
el.textContent = translatedText;
}
}
const titleKey = el.getAttribute("data-i18n-title");
if (titleKey) {
setAttrIfChanged(el, "title", t(titleKey));
}
const placeholderKey = el.getAttribute("data-i18n-placeholder");
if (placeholderKey) {
setAttrIfChanged(el, "placeholder", t(placeholderKey));
}
const ariaLabelKey = el.getAttribute("data-i18n-aria-label");
if (ariaLabelKey) {
setAttrIfChanged(el, "aria-label", t(ariaLabelKey));
}
translateLooseAttributes(el);
}
function translateSubtree(root = document) {
if (!root) return;
isApplyingTranslations = true;
if (root.nodeType === Node.ELEMENT_NODE) {
translateElement(root);
}
root
.querySelectorAll(
"[data-i18n], [data-i18n-title], [data-i18n-placeholder], [data-i18n-aria-label]",
)
.forEach(translateElement);
const elementRoot =
root.nodeType === Node.DOCUMENT_NODE ? root.body || root.documentElement : root;
if (elementRoot) {
translateTextNodes(elementRoot);
if (elementRoot.querySelectorAll) {
elementRoot
.querySelectorAll("[title], [placeholder], [aria-label], [data-tooltip]")
.forEach(translateLooseAttributes);
}
}
isApplyingTranslations = false;
}
function updateDocumentLang() {
document.documentElement.lang = currentLocale;
}
function renderLanguageSwitcher() {
const header = document.querySelector("header");
if (!header) return;
let container = document.getElementById("lang-switcher");
let select = document.getElementById("lang-select");
if (!container) {
container = document.createElement("div");
container.id = "lang-switcher";
container.style.display = "inline-flex";
container.style.alignItems = "center";
container.style.gap = "6px";
container.style.marginLeft = "12px";
container.style.flexShrink = "0";
const label = document.createElement("span");
label.style.fontSize = "0.75rem";
label.style.opacity = "0.8";
label.textContent = "🌐";
container.appendChild(label);
select = document.createElement("select");
select.id = "lang-select";
select.style.fontSize = "0.8rem";
select.style.padding = "3px 8px";
select.style.background = "var(--card-bg, #161b22)";
select.style.color = "var(--text, #c9d1d9)";
select.style.border = "1px solid var(--border, #30363d)";
select.style.borderRadius = "4px";
select.style.cursor = "pointer";
select.innerHTML = `
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
`;
container.appendChild(select);
const targetHost = header.querySelector(".header-left") || header;
targetHost.appendChild(container);
}
select = document.getElementById("lang-select");
if (select && !select.dataset.i18nBound) {
select.addEventListener("change", (e) => {
setLocale(e.target.value, { persist: true });
});
select.dataset.i18nBound = "1";
}
if (select) {
if (!select.options.length) {
select.innerHTML = `
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
`;
}
select.value = currentLocale;
}
}
function installObserver() {
if (observer) observer.disconnect();
if (!document.body) return;
observer = new MutationObserver((mutations) => {
if (isApplyingTranslations) return;
isApplyingTranslations = true;
try {
for (const mutation of mutations) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((addedNode) => {
if (addedNode.nodeType === Node.TEXT_NODE) {
const parent = addedNode.parentElement;
if (parent && !SKIP_TAGS.has(parent.tagName)) {
const translated = translateTextValue(addedNode.nodeValue || "");
if (translated !== addedNode.nodeValue) addedNode.nodeValue = translated;
}
return;
}
if (addedNode.nodeType === Node.ELEMENT_NODE) {
translateSubtree(addedNode);
}
});
}
if (mutation.type === "characterData" && mutation.target?.nodeType === Node.TEXT_NODE) {
const textNode = mutation.target;
const parent = textNode.parentElement;
if (parent && !SKIP_TAGS.has(parent.tagName)) {
const translated = translateTextValue(textNode.nodeValue || "");
if (translated !== textNode.nodeValue) textNode.nodeValue = translated;
}
}
}
} finally {
isApplyingTranslations = false;
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: false,
});
}
async function setLocale(locale, { persist = true } = {}) {
const normalized = normalizeLocale(locale);
const targetLocale = SUPPORTED_LOCALES.includes(normalized) ? normalized : DEFAULT_LOCALE;
let localeMessages;
try {
localeMessages = await loadLocaleMessages(targetLocale);
} catch (error) {
console.error("[i18n] Failed to load locale, fallback to English:", error);
localeMessages = await loadLocaleMessages(DEFAULT_LOCALE);
}
currentLocale = targetLocale;
activeMessages = localeMessages;
if (persist) {
localStorage.setItem(STORAGE_KEY, currentLocale);
}
updateDocumentLang();
translateSubtree(document);
renderLanguageSwitcher();
installObserver();
window.dispatchEvent(new CustomEvent("i18n:updated", { detail: { locale: currentLocale } }));
}
async function init() {
await loadLocaleMessages(DEFAULT_LOCALE);
await loadLocaleMessages("zh-CN").catch(() => null);
const initialLocale = getInitialLocale();
await setLocale(initialLocale, { persist: false });
}
window.I18N = {
init,
t,
setLocale,
getLocale: () => currentLocale,
translateSubtree,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();

507
public/js/lib/morphdom.min.js vendored Normal file
View File

@@ -0,0 +1,507 @@
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: ((global = global || self), (global.morphdom = factory()));
})(this, function () {
"use strict";
var DOCUMENT_FRAGMENT_NODE = 11;
function morphAttrs(fromNode, toNode) {
var toNodeAttrs = toNode.attributes;
var attr;
var attrName;
var attrNamespaceURI;
var attrValue;
var fromValue;
if (
toNode.nodeType === DOCUMENT_FRAGMENT_NODE ||
fromNode.nodeType === DOCUMENT_FRAGMENT_NODE
) {
return;
}
for (var i = toNodeAttrs.length - 1; i >= 0; i--) {
attr = toNodeAttrs[i];
attrName = attr.name;
attrNamespaceURI = attr.namespaceURI;
attrValue = attr.value;
if (attrNamespaceURI) {
attrName = attr.localName || attrName;
fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName);
if (fromValue !== attrValue) {
if (attr.prefix === "xmlns") {
attrName = attr.name;
}
fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue);
}
} else {
fromValue = fromNode.getAttribute(attrName);
if (fromValue !== attrValue) {
fromNode.setAttribute(attrName, attrValue);
}
}
}
var fromNodeAttrs = fromNode.attributes;
for (var d = fromNodeAttrs.length - 1; d >= 0; d--) {
attr = fromNodeAttrs[d];
attrName = attr.name;
attrNamespaceURI = attr.namespaceURI;
if (attrNamespaceURI) {
attrName = attr.localName || attrName;
if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) {
fromNode.removeAttributeNS(attrNamespaceURI, attrName);
}
} else {
if (!toNode.hasAttribute(attrName)) {
fromNode.removeAttribute(attrName);
}
}
}
}
var range;
var NS_XHTML = "http://www.w3.org/1999/xhtml";
var doc = typeof document === "undefined" ? undefined : document;
var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template");
var HAS_RANGE_SUPPORT =
!!doc && doc.createRange && "createContextualFragment" in doc.createRange();
function createFragmentFromTemplate(str) {
var template = doc.createElement("template");
template.innerHTML = str;
return template.content.childNodes[0];
}
function createFragmentFromRange(str) {
if (!range) {
range = doc.createRange();
range.selectNode(doc.body);
}
var fragment = range.createContextualFragment(str);
return fragment.childNodes[0];
}
function createFragmentFromWrap(str) {
var fragment = doc.createElement("body");
fragment.innerHTML = str;
return fragment.childNodes[0];
}
function toElement(str) {
str = str.trim();
if (HAS_TEMPLATE_SUPPORT) {
return createFragmentFromTemplate(str);
} else if (HAS_RANGE_SUPPORT) {
return createFragmentFromRange(str);
}
return createFragmentFromWrap(str);
}
function compareNodeNames(fromEl, toEl) {
var fromNodeName = fromEl.nodeName;
var toNodeName = toEl.nodeName;
var fromCodeStart, toCodeStart;
if (fromNodeName === toNodeName) {
return true;
}
fromCodeStart = fromNodeName.charCodeAt(0);
toCodeStart = toNodeName.charCodeAt(0);
if (fromCodeStart <= 90 && toCodeStart >= 97) {
return fromNodeName === toNodeName.toUpperCase();
} else if (toCodeStart <= 90 && fromCodeStart >= 97) {
return toNodeName === fromNodeName.toUpperCase();
} else {
return false;
}
}
function createElementNS(name, namespaceURI) {
return !namespaceURI || namespaceURI === NS_XHTML
? doc.createElement(name)
: doc.createElementNS(namespaceURI, name);
}
function moveChildren(fromEl, toEl) {
var curChild = fromEl.firstChild;
while (curChild) {
var nextChild = curChild.nextSibling;
toEl.appendChild(curChild);
curChild = nextChild;
}
return toEl;
}
function syncBooleanAttrProp(fromEl, toEl, name) {
if (fromEl[name] !== toEl[name]) {
fromEl[name] = toEl[name];
if (fromEl[name]) {
fromEl.setAttribute(name, "");
} else {
fromEl.removeAttribute(name);
}
}
}
var specialElHandlers = {
OPTION: function (fromEl, toEl) {
var parentNode = fromEl.parentNode;
if (parentNode) {
var parentName = parentNode.nodeName.toUpperCase();
if (parentName === "OPTGROUP") {
parentNode = parentNode.parentNode;
parentName = parentNode && parentNode.nodeName.toUpperCase();
}
if (parentName === "SELECT" && !parentNode.hasAttribute("multiple")) {
if (fromEl.hasAttribute("selected") && !toEl.selected) {
fromEl.setAttribute("selected", "selected");
fromEl.removeAttribute("selected");
}
parentNode.selectedIndex = -1;
}
}
syncBooleanAttrProp(fromEl, toEl, "selected");
},
INPUT: function (fromEl, toEl) {
syncBooleanAttrProp(fromEl, toEl, "checked");
syncBooleanAttrProp(fromEl, toEl, "disabled");
if (fromEl.value !== toEl.value) {
fromEl.value = toEl.value;
}
if (!toEl.hasAttribute("value")) {
fromEl.removeAttribute("value");
}
},
TEXTAREA: function (fromEl, toEl) {
var newValue = toEl.value;
if (fromEl.value !== newValue) {
fromEl.value = newValue;
}
var firstChild = fromEl.firstChild;
if (firstChild) {
var oldValue = firstChild.nodeValue;
if (oldValue == newValue || (!newValue && oldValue == fromEl.placeholder)) {
return;
}
firstChild.nodeValue = newValue;
}
},
SELECT: function (fromEl, toEl) {
if (!toEl.hasAttribute("multiple")) {
var selectedIndex = -1;
var i = 0;
var curChild = fromEl.firstChild;
var optgroup;
var nodeName;
while (curChild) {
nodeName = curChild.nodeName && curChild.nodeName.toUpperCase();
if (nodeName === "OPTGROUP") {
optgroup = curChild;
curChild = optgroup.firstChild;
} else {
if (nodeName === "OPTION") {
if (curChild.hasAttribute("selected")) {
selectedIndex = i;
break;
}
i++;
}
curChild = curChild.nextSibling;
if (!curChild && optgroup) {
curChild = optgroup.nextSibling;
optgroup = null;
}
}
}
fromEl.selectedIndex = selectedIndex;
}
},
};
var ELEMENT_NODE = 1;
var DOCUMENT_FRAGMENT_NODE$1 = 11;
var TEXT_NODE = 3;
var COMMENT_NODE = 8;
function noop() {}
function defaultGetNodeKey(node) {
if (node) {
return (node.getAttribute && node.getAttribute("id")) || node.id;
}
}
function morphdomFactory(morphAttrs) {
return function morphdom(fromNode, toNode, options) {
if (!options) {
options = {};
}
if (typeof toNode === "string") {
if (
fromNode.nodeName === "#document" ||
fromNode.nodeName === "HTML" ||
fromNode.nodeName === "BODY"
) {
var toNodeHtml = toNode;
toNode = doc.createElement("html");
toNode.innerHTML = toNodeHtml;
} else {
toNode = toElement(toNode);
}
} else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
toNode = toNode.firstElementChild;
}
var getNodeKey = options.getNodeKey || defaultGetNodeKey;
var onBeforeNodeAdded = options.onBeforeNodeAdded || noop;
var onNodeAdded = options.onNodeAdded || noop;
var onBeforeElUpdated = options.onBeforeElUpdated || noop;
var onElUpdated = options.onElUpdated || noop;
var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop;
var onNodeDiscarded = options.onNodeDiscarded || noop;
var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop;
var skipFromChildren = options.skipFromChildren || noop;
var addChild =
options.addChild ||
function (parent, child) {
return parent.appendChild(child);
};
var childrenOnly = options.childrenOnly === true;
var fromNodesLookup = Object.create(null);
var keyedRemovalList = [];
function addKeyedRemoval(key) {
keyedRemovalList.push(key);
}
function walkDiscardedChildNodes(node, skipKeyedNodes) {
if (node.nodeType === ELEMENT_NODE) {
var curChild = node.firstChild;
while (curChild) {
var key = undefined;
if (skipKeyedNodes && (key = getNodeKey(curChild))) {
addKeyedRemoval(key);
} else {
onNodeDiscarded(curChild);
if (curChild.firstChild) {
walkDiscardedChildNodes(curChild, skipKeyedNodes);
}
}
curChild = curChild.nextSibling;
}
}
}
function removeNode(node, parentNode, skipKeyedNodes) {
if (onBeforeNodeDiscarded(node) === false) {
return;
}
if (parentNode) {
parentNode.removeChild(node);
}
onNodeDiscarded(node);
walkDiscardedChildNodes(node, skipKeyedNodes);
}
function indexTree(node) {
if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
var curChild = node.firstChild;
while (curChild) {
var key = getNodeKey(curChild);
if (key) {
fromNodesLookup[key] = curChild;
}
indexTree(curChild);
curChild = curChild.nextSibling;
}
}
}
indexTree(fromNode);
function handleNodeAdded(el) {
onNodeAdded(el);
var curChild = el.firstChild;
while (curChild) {
var nextSibling = curChild.nextSibling;
var key = getNodeKey(curChild);
if (key) {
var unmatchedFromEl = fromNodesLookup[key];
if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) {
curChild.parentNode.replaceChild(unmatchedFromEl, curChild);
morphEl(unmatchedFromEl, curChild);
} else {
handleNodeAdded(curChild);
}
} else {
handleNodeAdded(curChild);
}
curChild = nextSibling;
}
}
function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) {
while (curFromNodeChild) {
var fromNextSibling = curFromNodeChild.nextSibling;
if ((curFromNodeKey = getNodeKey(curFromNodeChild))) {
addKeyedRemoval(curFromNodeKey);
} else {
removeNode(curFromNodeChild, fromEl, true);
}
curFromNodeChild = fromNextSibling;
}
}
function morphEl(fromEl, toEl, childrenOnly) {
var toElKey = getNodeKey(toEl);
if (toElKey) {
delete fromNodesLookup[toElKey];
}
if (!childrenOnly) {
if (onBeforeElUpdated(fromEl, toEl) === false) {
return;
}
morphAttrs(fromEl, toEl);
onElUpdated(fromEl);
if (onBeforeElChildrenUpdated(fromEl, toEl) === false) {
return;
}
}
if (fromEl.nodeName !== "TEXTAREA") {
morphChildren(fromEl, toEl);
} else {
specialElHandlers.TEXTAREA(fromEl, toEl);
}
}
function morphChildren(fromEl, toEl) {
var skipFrom = skipFromChildren(fromEl, toEl);
var curToNodeChild = toEl.firstChild;
var curFromNodeChild = fromEl.firstChild;
var curToNodeKey;
var curFromNodeKey;
var fromNextSibling;
var toNextSibling;
var matchingFromEl;
outer: while (curToNodeChild) {
toNextSibling = curToNodeChild.nextSibling;
curToNodeKey = getNodeKey(curToNodeChild);
while (!skipFrom && curFromNodeChild) {
fromNextSibling = curFromNodeChild.nextSibling;
if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) {
curToNodeChild = toNextSibling;
curFromNodeChild = fromNextSibling;
continue outer;
}
curFromNodeKey = getNodeKey(curFromNodeChild);
var curFromNodeType = curFromNodeChild.nodeType;
var isCompatible = undefined;
if (curFromNodeType === curToNodeChild.nodeType) {
if (curFromNodeType === ELEMENT_NODE) {
if (curToNodeKey) {
if (curToNodeKey !== curFromNodeKey) {
if ((matchingFromEl = fromNodesLookup[curToNodeKey])) {
if (fromNextSibling === matchingFromEl) {
isCompatible = false;
} else {
fromEl.insertBefore(matchingFromEl, curFromNodeChild);
if (curFromNodeKey) {
addKeyedRemoval(curFromNodeKey);
} else {
removeNode(curFromNodeChild, fromEl, true);
}
curFromNodeChild = matchingFromEl;
curFromNodeKey = getNodeKey(curFromNodeChild);
}
} else {
isCompatible = false;
}
}
} else if (curFromNodeKey) {
isCompatible = false;
}
isCompatible =
isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild);
if (isCompatible) {
morphEl(curFromNodeChild, curToNodeChild);
}
} else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) {
isCompatible = true;
if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) {
curFromNodeChild.nodeValue = curToNodeChild.nodeValue;
}
}
}
if (isCompatible) {
curToNodeChild = toNextSibling;
curFromNodeChild = fromNextSibling;
continue outer;
}
if (curFromNodeKey) {
addKeyedRemoval(curFromNodeKey);
} else {
removeNode(curFromNodeChild, fromEl, true);
}
curFromNodeChild = fromNextSibling;
}
if (
curToNodeKey &&
(matchingFromEl = fromNodesLookup[curToNodeKey]) &&
compareNodeNames(matchingFromEl, curToNodeChild)
) {
if (!skipFrom) {
addChild(fromEl, matchingFromEl);
}
morphEl(matchingFromEl, curToNodeChild);
} else {
var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild);
if (onBeforeNodeAddedResult !== false) {
if (onBeforeNodeAddedResult) {
curToNodeChild = onBeforeNodeAddedResult;
}
if (curToNodeChild.actualize) {
curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc);
}
addChild(fromEl, curToNodeChild);
handleNodeAdded(curToNodeChild);
}
}
curToNodeChild = toNextSibling;
curFromNodeChild = fromNextSibling;
}
cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey);
var specialElHandler = specialElHandlers[fromEl.nodeName];
if (specialElHandler) {
specialElHandler(fromEl, toEl);
}
}
var morphedNode = fromNode;
var morphedNodeType = morphedNode.nodeType;
var toNodeType = toNode.nodeType;
if (!childrenOnly) {
if (morphedNodeType === ELEMENT_NODE) {
if (toNodeType === ELEMENT_NODE) {
if (!compareNodeNames(fromNode, toNode)) {
onNodeDiscarded(fromNode);
morphedNode = moveChildren(
fromNode,
createElementNS(toNode.nodeName, toNode.namespaceURI),
);
}
} else {
morphedNode = toNode;
}
} else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) {
if (toNodeType === morphedNodeType) {
if (morphedNode.nodeValue !== toNode.nodeValue) {
morphedNode.nodeValue = toNode.nodeValue;
}
return morphedNode;
} else {
morphedNode = toNode;
}
}
}
if (morphedNode === toNode) {
onNodeDiscarded(fromNode);
} else {
if (toNode.isSameNode && toNode.isSameNode(morphedNode)) {
return;
}
morphEl(morphedNode, toNode, childrenOnly);
if (keyedRemovalList) {
for (var i = 0, len = keyedRemovalList.length; i < len; i++) {
var elToRemove = fromNodesLookup[keyedRemovalList[i]];
if (elToRemove) {
removeNode(elToRemove, elToRemove.parentNode, false);
}
}
}
}
if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) {
if (morphedNode.actualize) {
morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc);
}
fromNode.parentNode.replaceChild(morphedNode, fromNode);
}
return morphedNode;
};
}
var morphdom = morphdomFactory(morphAttrs);
return morphdom;
});

357
public/js/sidebar.js Normal file
View File

@@ -0,0 +1,357 @@
/**
* Shared Sidebar Loader
*
* Loads the sidebar partial and connects to SSE for live badge updates.
* Include this script in any page that needs the sidebar.
*/
(function () {
"use strict";
// State for sidebar badges
const sidebarState = {
sessions: 0,
cron: 0,
jobs: 0,
memory: 0,
cerebro: 0,
operators: 0,
tokens: "-",
cost: "-",
monthlyCost: "-",
avgTokens: "-",
avgCost: "-",
lastUpdated: null,
};
// SSE connection
let eventSource = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000;
/**
* Load and inject sidebar HTML
*/
async function loadSidebar() {
try {
const response = await fetch("/partials/sidebar.html");
if (!response.ok) throw new Error("Failed to load sidebar");
const html = await response.text();
// Find or create sidebar container
let container = document.getElementById("sidebar-container");
if (!container) {
// Insert at start of body
container = document.createElement("div");
container.id = "sidebar-container";
document.body.insertBefore(container, document.body.firstChild);
}
container.innerHTML = html;
if (window.I18N?.translateSubtree) {
window.I18N.translateSubtree(container);
}
// Set active state based on current page
setActiveNavItem();
// Connect to SSE for live updates
connectSSE();
// Also fetch initial state
fetchSidebarState();
} catch (error) {
console.error("[Sidebar] Failed to load:", error);
}
}
/**
* Check if we're on the main page
*/
function isMainPage() {
const path = window.location.pathname;
return path === "/" || path === "/index.html";
}
/**
* Set the active nav item based on current URL
*/
function setActiveNavItem() {
const currentPath = window.location.pathname;
const currentHash = window.location.hash;
document.querySelectorAll(".nav-item").forEach((item) => {
item.classList.remove("active");
const itemPage = item.dataset.page;
const itemHref = item.getAttribute("href");
// Check if this nav item matches the current page
if (itemPage === "/" && isMainPage()) {
// For main page sections
if (currentHash && itemHref && itemHref === currentHash) {
item.classList.add("active");
} else if (!currentHash && item.dataset.section === "vitals") {
// Default to vitals on main page with no hash
item.classList.add("active");
}
} else if (itemHref === currentPath) {
// Exact page match (like /jobs.html)
item.classList.add("active");
}
});
}
/**
* Set up navigation click handlers
* - Hash links on main page: smooth scroll
* - Hash links on other pages: navigate to main page with hash
*/
function setupNavigation() {
document.querySelectorAll(".nav-item[data-section]").forEach((item) => {
item.addEventListener("click", (e) => {
const section = item.dataset.section;
const targetHash = `#${section}-section`;
if (isMainPage()) {
// On main page: smooth scroll to section
e.preventDefault();
const target = document.querySelector(targetHash);
if (target) {
target.scrollIntoView({ behavior: "smooth" });
history.pushState(null, "", targetHash);
setActiveNavItem();
}
} else {
// On other page: navigate to main page with hash
e.preventDefault();
window.location.href = "/" + targetHash;
}
});
});
}
/**
* Connect to SSE for live updates
*/
function connectSSE() {
if (typeof EventSource === "undefined") {
console.warn("[Sidebar SSE] Not supported");
return;
}
eventSource = new EventSource("/api/events");
eventSource.onopen = () => {
console.log("[Sidebar SSE] Connected");
reconnectAttempts = 0;
};
eventSource.addEventListener("update", (e) => {
try {
const data = JSON.parse(e.data);
handleStateUpdate(data);
} catch (err) {
console.error("[Sidebar SSE] Parse error:", err);
}
});
eventSource.addEventListener("heartbeat", () => {
sidebarState.lastUpdated = new Date();
updateTimestamp();
});
eventSource.onerror = () => {
console.error("[Sidebar SSE] Connection error");
eventSource.close();
// Exponential backoff reconnect
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
setTimeout(connectSSE, delay);
};
}
/**
* Fetch initial sidebar state
*/
async function fetchSidebarState() {
try {
const response = await fetch("/api/state");
const data = await response.json();
handleStateUpdate(data);
} catch (error) {
console.error("[Sidebar] Failed to fetch state:", error);
}
}
/**
* Handle state updates and update badges
*/
function handleStateUpdate(data) {
// Update session count
if (data.sessions) {
sidebarState.sessions = data.sessions.length || 0;
}
if (data.statusCounts) {
sidebarState.sessions = data.statusCounts.all || 0;
}
// Update cron count
if (data.cron) {
sidebarState.cron = Array.isArray(data.cron) ? data.cron.length : 0;
}
// Update jobs count (from jobs API if available)
if (data.jobs) {
sidebarState.jobs = Array.isArray(data.jobs) ? data.jobs.length : data.jobs.total || 0;
}
// Update memory count
if (data.memory) {
sidebarState.memory = data.memory.fileCount || data.memory.totalFiles || 0;
}
// Update cerebro count
if (data.cerebro) {
sidebarState.cerebro = data.cerebro.topicCount || data.cerebro.totalTopics || 0;
}
// Update operators count
if (data.operators) {
sidebarState.operators = Array.isArray(data.operators.operators)
? data.operators.operators.length
: 0;
}
// Update token stats
if (data.tokenStats) {
sidebarState.tokens = data.tokenStats.totalFormatted || data.tokenStats.total || "-";
sidebarState.cost = data.tokenStats.estCostFormatted || data.tokenStats.estCost || "-";
sidebarState.monthlyCost =
data.tokenStats.estMonthlyCostFormatted || data.tokenStats.estMonthlyCost || "-";
sidebarState.avgTokens = data.tokenStats.avgTokensPerSession || "-";
sidebarState.avgCost = data.tokenStats.avgCostPerSession || "-";
}
sidebarState.lastUpdated = new Date();
// Update the DOM
updateBadges();
updateTimestamp();
}
/**
* Update badge elements
*/
function updateBadges() {
const updates = {
"nav-session-count": sidebarState.sessions,
"nav-cron-count": sidebarState.cron,
"nav-jobs-count": sidebarState.jobs || "-",
"nav-memory-count": sidebarState.memory,
"nav-cerebro-count": sidebarState.cerebro,
"nav-operator-count": sidebarState.operators,
"nav-tokens": sidebarState.tokens,
"nav-cost": sidebarState.cost,
"nav-monthly-cost": sidebarState.monthlyCost,
"nav-avg-tokens": sidebarState.avgTokens,
"nav-avg-cost": sidebarState.avgCost,
};
for (const [id, value] of Object.entries(updates)) {
const el = document.getElementById(id);
if (el && el.textContent !== String(value)) {
el.textContent = value;
}
}
}
/**
* Update the timestamp in sidebar footer
*/
function updateTimestamp() {
const el = document.getElementById("sidebar-updated");
if (el && sidebarState.lastUpdated) {
const timeStr = sidebarState.lastUpdated.toLocaleTimeString();
const t = window.I18N?.t;
el.textContent = t ? t("sidebar.live", { time: timeStr }) : `Live: ${timeStr}`;
}
}
/**
* Toggle sidebar collapsed state
*/
window.toggleSidebar = function () {
const sidebar = document.getElementById("sidebar");
const mainWrapper = document.getElementById("main-wrapper");
if (sidebar) {
sidebar.classList.toggle("collapsed");
}
if (mainWrapper) {
mainWrapper.classList.toggle("sidebar-collapsed");
}
// Save preference
const collapsed = sidebar?.classList.contains("collapsed");
try {
localStorage.setItem("sidebar-collapsed", collapsed ? "true" : "false");
} catch (e) {}
};
/**
* Restore sidebar collapsed state from localStorage
*/
function restoreSidebarState() {
try {
const collapsed = localStorage.getItem("sidebar-collapsed") === "true";
if (collapsed) {
const sidebar = document.getElementById("sidebar");
const mainWrapper = document.getElementById("main-wrapper");
if (sidebar) sidebar.classList.add("collapsed");
if (mainWrapper) mainWrapper.classList.add("sidebar-collapsed");
}
} catch (e) {}
}
// Fetch jobs count separately (since it's a different API)
async function fetchJobsCount() {
try {
const response = await fetch("/api/jobs");
const data = await response.json();
sidebarState.jobs = data.jobs?.length || 0;
updateBadges();
} catch (error) {
// Jobs API may not be available
}
}
// Initialize on DOM ready
function init() {
loadSidebar().then(() => {
restoreSidebarState();
setupNavigation();
fetchJobsCount();
});
// Listen for hash changes to update active state
window.addEventListener("hashchange", setActiveNavItem);
window.addEventListener("i18n:updated", () => {
const container = document.getElementById("sidebar-container");
if (container && window.I18N?.translateSubtree) {
window.I18N.translateSubtree(container);
}
updateTimestamp();
setActiveNavItem();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

87
public/js/store.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* Simple state store for Command Center
* Holds the current dashboard state and notifies subscribers of changes
*/
let state = {
vitals: null,
sessions: [],
tokenStats: null,
statusCounts: { all: 0, live: 0, recent: 0, idle: 0 },
capacity: null,
operators: { operators: [], roles: {} },
llmUsage: null,
cron: [],
memory: null,
cerebro: null,
subagents: [],
pagination: { page: 1, pageSize: 50, totalPages: 1 },
timestamp: null,
};
const subscribers = new Set();
/**
* Get the current state
* @returns {Object} Current state
*/
export function getState() {
return state;
}
/**
* Update the state with new data
* @param {Object} newState - New state data (partial or full)
*/
export function setState(newState) {
state = { ...state, ...newState, timestamp: Date.now() };
notifySubscribers();
}
/**
* Subscribe to state changes
* @param {Function} callback - Called when state changes
* @returns {Function} Unsubscribe function
*/
export function subscribe(callback) {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
function notifySubscribers() {
for (const callback of subscribers) {
try {
callback(state);
} catch (err) {
console.error("[Store] Subscriber error:", err);
}
}
}
// Filter state
export const filters = {
session: { status: "all", channel: "all", kind: "all" },
cron: { status: "all", schedule: "all" },
memory: { type: "all", age: "all" },
};
export function setFilter(section, key, value) {
if (filters[section]) {
filters[section][key] = value;
notifySubscribers();
}
}
// Pagination state
export const pagination = {
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
};
export function setPagination(newPagination) {
Object.assign(pagination, newPagination);
}

47
public/js/utils.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* Utility functions for Command Center
*/
export function formatTimeAgo(mins) {
if (mins < 1) return "now";
if (mins < 60) return `${mins}m`;
if (mins < 1440) return `${Math.round(mins / 60)}h`;
return `${Math.round(mins / 1440)}d`;
}
export function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
/**
* Smart DOM update using morphdom - only patches what changed
* @param {HTMLElement} targetEl - Element to update
* @param {string} newHtml - New HTML content
*/
export 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 });
}
}
export function formatBytes(bytes) {
if (bytes >= 1099511627776) return (bytes / 1099511627776).toFixed(1) + " TB";
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB";
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB";
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
return bytes + " B";
}

111
public/locales/en.json Normal file
View File

@@ -0,0 +1,111 @@
{
"app": {
"title": "OpenClaw Command Center",
"connecting": "Connecting...",
"connected": "🟢 Live",
"disconnected": "🔴 Disconnected",
"pollingMode": "Polling Mode",
"error": "🔴 Error"
},
"sidebar": {
"title": "Command Center",
"navigation": "Navigation",
"settings": "Settings",
"quickStats": "Quick Stats",
"systemVitals": "System Vitals",
"llmUsage": "LLM Usage",
"sessions": "Sessions",
"cronJobs": "Cron Jobs",
"aiJobs": "AI Jobs",
"memory": "Memory",
"cerebro": "Cerebro",
"operators": "Operators",
"privacy": "Privacy",
"about": "About",
"tokens": "Tokens",
"estDaily": "Est. Daily",
"estMonthly": "Est. Monthly",
"avgTokSess": "Avg Tok/Sess",
"avgCostSess": "Avg $/Sess",
"autoRefresh": "Auto-refresh: 30s",
"live": "Live: {time}",
"updated": "Updated: {time}"
},
"stats": {
"totalTokens": "Total Tokens",
"input": "Input",
"output": "Output",
"active15m": "Active (15m)",
"estCost24h": "Est. Cost (24h) 📊",
"estMonthlySavings": "Est. Monthly Savings",
"main": "Main",
"subagents": "Sub-agents"
},
"quickActions": {
"title": "⚡ Quick Actions",
"healthCheck": "🔍 Health Check",
"gatewayStatus": "🚪 Gateway Status",
"cleanStale": "🧹 Clean Stale Sessions"
},
"time": {
"now": "now",
"justNow": "Just now"
},
"actions": {
"remove": "Remove"
},
"privacy": {
"noCronHidden": "No cron jobs hidden"
},
"connection": {
"realtime": "Real-time updates via SSE ⚡",
"polling": "Polling mode (SSE disconnected)"
},
"jobs": {
"pageTitle": "AI Jobs - OpenClaw Command Center",
"dashboard": "🤖 AI Jobs Dashboard",
"refresh": "🔄 Refresh",
"totalJobs": "Total Jobs",
"active": "Active",
"paused": "Paused",
"running": "Running",
"successRate": "Success Rate",
"recentFailures": "Recent Failures",
"all": "All",
"failed": "Failed",
"loadingJobs": "Loading jobs...",
"noJobs": "No jobs found",
"runHistory": "Run History",
"loadingHistory": "Loading history...",
"status": "Status",
"started": "Started",
"duration": "Duration",
"details": "Details",
"noHistory": "No run history yet",
"statusFailing": "Failing",
"next": "Next: {value}",
"lane": "🛤️ {value}",
"runs": "Runs",
"success": "Success",
"avgTime": "Avg Time",
"lastRun": "Last run: {value}",
"neverRun": "Never run",
"run": "▶️ Run",
"resume": "▶️ Resume",
"pause": "⏸️ Pause",
"history": "📜 History",
"every": "Every {value}",
"at": "At {value}",
"toastLoadFailed": "Failed to load jobs",
"toastRunQueued": "Job \"{id}\" queued for execution",
"toastRunFailed": "Failed to run job",
"toastPaused": "Job \"{id}\" paused",
"toastPauseFailed": "Failed to pause job",
"toastResumed": "Job \"{id}\" resumed",
"toastResumeFailed": "Failed to resume job",
"historyTitle": "Run History: {name}",
"statusSuccess": "Success",
"statusFailed": "Failed",
"statusRunning": "Running"
}
}

521
public/locales/zh-CN.json Normal file
View File

@@ -0,0 +1,521 @@
{
"app": {
"title": "OpenClaw 指挥中心",
"connecting": "连接中...",
"connected": "🟢 实时",
"disconnected": "🔴 已断开",
"pollingMode": "轮询模式",
"error": "🔴 错误"
},
"sidebar": {
"title": "指挥中心",
"navigation": "导航",
"settings": "设置",
"quickStats": "快速统计",
"systemVitals": "系统状态",
"llmUsage": "LLM 用量",
"sessions": "会话",
"cronJobs": "定时任务",
"aiJobs": "AI 任务",
"memory": "记忆",
"cerebro": "Cerebro",
"operators": "Operators操作者",
"privacy": "隐私",
"about": "关于",
"tokens": "Token",
"estDaily": "日预估",
"estMonthly": "月预估",
"avgTokSess": "平均 Token/会话",
"avgCostSess": "平均 $/会话",
"autoRefresh": "自动刷新30秒",
"live": "实时:{time}",
"updated": "更新:{time}"
},
"stats": {
"totalTokens": "总 Token",
"input": "输入",
"output": "输出",
"active15m": "活跃15分钟",
"estCost24h": "预估成本24小时📊",
"estMonthlySavings": "预估月节省",
"main": "主会话",
"subagents": "子代理"
},
"quickActions": {
"title": "⚡ 快捷操作",
"healthCheck": "🔍 健康检查",
"gatewayStatus": "🚪 网关状态",
"cleanStale": "🧹 清理过期会话"
},
"time": {
"now": "刚刚",
"justNow": "刚刚"
},
"actions": {
"remove": "移除"
},
"privacy": {
"noCronHidden": "未隐藏任何定时任务"
},
"connection": {
"realtime": "通过 SSE 实时更新 ⚡",
"polling": "轮询模式SSE 已断开)"
},
"phrases": {
"exact": {
"OpenClaw Command Center": "OpenClaw 指挥中心",
"Command Center": "指挥中心",
"Connecting...": "连接中...",
"Total Tokens": "总 Token",
"Input": "输入",
"Output": "输出",
"Active (15m)": "活跃15分钟",
"Est. Cost (24h) 📊": "预估成本24小时📊",
"Est. Monthly Savings": "预估月节省",
"Main": "主会话",
"Sub-agents": "子代理",
"System Vitals": "系统状态",
"LLM Fuel Gauges": "LLM 用量仪表",
"Sessions": "会话",
"Cron Jobs": "定时任务",
"Memory": "记忆",
"Operators": "Operators操作者",
"About": "关于",
"Quick Actions": "快捷操作",
"Health Check": "健康检查",
"Gateway Status": "网关状态",
"Clean Stale Sessions": "清理过期会话",
"Session Details": "会话详情",
"Overview": "概览",
"Summary": "摘要",
"References": "引用",
"Needs Attention": "需关注",
"Key Facts": "关键信息",
"Tools Used": "使用工具",
"Recent Messages": "最近消息",
"Send Message": "发送消息",
"Refresh": "刷新",
"Clear Session": "清空会话",
"Fix Claude Authentication": "修复 Claude 鉴权",
"Step 1: Refresh Claude Token": "步骤 1刷新 Claude Token",
"Step 2: Update OpenClaw Agent": "步骤 2更新 OpenClaw 代理",
"Step 3: Verify": "步骤 3验证",
"Cost Breakdown (24h)": "成本明细24小时",
"Token Usage": "Token 用量",
"Pricing Rates (Claude Opus)": "价格费率Claude Opus",
"Calculation": "计算方式",
"Est. Savings": "预估节省",
"Top Sessions by Tokens (24h)": "按 Token 排名前会话24小时",
"User Stats": "用户统计",
"Loading user stats...": "正在加载用户统计...",
"Privacy Settings": "隐私设置",
"Hidden Topics": "隐藏话题",
"No topics hidden": "未隐藏任何话题",
"Hidden Sessions": "隐藏会话",
"No sessions hidden": "未隐藏任何会话",
"Hidden Cron Jobs": "隐藏定时任务",
"No cron jobs hidden": "未隐藏任何定时任务",
"Display Options": "显示选项",
"Hide system hostname": "隐藏系统主机名",
"Clear All Privacy Settings": "清除全部隐私设置",
"Status": "状态",
"Channel": "渠道",
"Kind": "类型",
"All": "全部",
"Live": "在线",
"Recent": "最近",
"Idle": "空闲",
"Slack": "Slack",
"Telegram": "Telegram",
"Discord": "Discord",
"Signal": "Signal",
"WhatsApp": "WhatsApp",
"Subagent": "子代理Subagent",
"Enabled": "启用",
"Disabled": "禁用",
"Schedule": "计划",
"Frequent (<1h)": "高频(<1小时",
"Daily": "每日",
"Weekly": "每周",
"Type": "类型",
"Today": "今天",
"This Week": "本周",
"Older": "更早",
"Long-term curated memories": "长期整理后的记忆",
"Raw logs by date": "按日期保存的原始日志",
"Recent Memory Files": "最近记忆文件",
"Loading...": "加载中...",
"Cerebro Not Initialized": "Cerebro 未初始化",
"Topics by Status": "话题状态统计",
"Threads": "线程",
"Recent Active Topics": "最近活跃话题",
"No active topics yet": "暂无活跃话题",
"Open cerebro/": "打开 cerebro/ 目录",
"Loading operators...": "正在加载 Operators操作者...",
"Permission Levels": "权限级别",
"Owner": "所有者",
"Full control": "完全控制",
"Admin": "管理员",
"Manage users & settings": "管理用户和设置",
"User": "用户",
"Dashboard access": "看板访问权限",
"A Starcraft-inspired dashboard for OpenClaw orchestration": "受星际争霸启发的 OpenClaw 编排看板",
"BUILT BY": "开发者",
"INSPIRED BY": "灵感来源",
"No active sub-agents": "暂无活跃子代理",
"Session Usage": "会话用量",
"Weekly Usage": "周用量",
"5-Hour Usage": "5 小时用量",
"Daily Usage": "日用量",
"Tasks Today": "今日任务",
"Session Limit": "会话额度",
"1m avg": "1分钟均值",
"5m avg": "5分钟均值",
"15m avg": "15分钟均值",
"used of total": "已用 / 总量",
"IOPS": "IOPS",
"MB/s": "MB/秒",
"KB/t": "KB/次",
"tok/min": "Token/分钟",
"v...": "v...",
"MIT License": "MIT 许可证",
"used": "已用",
"available": "可用",
"user": "用户",
"sys": "系统",
"idle": "空闲",
"cores": "核心",
"App": "应用",
"Wired": "常驻内存Wired",
"Compressed": "压缩内存",
"Cached": "缓存",
"Normal": "正常",
"Checking...": "检测中...",
"API Usage": "API 用量",
"ChatGPT Plus": "ChatGPT Plus",
"Status:": "状态:",
"Channel:": "渠道:",
"Kind:": "类型:",
"Schedule:": "计划:",
"Type:": "类型:",
"Age:": "时间:",
"size": "大小",
"lines": "行数",
"files": "文件",
"total": "总计",
"active": "活跃",
"resolved": "已解决",
"parked": "暂挂",
"tracked": "已跟踪",
"orphans": "孤立话题",
"No active sessions": "暂无活跃会话",
"No scheduled jobs": "暂无定时任务",
"No operators configured": "未配置 Operators操作者",
"Last Seen": "最后在线",
"Auth Error - Click to Fix": "鉴权错误 - 点击修复",
"N/A": "不可用",
"No memory files yet": "暂无记忆文件",
"Failed to load cost data": "加载成本数据失败",
"Operator not found": "未找到 Operator操作者",
"Failed to load user data": "加载用户数据失败",
"Active Sessions": "活跃会话",
"Total Sessions": "会话总数",
"First Seen": "首次出现",
"No sessions found": "未找到会话",
"Input Tokens": "输入 Token",
"Output Tokens": "输出 Token",
"Cache Read": "缓存读取",
"Cache Write": "缓存写入",
"API Requests": "API 请求数",
"Input Cost": "输入成本",
"Output Cost": "输出成本",
"Cache Read Cost": "缓存读取成本",
"Cache Write Cost": "缓存写入成本",
"Est. API Cost": "预估 API 成本",
"Projected Monthly Cost by Window:": "按时间窗口预测月成本:",
"No session data available": "暂无会话数据",
"Est. Cost": "预估成本",
"Cache (R/W)": "缓存(读/写)",
"Channel": "渠道",
"Model": "模型",
"Input / Output": "输入 / 输出",
"Last Active": "最后活跃",
"No summary": "暂无摘要",
"No references detected": "未检测到引用",
"Nothing needs attention": "暂无需关注项",
"No key facts": "暂无关键信息",
"No tools used": "未使用工具",
"No messages": "暂无消息",
"Navigation": "导航",
"Settings": "设置",
"Quick Stats": "快速统计",
"LLM Fuel Gauges": "LLM 用量仪表",
"System Vitals": "系统状态",
"LLM Usage": "LLM 用量",
"Privacy": "隐私",
"Total tokens (24h)": "总 Token24小时",
"Click for breakdown": "点击查看明细",
"Projected monthly cost": "预测月成本",
"Average tokens per session": "每会话平均 Token",
"Average cost per session": "每会话平均成本",
"SSE connection status": "SSE 连接状态",
"Sessions active within last 15 minutes": "最近 15 分钟内活跃会话",
"Click for cost breakdown": "点击查看成本明细",
"Main session capacity": "主会话容量",
"Sub-agent capacity": "子代理容量",
"Memory actively used by apps": "应用正在使用的内存",
"Memory that can't be swapped (kernel, drivers)": "不可交换内存(内核、驱动)",
"Memory compressed to save space": "为节省空间而压缩的内存",
"Recently-used data, can be reclaimed": "近期使用数据,可回收",
"Average routing latency (classification + execution)": "平均路由延迟(分类 + 执行)",
"AI Jobs Dashboard": "AI 任务面板",
"Privacy Settings": "隐私设置",
"No topics hidden": "未隐藏任何话题",
"No sessions hidden": "未隐藏任何会话",
"Hide topics and sessions from display for privacy during demos/screenshots. Settings are stored in your browser only.": "为保护演示/截图隐私,可隐藏话题和会话。设置仅保存在你的浏览器中。",
"Tip: You can also click the 👁️ icon on any session card to hide it quickly.": "提示:你也可以点击任意会话卡片上的 👁️ 图标快速隐藏。",
"Open Terminal and run:": "打开终端并运行:",
"Follow the prompts to authenticate with your Claude account.": "按提示完成 Claude 账号认证。",
"Run the onboard wizard to update your agent credentials:": "运行引导向导更新代理凭据:",
"Or manually update the main agent:": "或手动更新主代理:",
"Select \"Claude Code CLI\" when prompted for the auth source.": "当提示选择认证来源时,选择 \"Claude Code CLI\"。",
"Refresh this dashboard or run:": "刷新此看板,或运行:",
"You should see your usage percentages instead of an auth error.": "你应该看到用量百分比,而不是鉴权错误。",
"Memory editing UI coming soon (Inside Out style!)": "记忆编辑 UI 即将上线Inside Out 风格!)",
"Cerebro tracks conversation topics and threads across sessions.": "Cerebro 用于跨会话追踪对话话题与线程。",
"To initialize Cerebro:": "初始化 Cerebro",
"Threads linked to topics": "关联到话题的线程",
"No active sub-agents": "暂无活跃子代理",
"No active topics yet": "暂无活跃话题",
"No memory files yet": "暂无记忆文件",
"No references detected": "未检测到引用",
"Nothing needs attention": "暂无需关注项",
"No key facts": "暂无关键信息",
"No tools used": "未使用工具",
"No messages": "暂无消息",
"No summary": "暂无摘要",
"No session data available": "暂无会话数据",
"No sessions found": "未找到会话",
"Loading user stats...": "正在加载用户统计...",
"Loading operators...": "正在加载 Operators操作者...",
"Loading...": "加载中...",
"Add": "添加",
"Remove": "移除",
"Prev": "上一页",
"Next": "下一页",
"Last updated:": "最后更新:",
"Built by": "开发者",
"remaining": "剩余",
"resets in": "后重置",
"resets": "重置",
"Avg latency": "平均延迟"
},
"patterns": [
{
"pattern": "^Live:\\s*(.+)$",
"replace": "实时:$1"
},
{
"pattern": "^Updated:\\s*(.+)$",
"replace": "更新:$1"
},
{
"pattern": "^Uptime:\\s*(.+)$",
"replace": "运行时长:$1"
},
{
"pattern": "^Routing:\\s*(.+)$",
"replace": "路由:$1"
},
{
"pattern": "^Last updated:\\s*(.*)$",
"replace": "最后更新:$1"
},
{
"pattern": "^Last run:\\s*(.+)$",
"replace": "上次运行:$1"
},
{
"pattern": "^Run History:\\s*(.+)$",
"replace": "运行历史:$1"
},
{
"pattern": "^Operator not found:\\s*(.+)$",
"replace": "未找到 Operator操作者$1"
},
{
"pattern": "^Error:\\s*(.+)$",
"replace": "错误:$1"
},
{
"pattern": "^via\\s+(.+)$",
"replace": "来源:$1"
},
{
"pattern": "^Avg latency:\\s*(.+)$",
"replace": "平均延迟:$1"
},
{
"pattern": "^(\\d+) total topics$",
"replace": "$1 个话题"
},
{
"pattern": "^(\\d+) tracked$",
"replace": "$1 个已跟踪"
},
{
"pattern": "^(\\d+) orphans$",
"replace": "$1 个孤立"
},
{
"pattern": "^ID:\\s*(.+) • (\\d+) tokens • (.+)$",
"replace": "ID$1 • $2 Token • $3"
},
{
"pattern": "^Auto-refresh:\\s*30s$",
"replace": "自动刷新30秒"
},
{
"pattern": "^Polling Mode$",
"replace": "轮询模式"
},
{
"pattern": "^Real-time updates via SSE$",
"replace": "通过 SSE 实时更新"
},
{
"pattern": "^Just now$",
"replace": "刚刚"
},
{
"pattern": "^now$",
"replace": "刚刚"
}
],
"reversePatterns": [
{
"pattern": "^实时:\\s*(.+)$",
"replace": "Live: $1"
},
{
"pattern": "^更新:\\s*(.+)$",
"replace": "Updated: $1"
},
{
"pattern": "^运行时长:\\s*(.+)$",
"replace": "Uptime: $1"
},
{
"pattern": "^路由:\\s*(.+)$",
"replace": "Routing: $1"
},
{
"pattern": "^最后更新:\\s*(.*)$",
"replace": "Last updated: $1"
},
{
"pattern": "^上次运行:\\s*(.+)$",
"replace": "Last run: $1"
},
{
"pattern": "^运行历史:\\s*(.+)$",
"replace": "Run History: $1"
},
{
"pattern": "^未找到 Operator操作者\\s*(.+)$",
"replace": "Operator not found: $1"
},
{
"pattern": "^错误:\\s*(.+)$",
"replace": "Error: $1"
},
{
"pattern": "^来源:\\s*(.+)$",
"replace": "via $1"
},
{
"pattern": "^平均延迟:\\s*(.+)$",
"replace": "Avg latency: $1"
},
{
"pattern": "^(\\d+) 个话题$",
"replace": "$1 total topics"
},
{
"pattern": "^(\\d+) 个已跟踪$",
"replace": "$1 tracked"
},
{
"pattern": "^(\\d+) 个孤立$",
"replace": "$1 orphans"
},
{
"pattern": "^ID\\s*(.+) • (\\d+) Token • (.+)$",
"replace": "ID: $1 • $2 tokens • $3"
},
{
"pattern": "^自动刷新30秒$",
"replace": "Auto-refresh: 30s"
},
{
"pattern": "^轮询模式$",
"replace": "Polling Mode"
},
{
"pattern": "^通过 SSE 实时更新$",
"replace": "Real-time updates via SSE"
},
{
"pattern": "^刚刚$",
"replace": "Just now"
}
]
},
"jobs": {
"pageTitle": "AI 任务 - OpenClaw 指挥中心",
"dashboard": "🤖 AI 任务面板",
"refresh": "🔄 刷新",
"totalJobs": "任务总数",
"active": "启用",
"paused": "暂停",
"running": "运行中",
"successRate": "成功率",
"recentFailures": "最近失败",
"all": "全部",
"failed": "失败",
"loadingJobs": "正在加载任务...",
"noJobs": "未找到任务",
"runHistory": "运行历史",
"loadingHistory": "正在加载历史...",
"status": "状态",
"started": "开始时间",
"duration": "耗时",
"details": "详情",
"noHistory": "暂无运行历史",
"statusFailing": "持续失败",
"next": "下次:{value}",
"lane": "🛤️ {value}",
"runs": "运行次数",
"success": "成功",
"avgTime": "平均耗时",
"lastRun": "上次运行:{value}",
"neverRun": "从未运行",
"run": "▶️ 运行",
"resume": "▶️ 恢复",
"pause": "⏸️ 暂停",
"history": "📜 历史",
"every": "每 {value}",
"at": "在 {value}",
"toastLoadFailed": "加载任务失败",
"toastRunQueued": "任务 \"{id}\" 已加入执行队列",
"toastRunFailed": "执行任务失败",
"toastPaused": "任务 \"{id}\" 已暂停",
"toastPauseFailed": "暂停任务失败",
"toastResumed": "任务 \"{id}\" 已恢复",
"toastResumeFailed": "恢复任务失败",
"historyTitle": "运行历史:{name}",
"statusSuccess": "成功",
"statusFailed": "失败",
"statusRunning": "运行中"
}
}

View File

@@ -0,0 +1,167 @@
<!-- Shared Sidebar Partial -->
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<span class="sidebar-logo">🦞</span>
<span class="sidebar-title" data-i18n="sidebar.title">Command Center</span>
<button
class="sidebar-toggle"
id="sidebar-toggle-btn"
onclick="toggleSidebar()"
title="Toggle sidebar"
>
</button>
</div>
<div class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title" data-i18n="sidebar.navigation">Navigation</div>
<a
href="#vitals-section"
class="nav-item"
data-section="vitals"
data-page="/"
data-tooltip="System Vitals"
>
<span class="nav-icon">🖥️</span>
<span data-i18n="sidebar.systemVitals">System Vitals</span>
</a>
<a
href="#llm-section"
class="nav-item"
data-section="llm"
data-page="/"
data-tooltip="LLM Fuel Gauges"
>
<span class="nav-icon"></span>
<span data-i18n="sidebar.llmUsage">LLM Usage</span>
</a>
<a
href="#sessions-section"
class="nav-item"
data-section="sessions"
data-page="/"
data-tooltip="Sessions"
>
<span class="nav-icon">📡</span>
<span data-i18n="sidebar.sessions">Sessions</span>
<span class="nav-badge" id="nav-session-count">-</span>
</a>
<a
href="#cron-section"
class="nav-item"
data-section="cron"
data-page="/"
data-tooltip="Cron Jobs"
>
<span class="nav-icon"></span>
<span data-i18n="sidebar.cronJobs">Cron Jobs</span>
<span class="nav-badge" id="nav-cron-count">-</span>
</a>
<a href="/jobs.html" class="nav-item" data-page="/jobs.html" data-tooltip="AI Jobs Dashboard">
<span class="nav-icon">🤖</span>
<span data-i18n="sidebar.aiJobs">AI Jobs</span>
<span class="nav-badge" id="nav-jobs-count">-</span>
</a>
<a
href="#memory-section"
class="nav-item"
data-section="memory"
data-page="/"
data-tooltip="Memory"
>
<span class="nav-icon">🧠</span>
<span data-i18n="sidebar.memory">Memory</span>
<span class="nav-badge" id="nav-memory-count">-</span>
</a>
<a
href="#cerebro-section"
class="nav-item"
data-section="cerebro"
data-page="/"
data-tooltip="Cerebro"
>
<span class="nav-icon">🔮</span>
<span data-i18n="sidebar.cerebro">Cerebro</span>
<span class="nav-badge" id="nav-cerebro-count">-</span>
</a>
<a
href="#operators-section"
class="nav-item"
data-section="operators"
data-page="/"
data-tooltip="Operators"
>
<span class="nav-icon">👥</span>
<span data-i18n="sidebar.operators">Operators</span>
<span class="nav-badge" id="nav-operator-count">-</span>
</a>
</div>
<div class="nav-section">
<div class="nav-section-title" data-i18n="sidebar.settings">Settings</div>
<a
href="#"
class="nav-item"
onclick="
window.openPrivacyModal && openPrivacyModal();
return false;
"
data-tooltip="Privacy Settings"
>
<span class="nav-icon">🔒</span>
<span data-i18n="sidebar.privacy">Privacy</span>
</a>
<a
href="#about-section"
class="nav-item"
data-section="about"
data-page="/"
data-tooltip="About"
>
<span class="nav-icon"></span>
<span data-i18n="sidebar.about">About</span>
</a>
</div>
<div class="nav-section">
<div class="nav-section-title" data-i18n="sidebar.quickStats">Quick Stats</div>
<div class="nav-item" style="cursor: default" data-tooltip="Total tokens (24h)">
<span class="nav-icon">🎫</span>
<span data-i18n="sidebar.tokens">Tokens</span>
<span class="nav-badge" id="nav-tokens">-</span>
</div>
<div
class="nav-item"
style="cursor: pointer"
onclick="window.openCostModal && openCostModal()"
data-tooltip="Click for breakdown"
>
<span class="nav-icon">💰</span>
<span data-i18n="sidebar.estDaily">Est. Daily</span>
<span class="nav-badge" id="nav-cost">-</span>
</div>
<div
class="nav-item"
style="cursor: pointer"
onclick="window.openCostModal && openCostModal()"
data-tooltip="Projected monthly cost"
>
<span class="nav-icon">📅</span>
<span data-i18n="sidebar.estMonthly">Est. Monthly</span>
<span class="nav-badge" id="nav-monthly-cost">-</span>
</div>
<div class="nav-item" style="cursor: default" data-tooltip="Average tokens per session">
<span class="nav-icon">📊</span>
<span data-i18n="sidebar.avgTokSess">Avg Tok/Sess</span>
<span class="nav-badge" id="nav-avg-tokens">-</span>
</div>
<div class="nav-item" style="cursor: default" data-tooltip="Average cost per session">
<span class="nav-icon">💵</span>
<span data-i18n="sidebar.avgCostSess">Avg $/Sess</span>
<span class="nav-badge" id="nav-avg-cost">-</span>
</div>
</div>
</div>
<div class="sidebar-footer">
<span data-i18n="sidebar.autoRefresh">Auto-refresh: 30s</span><br />
<span id="sidebar-updated">-</span>
</div>
</nav>