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