// 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
};