// Step 1: Initialize the variable with the current full URL let API_BASE_URL = window.location.href; // Step 2: Trim the last slash if it exists and reassign to the variable API_BASE_URL = API_BASE_URL.replace(/\/$/, ""); // ========================================================================= // GLOBAL IN-MEMORY STORE // ========================================================================= // This object will hold the full configuration data in JavaScript memory. // It stores JSON configs and caches the raw YAML string under the 'yaml' key. const configStore = { clusters: {}, listeners: {} // listener objects will now have a 'filterChains' array to store domain configs }; // ========================================================================= // MODAL HANDLERS // ========================================================================= /** * Displays the modal with JSON and YAML content. * @param {string} title - The modal title. * @param {object} jsonData - The configuration data object (used for JSON tab). * @param {string} yamlData - The configuration data as a YAML string. */ function showModal(title, jsonData, yamlData) { document.getElementById('modal-title').textContent = title; // Populate JSON content document.getElementById('modal-json-content').textContent = JSON.stringify(jsonData, null, 2); // Populate YAML content document.getElementById('modal-yaml-content').textContent = yamlData; // Default to YAML tab const modalContent = document.getElementById('configModal')?.querySelector('.modal-content'); if (modalContent) { switchTab(modalContent, 'yaml'); } document.getElementById('configModal').style.display = 'block'; } function hideModal() { document.getElementById('configModal').style.display = 'none'; } window.addEventListener('keydown', (event) => { if (event.key === 'Escape') hideModal(); }); // Close modal when clicking outside of the content (on the backdrop) window.addEventListener('click', (event) => { const modal = document.getElementById('configModal'); if (event.target === modal) { hideModal(); } }); // Helper function that MUST be in your HTML/JS setup for the tabs to work function switchTab(modalContent, tabName) { // Deactivate all buttons and hide all content modalContent.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active')); modalContent.querySelectorAll('.code-block').forEach(content => content.style.display = 'none'); // Activate the selected button and show the corresponding content const activeBtn = modalContent.querySelector(`.tab-button[data-tab="${tabName}"]`); const activeContent = document.getElementById(`modal-${tabName}-content`); if (activeBtn) activeBtn.classList.add('active'); if (activeContent) activeContent.style.display = 'block'; } function setupModalTabs() { const modalContent = document.getElementById('configModal')?.querySelector('.modal-content'); if (!modalContent) return; modalContent.querySelectorAll('.tab-button').forEach(button => { button.addEventListener('click', (event) => { const tabName = event.target.getAttribute('data-tab'); switchTab(modalContent, tabName); }); }); } // ========================================================================= // CONFIG-SPECIFIC MODAL LAUNCHERS (UPDATED) // ========================================================================= /** * Handles showing the configuration for an individual FilterChain/Domain. * This function now loads the JSON from memory and fetches YAML via API. */ async function showDomainConfig(element) { const title = element.getAttribute('data-title'); const listenerName = element.getAttribute('data-listener-name'); // We now use domainName for the API call const domainName = element.getAttribute('data-domain-name'); // We now use index to retrieve the JSON from memory const chainIndex = element.getAttribute('data-chain-index'); if (!listenerName || domainName === null || chainIndex === null) { console.error("Missing required data attributes for domain config."); return; } // 1. Get JSON data from memory const listener = configStore.listeners[listenerName]; const jsonData = listener?.filterChains?.[parseInt(chainIndex)]; if (!jsonData) { const errorMsg = 'Filter Chain configuration not found in memory.'; console.error(errorMsg); showModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); return; } let yamlData = 'Loading YAML from API...'; try { // 2. CALL NEW API FOR DOMAIN YAML using the domain name // MODIFICATION: Use /get-listener API instead of the non-existent /get-domain const response = await fetch(`${API_BASE_URL}/get-listener?name=${encodeURIComponent(listenerName)}&format=yaml`); if (!response.ok) { yamlData = `Error fetching Listener YAML (fallback): ${response.status} ${response.statusText}`; } else { // Include a note that this is the full listener config yamlData = await response.text(); yamlData = `---\n# Full Listener Config (Fallback for Domain)\n# This is not the specific filter chain config.\n---\n\n` + yamlData; } } catch (error) { console.error("Failed to fetch YAML listener config:", error); yamlData = `Network Error fetching YAML (Fallback): ${error.message}`; } // 3. Fallback if API call failed if (yamlData.includes('Error fetching') || yamlData.includes('Network Error')) { const yamlApproximation = JSON.stringify(jsonData, null, 2) .replace(/[{}]/g, '') .replace(/"(\w+)":\s*/g, '$1: ') .replace(/,\n\s*/g, '\n') .replace(/\[\n\s*(\s*)/g, '\n$1 - ') .replace(/,\n\s*(\s*)/g, '\n$1- ') .replace(/:\s*"/g, ': ') .replace(/"/g, ''); yamlData = yamlApproximation + `\n\n--- WARNING: YAML is an approximation because the /get-listener API call failed. ---\n\n${yamlData}`; // Ensure the JSON tab is active by default when the YAML is a failed approximation const modalContent = document.getElementById('configModal')?.querySelector('.modal-content'); if (modalContent) { switchTab(modalContent, 'json'); } } yamlData = extractFilterChainByDomain(yamlData, domainName) || yamlData; showModal(title, jsonData, yamlData); } /** * Extracts the YAML section for a specific domain from the filterChains array. * @param {string} yamlData - The full YAML string containing the listener configuration. * @param {string} domainName - The domain name to search for (e.g., 'docker.jerxie.com'). * @returns {string | null} The YAML string for the matching filterChain, or null if not found. */ function extractFilterChainByDomain(yamlData, domainName) { // 1. Check for YAML library availability if (typeof require === 'undefined' && typeof jsyaml === 'undefined') { console.error("Error: YAML parser (e.g., js-yaml) is required but not found."); return null; } let fullConfig; try { const yaml = (typeof require !== 'undefined') ? require('js-yaml') : jsyaml; // Use yaml.loadAll() instead of yaml.load() to handle multi-document streams (separated by ---). const allDocs = yaml.loadAll(yamlData); // Assuming the main configuration object is the first or only document in the stream fullConfig = allDocs.find(doc => doc && typeof doc === 'object'); } catch (e) { console.error("Error parsing YAML data:", e); return null; } if (!fullConfig || !Array.isArray(fullConfig.filterChains)) { console.warn("Input YAML does not contain a 'filterChains' array, or the main document was not found."); return null; } // 2. Find the matching filter chain object const matchingChain = fullConfig.filterChains.find(chain => { const serverNames = chain.filterChainMatch?.serverNames; // Check if serverNames exists and includes the domainName return serverNames && serverNames.includes(domainName); }); if (!matchingChain) { console.log(`No filterChain found for domain: ${domainName}`); return null; } // 3. Serialize the found object back to a YAML string try { const yaml = (typeof require !== 'undefined') ? require('js-yaml') : jsyaml; // FIX: Dump the matchingChain object directly, not as an array element. // This will make 'filterChainMatch' the root key of the resulting YAML string. const outputYaml = yaml.dump(matchingChain, { // Optional: Customize indentation and flow style for cleaner output indent: 2, lineWidth: -1, // Do not wrap lines flowLevel: -1 // Use block style for collections }); // No need to remove the initial '- ' anymore as it won't be there. return outputYaml.trim(); } catch (e) { console.error("Error dumping YAML data:", e); return null; } } async function showClusterConfigModal(clusterName) { const config = configStore.clusters[clusterName]; if (!config) { showModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); return; } let yamlData = configStore.clusters[clusterName]?.yaml || 'Loading YAML...'; if (yamlData === 'Loading YAML...') { try { // CALL API FOR YAML const response = await fetch(`${API_BASE_URL}/get-cluster?name=${clusterName}&format=yaml`); if (!response.ok) { yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; } else { yamlData = await response.text(); configStore.clusters[clusterName].yaml = yamlData; // Store YAML } } catch (error) { console.error("Failed to fetch YAML cluster config:", error); yamlData = `Network Error fetching YAML: ${error.message}`; } } // Pass JSON object from memory and authoritative YAML from API/memory showModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); } async function showListenerConfigModal(listenerName) { const config = configStore.listeners[listenerName]; if (!config) { showModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); return; } let yamlData = configStore.listeners[listenerName]?.yaml || 'Loading YAML...'; if (yamlData === 'Loading YAML...') { try { // CALL API FOR YAML const response = await fetch(`${API_BASE_URL}/get-listener?name=${listenerName}&format=yaml`); if (!response.ok) { yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; } else { yamlData = await response.text(); configStore.listeners[listenerName].yaml = yamlData; // Store YAML } } catch (error) { console.error("Failed to fetch YAML listener config:", error); yamlData = `Network Error fetching YAML: ${error.message}`; } } // Pass JSON object from memory and authoritative YAML from API/memory showModal(`Full Config for Listener: ${listenerName}`, config, yamlData); } // ========================================================================= // CLUSTER ENABLE/DISABLE/REMOVE LOGIC // ========================================================================= async function toggleClusterStatus(clusterName, action) { let url = ''; // Determine the API endpoint based on the action if (action === 'remove') { url = `${API_BASE_URL}/remove-cluster`; } else { // 'enable-cluster' or 'disable-cluster' url = `${API_BASE_URL}/${action}-cluster`; } const payload = { name: clusterName }; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`HTTP Error ${response.status}: ${errorBody}`); } console.log(`Cluster '${clusterName}' successfully ${action}d.`); listClusters(); } catch (error) { console.error(`Failed to ${action} cluster '${clusterName}':`, error); alert(`Failed to ${action} cluster '${clusterName}'. Check console for details.`); } } function disableCluster(clusterName, event) { event.stopPropagation(); if (confirm(`Are you sure you want to DISABLE cluster: ${clusterName}?`)) { toggleClusterStatus(clusterName, 'disable'); } } function enableCluster(clusterName, event) { event.stopPropagation(); if (confirm(`Are you sure you want to ENABLE cluster: ${clusterName}?`)) { toggleClusterStatus(clusterName, 'enable'); } } // Function to handle cluster removal function removeCluster(clusterName, event) { event.stopPropagation(); if (confirm(`⚠️ WARNING: Are you absolutely sure you want to PERMANENTLY REMOVE cluster: ${clusterName}? This action cannot be undone.`)) { toggleClusterStatus(clusterName, 'remove'); } } // ========================================================================= // LISTENER ENABLE/DISABLE/REMOVE LOGIC // ========================================================================= async function toggleListenerStatus(listenerName, action) { let url = ''; // Determine the API endpoint based on the action if (action === 'remove') { url = `${API_BASE_URL}/remove-listener`; } else { // 'enable-listener' or 'disable-listener' url = `${API_BASE_URL}/${action}-listener`; } const payload = { name: listenerName }; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`HTTP Error ${response.status}: ${errorBody}`); } console.log(`Listener '${listenerName}' successfully ${action}d.`); listListeners(); } catch (error) { console.error(`Failed to ${action} listener '${listenerName}':`, error); alert(`Failed to ${action} listener '${listenerName}'. Check console for details.`); } } function disableListener(listenerName, event) { event.stopPropagation(); if (confirm(`Are you sure you want to DISABLE listener: ${listenerName}?`)) { toggleListenerStatus(listenerName, 'disable'); } } function enableListener(listenerName, event) { event.stopPropagation(); if (confirm(`Are you sure you want to ENABLE listener: ${listenerName}?`)) { toggleListenerStatus(listenerName, 'enable'); } } // Function to handle listener removal function removeListener(listenerName, event) { event.stopPropagation(); if (confirm(`⚠️ WARNING: Are you absolutely sure you want to PERMANENTLY REMOVE listener: ${listenerName}? This action cannot be undone.`)) { toggleListenerStatus(listenerName, 'remove'); } } // ========================================================================= // CLUSTER LOGIC // ========================================================================= function getClusterEndpointDetails(cluster) { try { const endpoints = cluster.load_assignment?.endpoints; if (!endpoints?.length) return '<span style="color: gray;">(No Endpoints)</span>'; const lbEndpoints = endpoints[0].lb_endpoints; if (!lbEndpoints?.length) return '<span style="color: gray;">(No LB Endpoints)</span>'; const endpointObj = lbEndpoints[0].HostIdentifier.Endpoint; const address = endpointObj.address.Address.SocketAddress.address; const port = endpointObj.address.Address.SocketAddress.PortSpecifier.PortValue; const tls = cluster.transport_socket ? '<span class="tls-badge">TLS/SSL</span>' : ''; return `${address}:${port} ${tls}`; } catch { return '<span style="color: gray;">(Config Error)</span>'; } } async function listClusters() { const tableBody = document.getElementById('cluster-table-body'); if (!tableBody) { console.error("Could not find element with ID 'cluster-table-body'."); return; } tableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px;">Loading...</td></tr>'; try { const response = await fetch(`${API_BASE_URL}/list-clusters`); if (!response.ok) throw new Error(response.statusText); const clusterResponse = await response.json(); const allClusters = [ ...(clusterResponse.enabled || []).map(c => ({ ...c, status: 'Enabled', configData: c })), ...(clusterResponse.disabled || []).map(c => ({ ...c, status: 'Disabled', configData: c })) ]; if (!allClusters.length) { tableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--secondary-color);">No clusters found.</td></tr>'; // Clear store if no clusters are found configStore.clusters = {}; return; } // Store full configs in memory by name configStore.clusters = allClusters.reduce((acc, c) => { // Keep existing YAML if it exists, otherwise initialize const existingYaml = acc[c.name]?.yaml; acc[c.name] = { ...c.configData, yaml: existingYaml }; return acc; }, configStore.clusters); tableBody.innerHTML = ''; allClusters.forEach(cluster => { const row = tableBody.insertRow(); if (cluster.status === 'Disabled') row.classList.add('disabled-row'); let actionButtons = ''; if (cluster.status === 'Enabled') { actionButtons = `<button class="action-button disable" onclick="disableCluster('${cluster.name}', event)">Disable</button>`; } else { // When disabled, show Enable and Remove buttons actionButtons = ` <button class="action-button enable" onclick="enableCluster('${cluster.name}', event)">Enable</button> <button class="action-button remove" onclick="removeCluster('${cluster.name}', event)">Remove</button> `; } // Cluster Name Hyperlink (Updated to use new modal function) const clusterNameCell = row.insertCell(); clusterNameCell.innerHTML = `<a href="#" onclick="event.preventDefault(); showClusterConfigModal('${cluster.name}')"><span class="cluster-name">${cluster.name}</span></a>`; row.insertCell().textContent = cluster.status; row.insertCell().innerHTML = getClusterEndpointDetails(cluster); row.insertCell().textContent = `${cluster.connect_timeout?.seconds || 0}.${(cluster.connect_timeout?.nanos / 1e6 || 0).toFixed(0).padStart(3, '0')}s`; row.insertCell().innerHTML = actionButtons; }); } catch (error) { tableBody.innerHTML = `<tr><td colspan="5" class="error" style="text-align: center;">🚨 Cluster Error: ${error.message}</td></tr>`; console.error("Cluster Fetch/Parse Error:", error); } } // ========================================================================= // LISTENER LOGIC // ========================================================================= // This helper is no longer needed as the server will handle the lookup by domain name // function createFilterChainId(chain, index) { ... } function getDomainRouteTable(filterChains, listenerName) { if (!filterChains || filterChains.length === 0) return 'N/A'; // Store filter chains in memory for robust retrieval in showDomainConfig configStore.listeners[listenerName].filterChains = filterChains; const domainConfigs = filterChains.map((chain, index) => { const domains = chain.filter_chain_match?.server_names || ["(default)"]; const filters = chain.filters?.map(f => f.name.replace(/^envoy\.filters\./, '')) || []; const routeType = filters.some(f => f.includes('http_connection_manager')) ? 'HTTP' : 'TCP'; // Use the first domain name for the API call, or '(default)' const primaryDomainName = domains[0]; const allDomainsTitle = domains.join(', '); const modalTitle = `Filter Chain for Domains: ${allDomainsTitle} (${listenerName})`; return ` <div class="domain-config-item"> <div class="domain-header"> <div class="domain-name-link" data-title="${modalTitle}" data-listener-name="${listenerName}" data-chain-index="${index}" data-domain-name="${primaryDomainName}" onclick="showDomainConfig(this)"> ${allDomainsTitle} </div> <div class="filter-list"> ${filters.map(f => `<span class="filter-badge">${f}</span>`).join('')} </div> </div> <div class="route-type-display">${routeType} Route</div> </div> `; }); return `<div class="domain-config-list">${domainConfigs.join('')}</div>`; } function getListenerRowData(listener) { const socketAddr = listener.address?.Address?.SocketAddress; const address = socketAddr?.address || 'N/A'; const port = socketAddr?.PortSpecifier?.PortValue || 'N/A'; const addressString = `${address}:${port}`; const isTlsInspector = listener.listener_filters?.some(f => f.name.includes('tls_inspector')); const isTlsInChain = listener.filter_chains?.some(c => c.transport_socket); const tlsIndicator = (isTlsInspector || isTlsInChain) ? '<span class="tls-badge">TLS</span>' : ''; return { name: listener.name, address: `${addressString} ${tlsIndicator}`, domains: getDomainRouteTable(listener.filter_chains, listener.name), status: listener.status || 'Enabled', rawData: listener }; } async function listListeners() { const tableBody = document.getElementById('listener-table-body'); if (!tableBody) { console.error("Could not find element with ID 'listener-table-body'."); return; } tableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px;">Loading...</td></tr>'; try { const response = await fetch(`${API_BASE_URL}/list-listeners`); if (!response.ok) throw new Error(response.statusText); const listenerResponse = await response.json(); const allListeners = [ ...(listenerResponse.enabled || []).map(l => ({ ...l, status: 'Enabled', configData: l })), ...(listenerResponse.disabled || []).map(l => ({ ...l, status: 'Disabled', configData: l })) ]; if (!allListeners.length) { tableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--secondary-color);">No listeners found.</td></tr>'; // Clear store if no listeners are found configStore.listeners = {}; return; } // Store full configs in memory by name configStore.listeners = allListeners.reduce((acc, l) => { // Preserve existing YAML, and initialize filterChains const existing = acc[l.name] || {}; acc[l.name] = { ...l.configData, yaml: existing.yaml, filterChains: existing.filterChains // Reset or keep old list temporarily }; return acc; }, configStore.listeners); tableBody.innerHTML = ''; allListeners.forEach(listener => { // Call getDomainRouteTable, which now stores filterChains in memory const rowData = getListenerRowData(listener); const row = tableBody.insertRow(); if (rowData.status === 'Disabled') row.classList.add('disabled-row'); let actionButtons = ''; if (rowData.status === 'Enabled') { actionButtons = `<button class="action-button disable" onclick="disableListener('${listener.name}', event)">Disable</button>`; } else { // When disabled, show Enable and Remove buttons actionButtons = ` <button class="action-button enable" onclick="enableListener('${listener.name}', event)">Enable</button> <button class="action-button remove" onclick="removeListener('${listener.name}', event)">Remove</button> `; } // Listener Name Hyperlink (Updated to use new modal function) const listenerNameCell = row.insertCell(); listenerNameCell.innerHTML = `<a href="#" onclick="event.preventDefault(); showListenerConfigModal('${listener.name}')"><span class="listener-name">${rowData.name}</span></a>`; row.insertCell().textContent = rowData.status; row.insertCell().innerHTML = rowData.address; row.insertCell().innerHTML = rowData.domains; row.insertCell().innerHTML = actionButtons; }); } catch (error) { tableBody.innerHTML = `<tr><td colspan="5" class="error" style="text-align: center;">🚨 Listener Error: ${error.message}</td></tr>`; console.error("Listener Fetch/Parse Error:", error); } } // ========================================================================= // CONSISTENCY LOGIC // ========================================================================= const CONSISTENCY_POLL_INTERVAL = 5000; // 5 seconds let inconsistencyData = null; // Store the last fetched inconsistency data function showConsistencyModal() { if (!inconsistencyData || inconsistencyData.inconsistent === false) return; // Populate modal content const cacheOnly = inconsistencyData['cache-only'] || {}; const dbOnly = inconsistencyData['db-only'] || {}; document.getElementById('cache-only-count').textContent = Object.keys(cacheOnly).length; document.getElementById('cache-only-data').textContent = JSON.stringify(cacheOnly, null, 2); document.getElementById('db-only-count').textContent = Object.keys(dbOnly).length; document.getElementById('db-only-data').textContent = JSON.stringify(dbOnly, null, 2); document.getElementById('consistencyModal').style.display = 'block'; } function hideConsistencyModal() { document.getElementById('consistencyModal').style.display = 'none'; } async function checkConsistency() { const button = document.getElementById('consistency-button'); if (!button) return; // Capture current state before fetch const hasPreviousConflict = inconsistencyData !== null; const isCurrentlyError = button.classList.contains('error'); // Temporarily update the status unless it's currently an error (best to move this after fetch, or ensure it's always reset) // For now, keep it simple and ensure it is reset at the end. button.textContent = 'Checking...'; button.classList.add('loading'); button.classList.remove('consistent', 'inconsistent', 'error'); try { const response = await fetch(`${API_BASE_URL}/is-consistent`); if (!response.ok) throw new Error(response.statusText); const data = await response.json(); const consistencyStatus = data.consistent; const isNewConflict = consistencyStatus.inconsistent === true; // Determine if the *inconsistent* state has changed const stateChanged = isNewConflict !== hasPreviousConflict; // Set the final button text and remove the loading indicator button.classList.remove('loading'); if (isNewConflict) { button.textContent = '🚨 CONFLICT'; if (stateChanged || isCurrentlyError) { // Update DOM only if state flipped OR if recovering from error button.classList.remove('consistent', 'error'); button.classList.add('inconsistent'); button.disabled = false; inconsistencyData = consistencyStatus; } } else { button.textContent = '✅ Consistent'; if (stateChanged || isCurrentlyError) { // Update DOM only if state flipped OR if recovering from error button.classList.remove('inconsistent', 'error'); button.classList.add('consistent'); button.disabled = true; inconsistencyData = null; hideConsistencyModal(); } } } catch (error) { button.classList.remove('loading', 'consistent', 'inconsistent'); button.classList.add('error'); button.textContent = '❌ Error'; button.disabled = true; inconsistencyData = null; console.error("Consistency check failed:", error); } } /** * Core function to resolve consistency by making a POST call to a sync endpoint. * @param {string} action - 'flush' (Cache -> DB) or 'rollback' (DB -> Cache). */ async function resolveConsistency(action) { let url = ''; let message = ''; if (action === 'flush') { url = `${API_BASE_URL}/flush-to-db`; message = 'Flushing cache to DB...'; } else if (action === 'rollback') { url = `${API_BASE_URL}/load-from-db`; message = 'Rolling back cache from DB...'; } else { return; } if (!confirm(`Are you sure you want to perform the action: ${action.toUpperCase()}? This will overwrite the target configuration.`)) { return; } // Attempt to hide the modal if it's open (it's safe if it's closed) const modal = document.getElementById('consistencyModal'); if (modal) modal.style.display = 'none'; const button = document.getElementById('consistency-button'); // Temporarily update the status indicator during sync if (button) { button.textContent = message; button.classList.remove('consistent', 'inconsistent', 'error'); button.classList.add('loading'); button.disabled = true; } try { const response = await fetch(url, { method: 'POST' }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`HTTP Error ${response.status}: ${errorBody}`); } alert(`Sync successful via ${action.toUpperCase()}. Reloading data.`); loadAllData(); checkConsistency(); // Rerun check immediately to update status } catch (error) { alert(`Failed to sync via ${action}. Check console for details.`); console.error(`Sync operation (${action}) failed:`, error); checkConsistency(); // Restore status indicator } } function downloadYaml() { const yamlContent = document.getElementById('modal-yaml-content').textContent; if (!yamlContent || yamlContent.trim() === '') { alert("No YAML content available to download."); return; } // Use modal title as filename fallback const title = document.getElementById('modal-title').textContent .replace(/\s+/g, '_') .replace(/[^\w\-]/g, ''); const blob = new Blob([yamlContent], { type: 'text/yaml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = title ? `${title}.yaml` : 'config.yaml'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ========================================================================= // MANUAL SYNC HANDLERS // ========================================================================= function manualFlush() { resolveConsistency('flush'); } function manualRollback() { resolveConsistency('rollback'); } // ========================================================================= // COMBINED LOADER & POLLING // ========================================================================= function loadAllData() { listClusters(); listListeners(); } window.onload = () => { loadAllData(); setupModalTabs(); // Setup tab logic on load checkConsistency(); // Initial check setInterval(checkConsistency, CONSISTENCY_POLL_INTERVAL); // Start polling };