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

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";
}