Initial commit with translated description
This commit is contained in:
119
public/js/api.js
Normal file
119
public/js/api.js
Normal 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
309
public/js/app.js
Normal 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
386
public/js/i18n.js
Normal 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
507
public/js/lib/morphdom.min.js
vendored
Normal 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
357
public/js/sidebar.js
Normal 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
87
public/js/store.js
Normal 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
47
public/js/utils.js
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user