358 lines
9.9 KiB
JavaScript
358 lines
9.9 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
})();
|