/** * Compose Farm Web UI JavaScript */ // ============================================================================ // CONSTANTS // ============================================================================ // ANSI escape codes for terminal output const ANSI = { RED: '\x1b[31m', DIM: '\x1b[2m', RESET: '\x1b[0m', CRLF: '\r\n' }; // Terminal color theme (dark mode matching PicoCSS) const TERMINAL_THEME = { background: '#1a1a2e', foreground: '#e4e4e7', cursor: '#e4e4e7', cursorAccent: '#1a1a2e', black: '#18181b', red: '#ef4444', green: '#22c55e', yellow: '#eab308', blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#e4e4e7', brightBlack: '#52525b', brightRed: '#f87171', brightGreen: '#4ade80', brightYellow: '#facc15', brightBlue: '#60a5fa', brightMagenta: '#c084fc', brightCyan: '#22d3ee', brightWhite: '#fafafa' }; // Language detection from file path const LANGUAGE_MAP = { 'yaml': 'yaml', 'yml': 'yaml', 'json': 'json', 'js': 'javascript', 'mjs': 'javascript', 'ts': 'typescript', 'tsx': 'typescript', 'py': 'python', 'sh': 'shell', 'bash': 'shell', 'md': 'markdown', 'html': 'html', 'htm': 'html', 'css': 'css', 'sql': 'sql', 'toml': 'toml', 'ini': 'ini', 'conf': 'ini', 'dockerfile': 'dockerfile', 'env': 'plaintext' }; // Detect Mac for keyboard shortcut display const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const MOD_KEY = IS_MAC ? '⌘' : 'Ctrl'; // ============================================================================ // STATE // ============================================================================ // Store active terminals and editors const terminals = {}; const editors = {}; let monacoLoaded = false; let monacoLoading = false; // LocalStorage key prefix for active tasks (scoped by page) const TASK_KEY_PREFIX = 'cf_task:'; const getTaskKey = () => TASK_KEY_PREFIX + window.location.pathname; // Exec terminal state let execTerminalWrapper = null; // {term, dispose} let execWs = null; // ============================================================================ // UTILITIES // ============================================================================ /** * Get Monaco language from file path * @param {string} path - File path * @returns {string} Monaco language identifier */ function getLanguageFromPath(path) { const ext = path.split('.').pop().toLowerCase(); return LANGUAGE_MAP[ext] || 'plaintext'; } window.getLanguageFromPath = getLanguageFromPath; /** * Create WebSocket connection with standard handlers * @param {string} path - WebSocket path * @returns {WebSocket} */ function createWebSocket(path) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return new WebSocket(`${protocol}//${window.location.host}${path}`); } window.createWebSocket = createWebSocket; /** * Wait for xterm.js to load, then execute callback * @param {function} callback - Function to call when xterm is ready * @param {number} maxAttempts - Max attempts (default 20 = 2 seconds) */ function whenXtermReady(callback, maxAttempts = 20) { const tryInit = (attempts) => { if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') { callback(); } else if (attempts > 0) { setTimeout(() => tryInit(attempts - 1), 100); } else { console.error('xterm.js failed to load'); } }; tryInit(maxAttempts); } // ============================================================================ // TERMINAL // ============================================================================ /** * Create a terminal with fit addon and resize observer * @param {HTMLElement} container - Container element * @param {object} extraOptions - Additional terminal options * @param {function} onResize - Optional callback called with (cols, rows) after resize * @returns {{term: Terminal, fitAddon: FitAddon, dispose: function}} */ function createTerminal(container, extraOptions = {}, onResize = null) { container.innerHTML = ''; const term = new Terminal({ convertEol: true, theme: TERMINAL_THEME, fontSize: 13, fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace', scrollback: 5000, ...extraOptions }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.open(container); const handleResize = () => { fitAddon.fit(); onResize?.(term.cols, term.rows); }; // Use ResizeObserver only (handles both container and window resize) const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(container); handleResize(); // Initial fit return { term, fitAddon, dispose() { resizeObserver.disconnect(); term.dispose(); } }; } /** * Initialize a terminal and connect to WebSocket for streaming */ function initTerminal(elementId, taskId) { const container = document.getElementById(elementId); if (!container) { console.error('Terminal container not found:', elementId); return; } const wrapper = createTerminal(container); const { term } = wrapper; const ws = createWebSocket(`/ws/terminal/${taskId}`); const taskKey = getTaskKey(); ws.onopen = () => { term.write(`${ANSI.DIM}[Connected]${ANSI.RESET}${ANSI.CRLF}`); setTerminalLoading(true); localStorage.setItem(taskKey, taskId); }; ws.onmessage = (event) => { term.write(event.data); if (event.data.includes('[Done]') || event.data.includes('[Failed]')) { localStorage.removeItem(taskKey); } }; ws.onclose = () => setTerminalLoading(false); ws.onerror = (error) => { term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`); console.error('WebSocket error:', error); setTerminalLoading(false); }; terminals[taskId] = { ...wrapper, ws }; return { term, ws }; } /** * Initialize an interactive exec terminal */ function initExecTerminal(stack, container, host) { const containerEl = document.getElementById('exec-terminal-container'); const terminalEl = document.getElementById('exec-terminal'); if (!containerEl || !terminalEl) { console.error('Exec terminal elements not found'); return; } // Unhide the terminal container first, then expand/scroll containerEl.classList.remove('hidden'); expandCollapse(document.getElementById('exec-collapse'), containerEl); // Clean up existing (use wrapper's dispose to clean up ResizeObserver) if (execWs) { execWs.close(); execWs = null; } if (execTerminalWrapper) { execTerminalWrapper.dispose(); execTerminalWrapper = null; } // Create WebSocket first so resize callback can use it execWs = createWebSocket(`/ws/exec/${stack}/${container}/${host}`); // Resize callback sends size to WebSocket const sendSize = (cols, rows) => { if (execWs && execWs.readyState === WebSocket.OPEN) { execWs.send(JSON.stringify({ type: 'resize', cols, rows })); } }; execTerminalWrapper = createTerminal(terminalEl, { cursorBlink: true }, sendSize); const term = execTerminalWrapper.term; execWs.onopen = () => { sendSize(term.cols, term.rows); term.focus(); }; execWs.onmessage = (event) => term.write(event.data); execWs.onclose = () => term.write(`${ANSI.CRLF}${ANSI.DIM}[Connection closed]${ANSI.RESET}${ANSI.CRLF}`); execWs.onerror = (error) => { term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`); console.error('Exec WebSocket error:', error); }; term.onData((data) => { if (execWs && execWs.readyState === WebSocket.OPEN) { execWs.send(data); } }); } window.initExecTerminal = initExecTerminal; /** * Expand a collapse component and scroll to a target element * @param {HTMLInputElement} toggle - The checkbox input that controls the collapse * @param {HTMLElement} [scrollTarget] - Element to scroll to (defaults to collapse container) */ function expandCollapse(toggle, scrollTarget = null) { if (!toggle) return; // Find the parent collapse container const collapse = toggle.closest('.collapse'); if (!collapse) return; const target = scrollTarget || collapse; const scrollToTarget = () => { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; if (!toggle.checked) { // Collapsed - expand first, then scroll after transition const onTransitionEnd = () => { collapse.removeEventListener('transitionend', onTransitionEnd); scrollToTarget(); }; collapse.addEventListener('transitionend', onTransitionEnd); toggle.checked = true; } else { // Already expanded - just scroll scrollToTarget(); } } /** * Expand terminal collapse and scroll to it */ function expandTerminal() { expandCollapse(document.getElementById('terminal-toggle')); } /** * Show/hide terminal loading spinner */ function setTerminalLoading(loading) { const spinner = document.getElementById('terminal-spinner'); if (spinner) { spinner.classList.toggle('hidden', !loading); } } // ============================================================================ // EDITOR (Monaco) // ============================================================================ /** * Load Monaco editor dynamically (only once) */ function loadMonaco(callback) { if (monacoLoaded) { callback(); return; } if (monacoLoading) { // Wait for it to load const checkInterval = setInterval(() => { if (monacoLoaded) { clearInterval(checkInterval); callback(); } }, 100); return; } monacoLoading = true; // Load the Monaco loader script // Use local paths when running from vendored wheel, CDN otherwise const monacoBase = window.CF_VENDORED ? '/static/vendor/monaco' : 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs'; const script = document.createElement('script'); script.src = monacoBase + '/loader.js'; script.onload = function() { require.config({ paths: { vs: monacoBase }}); require(['vs/editor/editor.main'], function() { monacoLoaded = true; monacoLoading = false; callback(); }); }; document.head.appendChild(script); } /** * Create a Monaco editor instance * @param {HTMLElement} container - Container element * @param {string} content - Initial content * @param {string} language - Editor language (yaml, plaintext, etc.) * @param {object} opts - Options: { readonly, onSave } * @returns {object} Monaco editor instance */ function createEditor(container, content, language, opts = {}) { const { readonly = false, onSave = null } = opts; const options = { value: content, language, theme: 'vs-dark', minimap: { enabled: false }, automaticLayout: true, scrollBeyondLastLine: false, fontSize: 14, lineNumbers: 'on', wordWrap: 'on' }; if (readonly) { options.readOnly = true; options.domReadOnly = true; } const editor = monaco.editor.create(container, options); // Add Command+S / Ctrl+S handler for editable editors if (!readonly) { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { if (onSave) { onSave(editor); } else { saveAllEditors(); } }); } return editor; } window.createEditor = createEditor; /** * Initialize all Monaco editors on the page */ function initMonacoEditors() { // Dispose existing editors Object.values(editors).forEach(ed => ed?.dispose?.()); for (const key in editors) delete editors[key]; const editorConfigs = [ { id: 'compose-editor', language: 'yaml', readonly: false }, { id: 'env-editor', language: 'plaintext', readonly: false }, { id: 'config-editor', language: 'yaml', readonly: false }, { id: 'state-viewer', language: 'yaml', readonly: true } ]; // Check if any editor elements exist const hasEditors = editorConfigs.some(({ id }) => document.getElementById(id)); if (!hasEditors) return; // Load Monaco and create editors loadMonaco(() => { editorConfigs.forEach(({ id, language, readonly }) => { const el = document.getElementById(id); if (!el) return; const content = el.dataset.content || ''; editors[id] = createEditor(el, content, language, { readonly }); if (!readonly) { editors[id].saveUrl = el.dataset.saveUrl; } }); }); } /** * Save all editors */ async function saveAllEditors() { const saveBtn = getSaveButton(); const results = []; for (const [id, editor] of Object.entries(editors)) { if (!editor || !editor.saveUrl) continue; const content = editor.getValue(); try { const response = await fetch(editor.saveUrl, { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: content }); const data = await response.json(); if (!response.ok || !data.success) { results.push({ id, success: false, error: data.detail || 'Unknown error' }); } else { results.push({ id, success: true }); } } catch (e) { results.push({ id, success: false, error: e.message }); } } // Show result if (saveBtn && results.length > 0) { saveBtn.textContent = 'Saved!'; setTimeout(() => saveBtn.textContent = saveBtn.id === 'save-config-btn' ? 'Save Config' : 'Save All', 2000); refreshDashboard(); } } /** * Initialize save button handler */ function initSaveButton() { const saveBtn = getSaveButton(); if (!saveBtn) return; saveBtn.onclick = saveAllEditors; } function getSaveButton() { return document.getElementById('save-btn') || document.getElementById('save-config-btn'); } // ============================================================================ // UI HELPERS // ============================================================================ /** * Refresh dashboard partials by dispatching a custom event. * Elements with hx-trigger="cf:refresh from:body" will automatically refresh. */ function refreshDashboard() { document.body.dispatchEvent(new CustomEvent('cf:refresh')); } /** * Filter sidebar stacks by name and host */ function sidebarFilter() { const q = (document.getElementById('sidebar-filter')?.value || '').toLowerCase(); const h = document.getElementById('sidebar-host-select')?.value || ''; let n = 0; document.querySelectorAll('#sidebar-stacks li').forEach(li => { const show = (!q || li.dataset.stack.includes(q)) && (!h || !li.dataset.h || li.dataset.h === h); li.hidden = !show; if (show) n++; }); document.getElementById('sidebar-count').textContent = '(' + n + ')'; } window.sidebarFilter = sidebarFilter; // Play intro animation on command palette button function playFabIntro() { const fab = document.getElementById('cmd-fab'); if (!fab) return; setTimeout(() => { fab.style.setProperty('--cmd-pos', '0'); fab.style.setProperty('--cmd-opacity', '1'); fab.style.setProperty('--cmd-blur', '30'); setTimeout(() => { fab.style.removeProperty('--cmd-pos'); fab.style.removeProperty('--cmd-opacity'); fab.style.removeProperty('--cmd-blur'); }, 3000); }, 500); } // ============================================================================ // COMMAND PALETTE // ============================================================================ (function() { const dialog = document.getElementById('cmd-palette'); const input = document.getElementById('cmd-input'); const list = document.getElementById('cmd-list'); const fab = document.getElementById('cmd-fab'); const themeBtn = document.getElementById('theme-btn'); if (!dialog || !input || !list) return; // Load icons from template (rendered server-side from icons.html) const iconTemplate = document.getElementById('cmd-icons'); const icons = {}; if (iconTemplate) { iconTemplate.content.querySelectorAll('[data-icon]').forEach(el => { icons[el.dataset.icon] = el.innerHTML; }); } // All available DaisyUI themes const THEMES = ['light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk']; const THEME_KEY = 'cf_theme'; const colors = { stack: '#22c55e', action: '#eab308', nav: '#3b82f6', app: '#a855f7', theme: '#ec4899', service: '#14b8a6' }; let commands = []; let filtered = []; let selected = 0; let originalTheme = null; // Store theme when palette opens for preview/restore const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'}); const nav = (url, afterNav) => () => { // Set hash before HTMX swap so inline scripts can read it const hashIndex = url.indexOf('#'); if (hashIndex !== -1) { window.location.hash = url.substring(hashIndex); } htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => { history.pushState({}, '', url); window.scrollTo(0, 0); afterNav?.(); }); }; // Navigate to dashboard (if needed) and trigger action const dashboardAction = (endpoint) => async () => { if (window.location.pathname !== '/') { await htmx.ajax('GET', '/', {target: '#main-content', select: '#main-content', swap: 'outerHTML'}); history.pushState({}, '', '/'); window.scrollTo(0, 0); } htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'}); }; // Apply theme and save to localStorage const setTheme = (theme) => () => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem(THEME_KEY, theme); }; // Preview theme without saving (for hover) const previewTheme = (theme) => { document.documentElement.setAttribute('data-theme', theme); }; // Restore original theme (when closing without selection) const restoreTheme = () => { if (originalTheme) { document.documentElement.setAttribute('data-theme', originalTheme); } }; // Generate color swatch HTML for a theme const themeSwatch = (theme) => ``; const cmd = (type, name, desc, action, icon = null, themeId = null) => ({ type, name, desc, action, icon, themeId }); // Reopen palette with theme filter const openThemePicker = () => { // Small delay to let dialog close before reopening setTimeout(() => open('theme:'), 50); }; function buildCommands() { const openExternal = (url) => () => window.open(url, '_blank'); const actions = [ cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check), cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw), cmd('action', 'Pull All', 'Pull latest images for all stacks', dashboardAction('pull-all'), icons.cloud_download), cmd('action', 'Update All', 'Update all stacks', dashboardAction('update-all'), icons.refresh_cw), cmd('app', 'Theme', 'Change color theme', openThemePicker, icons.palette), cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home), cmd('app', 'Live Stats', 'View all containers across hosts', nav('/live-stats'), icons.box), cmd('app', 'Console', 'Go to console', nav('/console'), icons.terminal), cmd('app', 'Edit Config', 'Edit compose-farm.yaml', nav('/console#editor'), icons.file_code), cmd('app', 'Docs', 'Open documentation', openExternal('https://compose-farm.nijho.lt/'), icons.book_open), cmd('app', 'GitHub Repo', 'Open GitHub repository', openExternal('https://github.com/basnijholt/compose-farm'), icons.external_link), ]; // Add stack-specific actions if on a stack page const match = window.location.pathname.match(/^\/stack\/(.+)$/); if (match) { const stack = decodeURIComponent(match[1]); const stackCmd = (name, desc, endpoint, icon) => cmd('stack', name, `${desc} ${stack}`, post(`/api/stack/${stack}/${endpoint}`), icon); actions.unshift( stackCmd('Up', 'Start', 'up', icons.play), stackCmd('Down', 'Stop', 'down', icons.square), stackCmd('Restart', 'Restart', 'restart', icons.rotate_cw), stackCmd('Pull', 'Pull', 'pull', icons.cloud_download), stackCmd('Update', 'Pull + restart', 'update', icons.refresh_cw), stackCmd('Logs', 'View logs for', 'logs', icons.file_text), ); // Add Open Website commands if website URLs are available const websiteUrlsAttr = document.querySelector('[data-website-urls]')?.getAttribute('data-website-urls'); if (websiteUrlsAttr) { const websiteUrls = JSON.parse(websiteUrlsAttr); for (const url of websiteUrls) { const displayUrl = url.replace(/^https?:\/\//, ''); const label = websiteUrls.length > 1 ? `Open: ${displayUrl}` : 'Open Website'; actions.unshift(cmd('stack', label, `Open ${displayUrl} in browser`, openExternal(url), icons.external_link)); } } // Add service-specific commands from data-services and data-containers attributes // Grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically const servicesAttr = document.querySelector('[data-services]')?.getAttribute('data-services'); const containersAttr = document.querySelector('[data-containers]')?.getAttribute('data-containers'); if (servicesAttr) { const services = servicesAttr.split(',').filter(s => s).sort(); // Parse container info for shell access: {service: {container, host}} const containers = containersAttr ? JSON.parse(containersAttr) : {}; const svcCmd = (action, service, desc, endpoint, icon) => cmd('service', `${action}: ${service}`, desc, post(`/api/stack/${stack}/service/${service}/${endpoint}`), icon); const svcActions = [ ['Logs', 'View logs for service', 'logs', icons.file_text], ['Pull', 'Pull image for service', 'pull', icons.cloud_download], ['Restart', 'Restart service', 'restart', icons.rotate_cw], ['Stop', 'Stop service', 'stop', icons.square], ['Up', 'Start service', 'up', icons.play], ]; for (const [action, desc, endpoint, icon] of svcActions) { for (const service of services) { actions.push(svcCmd(action, service, desc, endpoint, icon)); } } // Add Shell commands if container info is available for (const service of services) { const info = containers[service]; if (info?.container && info?.host) { actions.push(cmd('service', `Shell: ${service}`, 'Open interactive shell', () => initExecTerminal(stack, info.container, info.host), icons.terminal)); } } } } // Add nav commands for all stacks from sidebar const stacks = [...document.querySelectorAll('#sidebar-stacks li[data-stack] a[href]')].map(a => { const name = a.getAttribute('href').replace('/stack/', ''); return cmd('nav', name, 'Go to stack', nav(`/stack/${name}`), icons.box); }); // Add theme commands with color swatches const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; const themeCommands = THEMES.map(theme => cmd('theme', `theme: ${theme}`, theme === currentTheme ? '(current)' : 'Switch theme', setTheme(theme), themeSwatch(theme), theme) ); commands = [...actions, ...stacks, ...themeCommands]; } function filter() { // Fuzzy matching: all query words must match the START of a word in the command name // Examples: "r ba" matches "Restart: bazarr" but NOT "Logs: bazarr" const q = input.value.toLowerCase().trim(); // Split query into words and strip non-alphanumeric chars const queryWords = q.split(/[^a-z0-9]+/).filter(w => w); filtered = commands.filter(c => { const name = c.name.toLowerCase(); // Split command name into words (split on non-alphanumeric) const nameWords = name.split(/[^a-z0-9]+/).filter(w => w); // Each query word must match the start of some word in the command name return queryWords.every(qw => nameWords.some(nw => nw.startsWith(qw)) ); }); selected = Math.max(0, Math.min(selected, filtered.length - 1)); } function render() { list.innerHTML = filtered.map((c, i) => ` ${c.icon || ''}${c.name} ${c.desc} `).join('') || '
No matches
'; // Scroll selected item into view const sel = list.querySelector(`[data-idx="${selected}"]`); if (sel) sel.scrollIntoView({ block: 'nearest' }); // Preview theme if selected item is a theme command const selectedCmd = filtered[selected]; if (selectedCmd?.themeId) { previewTheme(selectedCmd.themeId); } else if (originalTheme) { // Restore original when navigating away from theme commands previewTheme(originalTheme); } } function open(initialFilter = '') { // Store original theme for preview/restore originalTheme = document.documentElement.getAttribute('data-theme') || 'dark'; buildCommands(); selected = 0; input.value = initialFilter; filter(); // If opening theme picker, select current theme if (initialFilter.startsWith('theme:')) { const currentIdx = filtered.findIndex(c => c.themeId === originalTheme); if (currentIdx >= 0) selected = currentIdx; } render(); dialog.showModal(); input.focus(); } function exec() { const cmd = filtered[selected]; if (cmd) { if (cmd.themeId) { // Theme command commits the previewed choice. originalTheme = null; } dialog.close(); cmd.action(); } } // Keyboard: Cmd+K to open document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); open(); } }); // Input filtering input.addEventListener('input', () => { filter(); render(); }); // Keyboard nav inside palette dialog.addEventListener('keydown', e => { if (!dialog.open) return; if (e.key === 'ArrowDown') { e.preventDefault(); selected = Math.min(selected + 1, filtered.length - 1); render(); } else if (e.key === 'ArrowUp') { e.preventDefault(); selected = Math.max(selected - 1, 0); render(); } else if (e.key === 'Enter') { e.preventDefault(); exec(); } }); // Click to execute list.addEventListener('click', e => { const a = e.target.closest('a[data-idx]'); if (a) { selected = parseInt(a.dataset.idx, 10); exec(); } }); // Hover previews theme without changing selection list.addEventListener('mouseover', e => { const a = e.target.closest('a[data-theme-id]'); if (a) previewTheme(a.dataset.themeId); }); // Mouse leaving list restores to selected item's theme (or original) list.addEventListener('mouseleave', () => { const cmd = filtered[selected]; previewTheme(cmd?.themeId || originalTheme); }); // Restore theme when dialog closes without selection (Escape, backdrop click) dialog.addEventListener('close', () => { if (originalTheme) { restoreTheme(); originalTheme = null; } }); // FAB click to open if (fab) fab.addEventListener('click', () => open()); // Theme button opens palette with "theme:" filter if (themeBtn) themeBtn.addEventListener('click', () => open('theme:')); })(); // ============================================================================ // THEME PERSISTENCE // ============================================================================ // Restore saved theme on load (also handled in inline script to prevent flash) (function() { const saved = localStorage.getItem('cf_theme'); if (saved) document.documentElement.setAttribute('data-theme', saved); })(); // ============================================================================ // INITIALIZATION // ============================================================================ /** * Global keyboard shortcut handler */ function initKeyboardShortcuts() { document.addEventListener('keydown', function(e) { // Command+S (Mac) or Ctrl+S (Windows/Linux) if ((e.metaKey || e.ctrlKey) && e.key === 's') { // Only handle if we have editors and no Monaco editor is focused if (Object.keys(editors).length > 0) { // Check if any Monaco editor is focused const focusedEditor = Object.values(editors).find(ed => ed?.hasTextFocus?.()); if (!focusedEditor) { e.preventDefault(); saveAllEditors(); } } } }); } /** * Update keyboard shortcut display based on OS * Replaces ⌘ with Ctrl on non-Mac platforms */ function updateShortcutKeys() { // Update elements with class 'shortcut-key' that contain ⌘ document.querySelectorAll('.shortcut-key').forEach(el => { if (el.textContent === '⌘') { el.textContent = MOD_KEY; } }); } /** * Initialize page components */ function initPage() { initMonacoEditors(); initSaveButton(); updateShortcutKeys(); initLiveStats(); initSharedActionMenu(); maybeRunStackAction(); } function navigateToStack(stack, action = null) { const url = action ? `/stack/${stack}?action=${action}` : `/stack/${stack}`; window.location.href = url; } /** * Initialize shared action menu for container rows */ function initSharedActionMenu() { const menuEl = document.getElementById('shared-action-menu'); if (!menuEl) return; if (menuEl.dataset.bound === '1') return; menuEl.dataset.bound = '1'; let hoverTimeout = null; function showMenuForButton(btn, stack) { menuEl.dataset.stack = stack; // Position menu relative to button const rect = btn.getBoundingClientRect(); menuEl.classList.remove('hidden'); menuEl.style.visibility = 'hidden'; const menuRect = menuEl.getBoundingClientRect(); const left = rect.right - menuRect.width + window.scrollX; const top = rect.bottom + window.scrollY; menuEl.style.top = `${top}px`; menuEl.style.left = `${left}px`; menuEl.style.visibility = ''; if (typeof liveStats !== 'undefined') liveStats.dropdownOpen = true; } function closeMenu() { menuEl.classList.add('hidden'); if (typeof liveStats !== 'undefined') liveStats.dropdownOpen = false; menuEl.dataset.stack = ''; } function scheduleClose() { if (hoverTimeout) clearTimeout(hoverTimeout); hoverTimeout = setTimeout(closeMenu, 100); } function cancelClose() { if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; } } // Button hover: show menu (event delegation on tbody) const tbody = document.getElementById('container-rows'); if (tbody) { tbody.addEventListener('mouseenter', (e) => { const btn = e.target.closest('button[onclick^="openActionMenu"]'); if (!btn) return; // Extract stack from onclick attribute const match = btn.getAttribute('onclick')?.match(/openActionMenu\(event,\s*'([^']+)'\)/); if (!match) return; cancelClose(); showMenuForButton(btn, match[1]); }, true); tbody.addEventListener('mouseleave', (e) => { const btn = e.target.closest('button[onclick^="openActionMenu"]'); if (btn) scheduleClose(); }, true); } // Keep menu open while hovering over it menuEl.addEventListener('mouseenter', cancelClose); menuEl.addEventListener('mouseleave', scheduleClose); // Click action in menu menuEl.addEventListener('click', (e) => { const link = e.target.closest('a[data-action]'); const stack = menuEl.dataset.stack; if (!link || !stack) return; e.preventDefault(); navigateToStack(stack, link.dataset.action); closeMenu(); }); // Also support click on button (for touch/accessibility) window.openActionMenu = function(event, stack) { event.stopPropagation(); showMenuForButton(event.currentTarget, stack); }; // Close on outside click document.body.addEventListener('click', (e) => { if (!menuEl.classList.contains('hidden') && !menuEl.contains(e.target) && !e.target.closest('button[onclick^="openActionMenu"]')) { closeMenu(); } }); // Close on Escape document.body.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); }); } /** * Attempt to reconnect to an active task from localStorage * @param {string} [path] - Optional path to use for task key lookup. * If not provided, uses current window.location.pathname. * This is important for HTMX navigation where pushState * hasn't happened yet when htmx:afterSwap fires. */ function tryReconnectToTask(path) { const taskKey = TASK_KEY_PREFIX + (path || window.location.pathname); const taskId = localStorage.getItem(taskKey); if (!taskId) return; whenXtermReady(() => { expandTerminal(); initTerminal('terminal-output', taskId); }); } function maybeRunStackAction() { const params = new URLSearchParams(window.location.search); const stackEl = document.querySelector('[data-stack-name]'); const stackName = stackEl?.dataset?.stackName; if (!stackName) return; const action = params.get('action'); if (!action) return; const button = document.querySelector(`button[hx-post="/api/stack/${stackName}/${action}"]`); if (!button) return; params.delete('action'); const newQuery = params.toString(); const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname; history.replaceState({}, '', newUrl); if (window.htmx) { htmx.trigger(button, 'click'); } else { button.click(); } } // Initialize on page load document.addEventListener('DOMContentLoaded', function() { initPage(); initKeyboardShortcuts(); playFabIntro(); // Try to reconnect to any active task tryReconnectToTask(); }); // Re-initialize after HTMX swaps main content document.body.addEventListener('htmx:afterSwap', function(evt) { if (evt.detail.target.id === 'main-content') { initPage(); // Try to reconnect to task for the TARGET page, not current URL. // When using command palette navigation (htmx.ajax + manual pushState), // window.location.pathname still reflects the OLD page at this point. // Use pathInfo.requestPath to get the correct target path. const targetPath = evt.detail.pathInfo?.requestPath?.split('?')[0] || window.location.pathname; tryReconnectToTask(targetPath); } }); // Handle action responses (terminal streaming) document.body.addEventListener('htmx:afterRequest', function(evt) { if (!evt.detail.successful || !evt.detail.xhr) return; const text = evt.detail.xhr.responseText; // Only try to parse if it looks like JSON (starts with {) if (!text || !text.trim().startsWith('{')) return; try { const response = JSON.parse(text); if (response.task_id) { expandTerminal(); whenXtermReady(() => initTerminal('terminal-output', response.task_id)); } } catch (e) { // Not valid JSON, ignore } }); // ============================================================================ // LIVE STATS PAGE // ============================================================================ // State persists across SPA navigation (intervals must be cleared on re-init) let liveStats = { sortCol: 9, sortAsc: false, lastUpdate: 0, dropdownOpen: false, scrolling: false, scrollTimer: null, loadingHosts: new Set(), eventsBound: false, intervals: [], updateCheckTimes: new Map(), autoRefresh: true }; const REFRESH_INTERVAL = 5000; const UPDATE_CHECK_TTL = 120000; const NUMERIC_COLS = new Set([8, 9, 10, 11]); // uptime, cpu, mem, net function filterTable() { const textFilter = document.getElementById('filter-input')?.value.toLowerCase() || ''; const hostFilter = document.getElementById('host-filter')?.value || ''; const rows = document.querySelectorAll('#container-rows tr'); let visible = 0; let total = 0; rows.forEach(row => { // Skip loading/empty/error rows (they have colspan) if (row.cells[0]?.colSpan > 1) return; total++; const matchesText = !textFilter || row.textContent.toLowerCase().includes(textFilter); const matchesHost = !hostFilter || row.dataset.host === hostFilter; const show = matchesText && matchesHost; row.style.display = show ? '' : 'none'; if (show) visible++; }); const countEl = document.getElementById('container-count'); if (countEl) { const isFiltering = textFilter || hostFilter; countEl.textContent = total > 0 ? (isFiltering ? `${visible} of ${total} containers` : `${total} containers`) : ''; } } window.filterTable = filterTable; function sortTable(col) { if (liveStats.sortCol === col) { liveStats.sortAsc = !liveStats.sortAsc; } else { liveStats.sortCol = col; liveStats.sortAsc = false; } updateSortIndicators(); doSort(); } window.sortTable = sortTable; function updateSortIndicators() { document.querySelectorAll('thead th').forEach((th, i) => { const span = th.querySelector('.sort-indicator'); if (span) { span.textContent = (i === liveStats.sortCol) ? (liveStats.sortAsc ? '↑' : '↓') : ''; span.style.opacity = (i === liveStats.sortCol) ? '1' : '0.3'; } }); } function doSort() { const tbody = document.getElementById('container-rows'); if (!tbody) return; const rows = Array.from(tbody.querySelectorAll('tr')); if (rows.length === 0) return; if (rows.length === 1 && rows[0].cells[0]?.colSpan > 1) return; // Empty state row const isNumeric = NUMERIC_COLS.has(liveStats.sortCol); rows.sort((a, b) => { // Pin placeholders/empty rows to the bottom const aLoading = a.classList.contains('loading-row') || a.classList.contains('host-empty') || a.cells[0]?.colSpan > 1; const bLoading = b.classList.contains('loading-row') || b.classList.contains('host-empty') || b.cells[0]?.colSpan > 1; if (aLoading && !bLoading) return 1; if (!aLoading && bLoading) return -1; if (aLoading && bLoading) return 0; const aVal = a.cells[liveStats.sortCol]?.dataset?.sort ?? ''; const bVal = b.cells[liveStats.sortCol]?.dataset?.sort ?? ''; const cmp = isNumeric ? aVal - bVal : aVal.localeCompare(bVal); return liveStats.sortAsc ? cmp : -cmp; }); let index = 1; const fragment = document.createDocumentFragment(); rows.forEach((row) => { if (row.cells.length > 1) { row.cells[0].textContent = index++; } fragment.appendChild(row); }); tbody.appendChild(fragment); } function isLoading() { return liveStats.loadingHosts.size > 0; } function getLiveStatsHosts() { const tbody = document.getElementById('container-rows'); if (!tbody) return []; const dataHosts = tbody.dataset.hosts || ''; return dataHosts.split(',').map(h => h.trim()).filter(Boolean); } function buildHostRow(host, message, className) { return ( `` + `` + `${message}` + `` ); } async function checkUpdatesForHost(host) { // Update checks always run - they only update small cells, not disruptive const last = liveStats.updateCheckTimes.get(host) || 0; if (Date.now() - last < UPDATE_CHECK_TTL) return; const cells = Array.from( document.querySelectorAll(`tr[data-host="${host}"] td.update-cell[data-image][data-tag]`) ); if (cells.length === 0) return; const items = []; const seen = new Set(); cells.forEach(cell => { const image = decodeURIComponent(cell.dataset.image || ''); const tag = decodeURIComponent(cell.dataset.tag || ''); const key = `${image}:${tag}`; if (!image || seen.has(key)) return; seen.add(key); items.push({ image, tag }); }); if (items.length === 0) return; try { const response = await fetch('/api/containers/check-updates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }) }); if (!response.ok) return; const data = await response.json(); const results = Array.isArray(data?.results) ? data.results : []; const htmlMap = new Map(); results.forEach(result => { const key = `${result.image}:${result.tag}`; htmlMap.set(key, result.html); }); cells.forEach(cell => { const image = decodeURIComponent(cell.dataset.image || ''); const tag = decodeURIComponent(cell.dataset.tag || ''); const key = `${image}:${tag}`; const html = htmlMap.get(key); if (html && cell.innerHTML !== html) { cell.innerHTML = html; } }); liveStats.updateCheckTimes.set(host, Date.now()); } catch (e) { console.error('Update check failed:', e); } } function replaceHostRows(host, html) { const tbody = document.getElementById('container-rows'); if (!tbody) return; // Remove loading indicator for this host if present const loadingRow = tbody.querySelector(`tr.loading-row[data-host="${host}"]`); if (loadingRow) loadingRow.remove(); const template = document.createElement('template'); template.innerHTML = html.trim(); let newRows = Array.from(template.content.children).filter(el => el.tagName === 'TR'); if (newRows.length === 0) { // Only show empty message if we don't have any rows for this host const existing = tbody.querySelector(`tr[data-host="${host}"]:not(.loading-row)`); if (!existing) { template.innerHTML = buildHostRow(host, `No containers on ${host}`, 'host-empty'); newRows = Array.from(template.content.children); } } // Track which IDs we've seen in this update const newIds = new Set(); newRows.forEach(newRow => { const id = newRow.id; if (id) newIds.add(id); if (id) { const existing = document.getElementById(id); if (existing) { // Morph in place if Idiomorph is available, otherwise replace if (typeof Idiomorph !== 'undefined') { Idiomorph.morph(existing, newRow); } else { existing.replaceWith(newRow); } // Re-process HTMX if needed (though inner content usually carries attributes) const morphedRow = document.getElementById(id); if (window.htmx) htmx.process(morphedRow); // Trigger refresh animation if (morphedRow) { morphedRow.classList.add('row-updated'); setTimeout(() => morphedRow.classList.remove('row-updated'), 500); } } else { // New row - append (will be sorted later) tbody.appendChild(newRow); if (window.htmx) htmx.process(newRow); // Animate new rows too newRow.classList.add('row-updated'); setTimeout(() => newRow.classList.remove('row-updated'), 500); } } else { // Fallback for rows without ID (like error/empty messages) // Just append them, cleaning up previous generic rows handled below tbody.appendChild(newRow); } }); // Remove orphaned rows for this host (rows that exist in DOM but not in new response) // Be careful not to remove rows that were just added (if they lack IDs) const currentHostRows = Array.from(tbody.querySelectorAll(`tr[data-host="${host}"]`)); currentHostRows.forEach(row => { // Skip if it's one of the new rows we just appended (check presence in newRows?) // Actually, if we just appended it, it is in DOM. // We rely on ID matching. // Error/Empty rows usually don't have ID, but we handle them by clearing old ones? // Let's assume data rows have IDs. if (row.id && !newIds.has(row.id)) { row.remove(); } // Also remove old empty/error messages if we now have data if (!row.id && newRows.length > 0 && newRows[0].id) { row.remove(); } }); liveStats.loadingHosts.delete(host); checkUpdatesForHost(host); scheduleRowUpdate(); } async function loadHostRows(host) { liveStats.loadingHosts.add(host); try { const response = await fetch(`/api/containers/rows/${encodeURIComponent(host)}`); const html = response.ok ? await response.text() : ''; replaceHostRows(host, html); } catch (e) { console.error(`Failed to load ${host}:`, e); const msg = e.message || String(e); // Fallback to simpler error display if replaceHostRows fails (e.g. Idiomorph missing) try { replaceHostRows(host, buildHostRow(host, `Error: ${msg}`, 'text-error')); } catch (err2) { // Last resort: find row and force innerHTML const tbody = document.getElementById('container-rows'); const row = tbody?.querySelector(`tr[data-host="${host}"]`); if (row) row.innerHTML = `Error: ${msg}`; } } finally { liveStats.loadingHosts.delete(host); } } function refreshLiveStats() { if (liveStats.dropdownOpen || liveStats.scrolling) return; const hosts = getLiveStatsHosts(); if (hosts.length === 0) return; liveStats.lastUpdate = Date.now(); hosts.forEach(loadHostRows); } window.refreshLiveStats = refreshLiveStats; function toggleAutoRefresh() { liveStats.autoRefresh = !liveStats.autoRefresh; const timer = document.getElementById('refresh-timer'); if (timer) { timer.classList.toggle('btn-error', !liveStats.autoRefresh); timer.classList.toggle('btn-outline', liveStats.autoRefresh); } if (liveStats.autoRefresh) { // Re-enabling: trigger immediate refresh refreshLiveStats(); } else { // Disabling: ensure update checks run for current data const hosts = getLiveStatsHosts(); hosts.forEach(host => checkUpdatesForHost(host)); } } window.toggleAutoRefresh = toggleAutoRefresh; function initLiveStats() { if (!document.getElementById('refresh-timer')) return; // Clear previous intervals (important for SPA navigation) liveStats.intervals.forEach(clearInterval); liveStats.intervals = []; liveStats.lastUpdate = Date.now(); liveStats.dropdownOpen = false; liveStats.scrolling = false; if (liveStats.scrollTimer) clearTimeout(liveStats.scrollTimer); liveStats.scrollTimer = null; liveStats.loadingHosts.clear(); liveStats.updateCheckTimes = new Map(); liveStats.autoRefresh = true; if (!liveStats.eventsBound) { liveStats.eventsBound = true; // Dropdown pauses refresh document.body.addEventListener('click', e => { liveStats.dropdownOpen = !!e.target.closest('.dropdown'); }); document.body.addEventListener('focusin', e => { if (e.target.closest('.dropdown')) liveStats.dropdownOpen = true; }); document.body.addEventListener('focusout', () => { setTimeout(() => { liveStats.dropdownOpen = !!document.activeElement?.closest('.dropdown'); }, 150); }); document.body.addEventListener('keydown', e => { if (e.key === 'Escape') liveStats.dropdownOpen = false; }); // Pause refresh while scrolling (helps on slow mobile browsers) window.addEventListener('scroll', () => { liveStats.scrolling = true; if (liveStats.scrollTimer) clearTimeout(liveStats.scrollTimer); liveStats.scrollTimer = setTimeout(() => { liveStats.scrolling = false; }, 200); }, { passive: true }); } // Auto-refresh every 5 seconds (skip if disabled, loading, or dropdown open) liveStats.intervals.push(setInterval(() => { if (!liveStats.autoRefresh) return; if (liveStats.dropdownOpen || liveStats.scrolling || isLoading()) return; refreshLiveStats(); }, REFRESH_INTERVAL)); // Timer display (updates every 100ms) liveStats.intervals.push(setInterval(() => { const timer = document.getElementById('refresh-timer'); if (!timer) { liveStats.intervals.forEach(clearInterval); return; } const loading = isLoading(); const paused = liveStats.dropdownOpen || liveStats.scrolling; const elapsed = Date.now() - liveStats.lastUpdate; window.refreshPaused = paused || loading || !liveStats.autoRefresh; // Update refresh timer button let text; if (!liveStats.autoRefresh) { text = 'OFF'; } else if (paused) { text = '❚❚'; } else { const remaining = Math.max(0, REFRESH_INTERVAL - elapsed); text = loading ? '↻ …' : `↻ ${Math.ceil(remaining / 1000)}s`; } if (timer.textContent !== text) { timer.textContent = text; } // Update "last updated" display const lastUpdatedEl = document.getElementById('last-updated'); if (lastUpdatedEl) { const secs = Math.floor(elapsed / 1000); const updatedText = secs < 5 ? 'Updated just now' : `Updated ${secs}s ago`; if (lastUpdatedEl.textContent !== updatedText) { lastUpdatedEl.textContent = updatedText; } } }, 100)); updateSortIndicators(); refreshLiveStats(); } function scheduleRowUpdate() { // Sort and filter immediately to prevent flicker doSort(); filterTable(); } // ============================================================================ // STACKS BY HOST FILTER // ============================================================================ function sbhFilter() { const query = (document.getElementById('sbh-filter')?.value || '').toLowerCase(); const hostFilter = document.getElementById('sbh-host-select')?.value || ''; document.querySelectorAll('.sbh-group').forEach(group => { if (hostFilter && group.dataset.h !== hostFilter) { group.hidden = true; return; } let visibleCount = 0; group.querySelectorAll('li[data-s]').forEach(li => { const show = !query || li.dataset.s.includes(query); li.hidden = !show; if (show) visibleCount++; }); group.hidden = visibleCount === 0; }); } window.sbhFilter = sbhFilter;