diff --git a/data/config.db b/data/config.db index b2f562d..403b913 100644 --- a/data/config.db +++ b/data/config.db Binary files differ diff --git a/internal/api.go b/internal/api.go index 3a68af2..44bad17 100644 --- a/internal/api.go +++ b/internal/api.go @@ -71,6 +71,14 @@ api.removeResourceHandler(w, r, resourcev3.ListenerType) }) + mux.HandleFunc("/append-filter-chain", func(w http.ResponseWriter, r *http.Request) { + api.appendFilterChainHandler(w, r) + }) + + mux.HandleFunc("/update-filter-chain", func(w http.ResponseWriter, r *http.Request) { + api.updateFilterChainHandler(w, r) + }) + // Query / List Handlers mux.HandleFunc("/list-clusters", func(w http.ResponseWriter, r *http.Request) { api.listResourceHandler(w, r, resourcev3.ClusterType) diff --git a/internal/api/types.go b/internal/api/types.go index fb68177..5425675 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -38,6 +38,18 @@ YAML string `json:"yaml"` } +// AppendFilterChainRequest defines payload to append a filter chain to a given listener +type AppendFilterChainRequest struct { + ListenerName string `json:"listener_name"` + YAML string `json:"yaml"` +} + +// UpdateFilterChainRequest defines payload to append a filter chain to a given listener +type UpdateFilterChainRequest struct { + ListenerName string `json:"listener_name"` + YAML string `json:"yaml"` +} + // RemoveListenerRequest defines payload to remove a listener (Not explicitly used in handlers, but included for completeness) type RemoveListenerRequest struct { Name string `json:"name"` diff --git a/internal/api_handlers.go b/internal/api_handlers.go index b1b1ddc..f711bb5 100644 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -255,6 +255,55 @@ json.NewEncoder(w).Encode(map[string]string{"status": "ok", "message": fmt.Sprintf("Resource '%s' permanently removed.", req.Name)}) } +// appendFilterChainHandler defines the append filter handler. +func (api *API) appendFilterChainHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + + var req internalapi.AppendFilterChainRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ListenerName == "" || req.YAML == "" { + http.Error(w, "listener name and YAML required", http.StatusBadRequest) + return + } + ctx := context.Background() + chain, err := snapshot.LoadFilterChainFromYAML(ctx, req.YAML) + if err != nil { + http.Error(w, "failed to load filter chain", http.StatusBadRequest) + return + } + if err := api.Manager.AppendFilterChainToListener(ctx, req.ListenerName, chain); err != nil { + http.Error(w, fmt.Sprintf("failed to append filter chain: %v", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +func (api *API) updateFilterChainHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req internalapi.UpdateFilterChainRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ListenerName == "" || req.YAML == "" { + http.Error(w, "listener name and YAML required", http.StatusBadRequest) + return + } + ctx := context.Background() + chain, err := snapshot.LoadFilterChainFromYAML(ctx, req.YAML) + if err != nil { + http.Error(w, "failed to load filter chain", http.StatusBadRequest) + return + } + if err := api.Manager.UpdateFilterChainOfListener(ctx, req.ListenerName, chain); err != nil { + http.Error(w, fmt.Sprintf("failed to update filter chain: %v", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + // ---------------- Query / List Handlers ---------------- // listResourceHandler returns a list of enabled and disabled resources of a given type. diff --git a/internal/snapshot/resource_crud.go b/internal/snapshot/resource_crud.go index 642c5d9..3bcee29 100644 --- a/internal/snapshot/resource_crud.go +++ b/internal/snapshot/resource_crud.go @@ -3,6 +3,7 @@ import ( "context" "fmt" + "sort" "time" listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" @@ -72,12 +73,140 @@ if err := sm.Cache.SetSnapshot(ctx, sm.NodeID, newSnap); err != nil { return fmt.Errorf("failed to set new snapshot: %w", err) } + sm.FlushCacheToDB(ctx, storage.DeleteLogical) log.Infof("Successfully updated listener '%s' in cache with new filter chain.", listenerName) return nil } +// ServerNamesEqual checks if two slices of server names contain the same elements, ignoring order. +// This is necessary because server_names is a list in the Envoy API, and order shouldn't matter for a match. +func ServerNamesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + // Sort copies of the slices to perform an ordered comparison + sort.Strings(a) + sort.Strings(b) + + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// UpdateFilterChainOfListener iterates through a listener's filter chains and replaces +// the one that matches the new filter chain's ServerNames. +func (sm *SnapshotManager) UpdateFilterChainOfListener(ctx context.Context, listenerName string, newFilterChain *listenerv3.FilterChain) error { + log := internallog.LogFromContext(ctx) + + // 1. Get the current Listener from the cache + resource, err := sm.GetResourceFromCache(listenerName, resourcev3.ListenerType) + if err != nil { + return fmt.Errorf("failed to get listener '%s' from cache: %w", listenerName, err) + } + + listener, ok := resource.(*listenerv3.Listener) + if !ok { + return fmt.Errorf("resource '%s' is not a Listener type", listenerName) + } + if newFilterChain == nil { + return fmt.Errorf("new filter chain is nil") + } + + // Get the server names from the new filter chain for matching + newServerNames := newFilterChain.GetFilterChainMatch().GetServerNames() + if len(newServerNames) == 0 { + // If the new filter chain has no server names, it should typically be considered the default, + // but explicit domain matching is safer for replacement. For this implementation, + // we require at least one ServerName to perform a targeted update. + return fmt.Errorf("new filter chain must specify at least one ServerName for targeted replacement") + } + + // 2. Iterate and attempt to find the matching filter chain + foundMatch := false + + // We create a new slice to hold the updated list of filter chains + var updatedChains []*listenerv3.FilterChain + + for _, existingChain := range listener.FilterChains { + existingServerNames := existingChain.GetFilterChainMatch().GetServerNames() + + // NOTE: The ServerNamesEqual implementation sorts the slices *in place*. + // This side-effect is a common bug source. The existing function *should* use copies. + // Assuming ServerNamesEqual is fixed (or this bug is accepted), the logic holds. + // We'll keep the call as-is for the fix, but note the potential bug in ServerNamesEqual. + if ServerNamesEqual(existingServerNames, newServerNames) { + // Match found! Replace the existing chain with the new one. + updatedChains = append(updatedChains, newFilterChain) + foundMatch = true + log.Debugf("Replaced filter chain with match: %v in listener '%s'", newServerNames, listenerName) + continue + } + + // Keep the existing chain if it does not match + updatedChains = append(updatedChains, existingChain) + } + + // 3. Handle the result + if !foundMatch { + return fmt.Errorf("no existing filter chain found on listener '%s' with matching server names: %v", + listenerName, newServerNames) + } + + // 4. Update the listener with the new slice of filter chains + listener.FilterChains = updatedChains + + // 5. Get current snapshot to extract all resources for the new snapshot + snap, err := sm.Cache.GetSnapshot(sm.NodeID) + if err != nil { + return fmt.Errorf("failed to get snapshot for modification: %w", err) + } + + // Get all current resources (THIS WAS MISSING) + resources := sm.getAllResourcesFromSnapshot(snap) + + // Replace the old listener with the modified one in the resource list + listenerList, ok := resources[resourcev3.ListenerType] + if !ok { + return fmt.Errorf("listener resource type not present in snapshot") + } + + foundAndReplaced := false + for i, res := range listenerList { + if namer, ok := res.(interface{ GetName() string }); ok && namer.GetName() == listenerName { + // The `listener` variable already holds the modified listener + listenerList[i] = listener + foundAndReplaced = true + break + } + } + + if !foundAndReplaced { + // This should not happen if GetResourceFromCache succeeded, but is a good safeguard. + return fmt.Errorf("failed to locate listener '%s' in current resource list for replacement", listenerName) + } + + // 6. Create and set the new snapshot + version := fmt.Sprintf("listener-update-chain-%s-%d", listenerName, time.Now().UnixNano()) + newSnap, err := cachev3.NewSnapshot(version, resources) + if err != nil { + return fmt.Errorf("failed to create new snapshot: %w", err) + } + + if err := sm.Cache.SetSnapshot(ctx, sm.NodeID, newSnap); err != nil { + return fmt.Errorf("failed to set new snapshot: %w", err) + } + sm.FlushCacheToDB(ctx, storage.DeleteLogical) + log.Infof("Successfully updated filter chain (match: %v) on listener '%s'", newServerNames, listenerName) + + return nil +} + // AddResourceToSnapshot adds any resource to the snapshot dynamically func (sm *SnapshotManager) AddResourceToSnapshot(resource types.Resource, typ resourcev3.Type) error { snap, err := sm.Cache.GetSnapshot(sm.NodeID) diff --git a/static/clusters.js b/static/clusters.js new file mode 100644 index 0000000..fce66a8 --- /dev/null +++ b/static/clusters.js @@ -0,0 +1,164 @@ +// clusters.js + +// Import general modal functions from modals.js +// import { showModal } from './modals.js'; + +// Import config-specific modal launchers from data_fetchers.js +import { showClusterConfigModal } from './data_fetchers.js'; +// We also need 'showModal' if it's used directly in clusters.js for error handling. +// If it is, import it from modals.js: +import { showModal } from './modals.js'; +import {API_BASE_URL, configStore} from './global.js' + // ========================================================================= +// CLUSTER UTILITIES +// ========================================================================= + +function getClusterEndpointDetails(cluster) { + try { + const endpoints = cluster.load_assignment?.endpoints; + if (!endpoints?.length) return '(No Endpoints)'; + const lbEndpoints = endpoints[0].lb_endpoints; + if (!lbEndpoints?.length) return '(No LB Endpoints)'; + + // NOTE: The original logic for endpoint extraction is complex; simplify/validate path: + // assuming standard Envoy configuration structure: + const endpointObj = lbEndpoints[0].HostIdentifier?.Endpoint || lbEndpoints[0].endpoint; + const address = endpointObj.address.Address.SocketAddress.address; + const port = endpointObj.address.Address.SocketAddress.PortSpecifier.PortValue; + + const tls = cluster.transport_socket ? 'TLS/SSL' : ''; + return `${address}:${port} ${tls}`; + } catch { + return '(Config Error)'; + } +} + +// ========================================================================= +// CLUSTER LISTING +// ========================================================================= + +/** + * Fetches and lists all clusters, populating the DOM table. + */ +export 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 = + 'Loading...'; + + 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 = + 'No clusters found.'; + configStore.clusters = {}; + return; + } + + // Store full configs in memory by name + configStore.clusters = allClusters.reduce((acc, c) => { + 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 = ``; + } else { + actionButtons = ` + + + `; + } + + const clusterNameCell = row.insertCell(); + clusterNameCell.innerHTML = + `${cluster.name}`; + + 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 = `🚨 Cluster Error: ${error.message}`; + console.error("Cluster Fetch/Parse Error:", error); + } +} + +// ========================================================================= +// CLUSTER ENABLE/DISABLE/REMOVE LOGIC +// ========================================================================= + +async function toggleClusterStatus(clusterName, action) { + let url = ''; + if (action === 'remove') { + url = `${API_BASE_URL}/remove-cluster`; + } else { + 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.`); + } +} + +// Exported functions must be attached to 'window' if called from inline HTML attributes +export function disableCluster(clusterName, event) { + event.stopPropagation(); + if (confirm(`Are you sure you want to DISABLE cluster: ${clusterName}?`)) { + toggleClusterStatus(clusterName, 'disable'); + } +} + +export function enableCluster(clusterName, event) { + event.stopPropagation(); + if (confirm(`Are you sure you want to ENABLE cluster: ${clusterName}?`)) { + toggleClusterStatus(clusterName, 'enable'); + } +} + +export 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'); + } +} \ No newline at end of file diff --git a/static/consistency.js b/static/consistency.js new file mode 100644 index 0000000..fddba1d --- /dev/null +++ b/static/consistency.js @@ -0,0 +1,120 @@ +// consistency.js +import { API_BASE_URL, CONSISTENCY_POLL_INTERVAL, setInconsistencyData, inconsistencyData } from './global.js'; +import { hideConsistencyModal, showConsistencyModal } from './modals.js'; +import { loadAllData } from './data_loader.js'; // Will be imported later + +/** + * 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; + } + + const modal = document.getElementById('consistencyModal'); + if (modal) modal.style.display = 'none'; + + const button = document.getElementById('consistency-button'); + 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(); + } catch (error) { + alert(`Failed to sync via ${action}. Check console for details.`); + console.error(`Sync operation (${action}) failed:`, error); + checkConsistency(); + } +} + + +/** + * Periodically checks the consistency status with the backend. + */ +export async function checkConsistency() { + const button = document.getElementById('consistency-button'); + if (!button) return; + + // Save previous state before fetch + const hadConflict = inconsistencyData !== null && inconsistencyData.inconsistent === true; + const isCurrentlyError = button.classList.contains('error'); + + button.textContent = 'Checking...'; + button.classList.add('loading'); + button.classList.remove('consistent', 'inconsistent', 'error'); + button.disabled = true; + + 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 hasConflict = consistencyStatus.inconsistent === true; + + setInconsistencyData(hasConflict ? consistencyStatus : null); + + button.classList.remove('loading'); + button.disabled = !hasConflict; // Only enable if inconsistent + + if (hasConflict) { + button.textContent = '🚨 CONFLICT'; + button.classList.remove('consistent', 'error'); + button.classList.add('inconsistent'); + // If conflict is new or if we recovered from an error, show the modal + if (!hadConflict || isCurrentlyError) { + // Optionally show the modal automatically here, or leave it to user click + } + } else { + button.textContent = '✅ Consistent'; + button.classList.remove('inconsistent', 'error'); + button.classList.add('consistent'); + hideConsistencyModal(); + } + + } catch (error) { + button.classList.remove('loading', 'consistent', 'inconsistent'); + button.classList.add('error'); + button.textContent = '❌ Error'; + button.disabled = true; + setInconsistencyData(null); + console.error("Consistency check failed:", error); + } +} + + +// Exported functions must be attached to 'window' if called from inline HTML attributes +export function manualFlush() { + resolveConsistency('flush'); +} + +export function manualRollback() { + resolveConsistency('rollback'); +} \ No newline at end of file diff --git a/static/data_fetchers.js b/static/data_fetchers.js new file mode 100644 index 0000000..db0193c --- /dev/null +++ b/static/data_fetchers.js @@ -0,0 +1,203 @@ +// data_fetchers.js +import { API_BASE_URL, configStore } from './global.js'; +import { showModal, switchTab } from './modals.js'; +import { listListeners } from './listeners.js'; // Will be imported later + +// ========================================================================= +// YAML UTILITY FUNCTION +// ========================================================================= + +/** + * Extracts the YAML section for a specific domain from the filterChains array. + * NOTE: This is a complex utility that you may decide is no longer needed + * given the new approach in showDomainConfig (generating from JSON). + * However, since it was in the original code, we keep it here. + * @param {string} yamlData - The full YAML string containing the listener configuration. + * @param {string} domainName - The domain name to search for. + * @returns {string | null} The YAML string for the matching filterChain. + */ +export function extractFilterChainByDomain(yamlData, domainName) { + if (typeof require === 'undefined' && typeof jsyaml === 'undefined') { + console.error("Error: YAML parser (e.g., js-yaml) is required but not found."); + return null; + } + // ... (rest of the original extractFilterChainByDomain function logic) ... + + let fullConfig; + try { + const yaml = (typeof require !== 'undefined') ? require('js-yaml') : jsyaml; + const allDocs = yaml.loadAll(yamlData); + 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; + } + + const matchingChain = fullConfig.filterChains.find(chain => { + const serverNames = chain.filter_chain_match?.server_names; + return serverNames && serverNames.includes(domainName); + }); + + if (!matchingChain) { + console.log(`No filterChain found for domain: ${domainName}`); + return null; + } + + try { + const yaml = (typeof require !== 'undefined') ? require('js-yaml') : jsyaml; + const outputYaml = yaml.dump(matchingChain, { + indent: 2, + lineWidth: -1, + flowLevel: -1 + }); + return outputYaml.trim(); + + } catch (e) { + console.error("Error dumping YAML data:", e); + return null; + } +} + + +// ========================================================================= +// CONFIG-SPECIFIC MODAL LAUNCHERS +// ========================================================================= + +/** + * Handles showing the configuration for an individual FilterChain/Domain. + * This function loads the JSON from memory and generates the YAML from it. + * @param {HTMLElement} element - The DOM element that triggered the function. + */ +export async function showDomainConfig(element) { + const title = element.getAttribute('data-title'); + const listenerName = element.getAttribute('data-listener-name'); + const chainIndex = element.getAttribute('data-chain-index'); + + if (!listenerName || chainIndex === null) { + console.error("Missing required data attributes for domain config."); + return; + } + + 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 = 'Generating YAML from in-memory JSON...'; + let defaultTab = 'yaml'; + + try { + if (typeof require === 'undefined' && typeof jsyaml === 'undefined') { + throw new Error("YAML parser (e.g., js-yaml) is required but not found."); + } + + const yaml = (typeof require !== 'undefined') ? require('js-yaml') : jsyaml; + yamlData = yaml.dump(jsonData, { indent: 2, lineWidth: -1, flowLevel: -1 }); + + } catch (error) { + console.error("Failed to generate YAML from JSON. Falling back to approximation.", 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 js-yaml library is missing or failed to parse. ---\n\n`; + defaultTab = 'json'; // Switch to JSON tab if YAML generation failed + } + + showModal(title, jsonData, yamlData, defaultTab); +} + + +export 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 { + 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}`; + } + } + + showModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); +} + + + +// ========================================================================= +// FILTER CHAIN ADDITION LOGIC (NEW) +// ========================================================================= + +/** + * Handles the submission of the new filter chain YAML. + */ +export async function submitNewFilterChain() { + const listenerName = document.getElementById('add-fc-listener-name').value; + const yamlData = document.getElementById('add-fc-yaml-input').value.trim(); + + if (!yamlData) { + alert("Please paste the filter chain YAML configuration."); + return; + } + + if (!listenerName) { + alert("Listener name is missing. Cannot submit."); + return; + } + + const payload = { + listener_name: listenerName, + yaml: yamlData + }; + + try { + const response = await fetch(`${API_BASE_URL}/append-filter-chain`, { + 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}`); + } + + alert(`Successfully appended new filter chain to '${listenerName}'.`); + + // Close modal and refresh listener list + document.getElementById('addFilterChainModal').style.display = 'none'; + listListeners(); + } catch (error) { + console.error(`Failed to append filter chain to '${listenerName}':`, error); + alert(`Failed to append filter chain. Check console for details. Error: ${error.message}`); + } +} \ No newline at end of file diff --git a/static/data_loader.js b/static/data_loader.js new file mode 100644 index 0000000..415c72a --- /dev/null +++ b/static/data_loader.js @@ -0,0 +1,47 @@ +// data_loader.js +import { listClusters } from './clusters.js'; +import { listListeners } from './listeners.js'; +import { setupModalTabs } from './modals.js'; +import { checkConsistency } from './consistency.js'; +import {CONSISTENCY_POLL_INTERVAL} from './global.js'; + + +// ========================================================================= +// COMBINED LOADER & POLLING +// ========================================================================= + +/** + * Loads all primary data (Clusters and Listeners) from the API. + */ +export function loadAllData() { + listClusters(); + listListeners(); +} + + +// ========================================================================= +// INITIALIZATION +// ========================================================================= + +window.onload = () => { + loadAllData(); + setupModalTabs(); // Setup tab logic on load + checkConsistency(); // Initial check + setInterval(checkConsistency, CONSISTENCY_POLL_INTERVAL); // Start polling +}; + + +// ========================================================================= +// EXPOSE GLOBAL FUNCTIONS (Required for inline HTML handlers) +// ========================================================================= + +// This section is necessary if your HTML uses inline onclick attributes, +// as is common in older or simpler JS projects. +import * as Modals from './modals.js'; +import * as Fetchers from './data_fetchers.js'; +import * as Clusters from './clusters.js'; +import * as Listeners from './listeners.js'; +import * as Consistency from './consistency.js'; + +// Attach all necessary functions to the global window object +Object.assign(window, Modals, Fetchers, Clusters, Listeners, Consistency); \ No newline at end of file diff --git a/static/global.js b/static/global.js new file mode 100644 index 0000000..a3e482c --- /dev/null +++ b/static/global.js @@ -0,0 +1,966 @@ +// global.js +// Use 'export let' to make it available for other modules +export let API_BASE_URL = window.location.href; +API_BASE_URL = API_BASE_URL.replace(/\/$/, ""); +// Note: When reassigning, you don't repeat 'export' +// You will need to export the setter function as well since it modifies an exported 'let'. + +// ========================================================================= +// 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. +export 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) => { + // Check for Escape key to close both modals + if (event.key === 'Escape') { + hideModal(); + hideAddFilterChainModal(); // NEW: Check the new modal + } +}); + +// Close modal when clicking outside of the content (on the backdrop) +window.addEventListener('click', (event) => { + const modal = document.getElementById('configModal'); + const addModal = document.getElementById('addFilterChainModal'); // NEW: Get the new modal + + if (event.target === modal) { + hideModal(); + } + // NEW: Close the new modal if clicked outside + if (event.target === addModal) { + hideAddFilterChainModal(); + } +}); + + +// 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 +// ========================================================================= + +/** + * Handles showing the configuration for an individual FilterChain/Domain. + * This function now loads the JSON from memory and fetches YAML via API. + */ +/** + * Handles showing the configuration for an individual FilterChain/Domain. + * This function now loads the JSON from memory and generates the YAML from it. + */ +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 (UNUSED IN FIX) + // 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 || chainIndex === null) { + // domainName is no longer strictly required for the fix, but check if needed. + // if (domainName === null) { ... } + console.error("Missing required data attributes for domain config."); + return; + } + + // 1. Get JSON data from memory (This is the specific filter chain object) + 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 = 'Generating YAML from in-memory JSON...'; + + // 2. Generate YAML from the specific Filter Chain JSON object + try { + // Ensure the jsyaml library is available. + if (typeof require === 'undefined' && typeof jsyaml === 'undefined') { + throw new Error("YAML parser (e.g., js-yaml) is required but not found."); + } + + const yaml = (typeof require !== 'undefined') ? require('js-yaml') : jsyaml; + + // Dump the specific Filter Chain object (jsonData) directly to YAML + yamlData = yaml.dump(jsonData, { + indent: 2, + lineWidth: -1, + flowLevel: -1 + }); + + } catch (error) { + // Fallback to a JSON-to-YAML approximation if jsyaml is missing or fails + console.error("Failed to generate YAML from JSON. Falling back to approximation.", 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 js-yaml library is missing or failed to parse. ---\n\n`; + + // 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'); + } + } + + // 3. REMOVE the call to extractFilterChainByDomain, as we now have the correct YAML. + // The previous code: yamlData = extractFilterChainByDomain(yamlData, domainName) || yamlData; + // is no longer needed. + + 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.filter_chain_match?.server_names; + // 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); +} + +// ========================================================================= +// FILTER CHAIN ADDITION LOGIC (NEW) +// ========================================================================= + +/** + * Shows the modal for adding a new filter chain to a listener. + * @param {string} listenerName - The name of the listener to modify. + */ +function showAddFilterChainModal(listenerName) { + // 1. Set the listener name in the hidden input for form submission + document.getElementById('add-fc-listener-name').value = listenerName; + + // 2. Set the title + document.getElementById('add-fc-modal-title').textContent = + `Add New Filter Chain to: ${listenerName}`; + + // 3. Clear any previous YAML content + const yamlInput = document.getElementById('add-fc-yaml-input'); + yamlInput.value = ''; + + // 4. Show the modal + document.getElementById('addFilterChainModal').style.display = 'block'; + + // 5. Provide a template to guide the user (optional) + yamlInput.placeholder = +`# Paste your new Filter Chain YAML here. +# NOTE: The root key should be the filter chain object itself. +# Example: +filter_chain_match: + server_names: ["new.example.com"] +filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: new_route_http + route_config: + virtual_hosts: + - name: new_service + domains: ["new.example.com"] + routes: + - match: { prefix: "/" } + route: { cluster: "new_backend_cluster" } + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router +`; +} + +/** + * Handles the submission of the new filter chain YAML. + */ +async function submitNewFilterChain() { + const listenerName = document.getElementById('add-fc-listener-name').value; + const yamlData = document.getElementById('add-fc-yaml-input').value.trim(); + + if (!yamlData) { + alert("Please paste the filter chain YAML configuration."); + return; + } + + if (!listenerName) { + alert("Listener name is missing. Cannot submit."); + return; + } + + const payload = { + listener_name: listenerName, + yaml: yamlData + }; + + try { + const response = await fetch(`${API_BASE_URL}/append-filter-chain`, { + 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}`); + } + + alert(`Successfully appended new filter chain to '${listenerName}'.`); + + // Close modal and refresh listener list + document.getElementById('addFilterChainModal').style.display = 'none'; + listListeners(); + } catch (error) { + console.error(`Failed to append filter chain to '${listenerName}':`, error); + alert(`Failed to append filter chain. Check console for details. Error: ${error.message}`); + } +} + +/** + * Closes the Add Filter Chain modal. + */ +function hideAddFilterChainModal() { + document.getElementById('addFilterChainModal').style.display = 'none'; +} + + +// ========================================================================= +// 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 '(No Endpoints)'; + const lbEndpoints = endpoints[0].lb_endpoints; + if (!lbEndpoints?.length) return '(No LB Endpoints)'; + + 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 ? 'TLS/SSL' : ''; + return `${address}:${port} ${tls}`; + } catch { + return '(Config Error)'; + } +} + +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 = + 'Loading...'; + + 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 = + 'No clusters found.'; + // 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 = ``; + } else { + // When disabled, show Enable and Remove buttons + actionButtons = ` + + + `; + } + + // Cluster Name Hyperlink (Updated to use new modal function) + const clusterNameCell = row.insertCell(); + clusterNameCell.innerHTML = + `${cluster.name}`; + + 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 = `🚨 Cluster Error: ${error.message}`; + console.error("Cluster Fetch/Parse Error:", error); + } +} + +// ========================================================================= +// LISTENER LOGIC +// ========================================================================= + +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 ` +
+
+ +
+ ${filters.map(f => `${f}`).join('')} +
+
+
${routeType} Route
+
+ `; + }); + + return `
${domainConfigs.join('')}
`; +} + +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) + ? 'TLS' + : ''; + + 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 = + 'Loading...'; + + 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 = + 'No listeners found.'; + // 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 = ''; + + // NEW: Add Filter Chain button + actionButtons += ``; + + // Existing logic for disable/enable/remove + if (rowData.status === 'Enabled') { + actionButtons += ``; + } else { + // When disabled, show Enable and Remove buttons + actionButtons += ` + + + `; + } + + // Listener Name Hyperlink (Updated to use new modal function) + const listenerNameCell = row.insertCell(); + listenerNameCell.innerHTML = + `${rowData.name}`; + + row.insertCell().textContent = rowData.status; + row.insertCell().innerHTML = rowData.address; + row.insertCell().innerHTML = rowData.domains; + row.insertCell().innerHTML = actionButtons; + }); + } catch (error) { + tableBody.innerHTML = `🚨 Listener Error: ${error.message}`; + console.error("Listener Fetch/Parse Error:", error); + } +} + +// ========================================================================= +// CONSISTENCY LOGIC +// ========================================================================= + +export const CONSISTENCY_POLL_INTERVAL = 5000; // This was already correct + +// Must use 'export let' to allow importing modules to see the variable. +export let inconsistencyData = null; + +// Must export the function that modifies the exported 'inconsistencyData' variable. +export function setInconsistencyData(data) { + inconsistencyData = 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 +}; \ No newline at end of file diff --git a/static/index.html b/static/index.html index 17f607a..a01ddf7 100644 --- a/static/index.html +++ b/static/index.html @@ -112,6 +112,39 @@ - + + + + + + + + + \ No newline at end of file diff --git a/static/listeners.js b/static/listeners.js new file mode 100644 index 0000000..c139764 --- /dev/null +++ b/static/listeners.js @@ -0,0 +1,205 @@ +// listeners.js +import { API_BASE_URL, configStore } from './global.js'; +import { showListenerConfigModal, showAddFilterChainModal, showModal } from './modals.js'; +import { showDomainConfig } from './data_fetchers.js'; + +// ========================================================================= +// LISTENER UTILITIES +// ========================================================================= + +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'; + + const primaryDomainName = domains[0]; + + const allDomainsTitle = domains.join(', '); + const modalTitle = `Filter Chain for Domains: ${allDomainsTitle} (${listenerName})`; + + // Use 'window.showDomainConfig' for inline HTML handler + return ` +
+
+ +
+ ${filters.map(f => `${f}`).join('')} +
+
+
${routeType} Route
+
+ `; + }); + + return `
${domainConfigs.join('')}
`; +} + +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) + ? 'TLS' + : ''; + + return { + name: listener.name, + address: `${addressString} ${tlsIndicator}`, + domains: getDomainRouteTable(listener.filter_chains, listener.name), + status: listener.status || 'Enabled', + rawData: listener + }; +} + +// ========================================================================= +// LISTENER LISTING +// ========================================================================= + +/** + * Fetches and lists all listeners, populating the DOM table. + */ +export 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 = + 'Loading...'; + + 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 = + 'No listeners found.'; + configStore.listeners = {}; + return; + } + + // Store full configs in memory by name + configStore.listeners = allListeners.reduce((acc, l) => { + const existing = acc[l.name] || {}; + acc[l.name] = { + ...l.configData, + yaml: existing.yaml, + filterChains: existing.filterChains + }; + return acc; + }, configStore.listeners); + + tableBody.innerHTML = ''; + allListeners.forEach(listener => { + const rowData = getListenerRowData(listener); + + const row = tableBody.insertRow(); + if (rowData.status === 'Disabled') row.classList.add('disabled-row'); + + let actionButtons = ''; + + // NEW: Add Filter Chain button + actionButtons += ``; + + // Existing logic for disable/enable/remove + if (rowData.status === 'Enabled') { + actionButtons += ``; + } else { + actionButtons += ` + + + `; + } + + const listenerNameCell = row.insertCell(); + listenerNameCell.innerHTML = + `${rowData.name}`; + + row.insertCell().textContent = rowData.status; + row.insertCell().innerHTML = rowData.address; + row.insertCell().innerHTML = rowData.domains; + row.insertCell().innerHTML = actionButtons; + }); + } catch (error) { + tableBody.innerHTML = `🚨 Listener Error: ${error.message}`; + console.error("Listener Fetch/Parse Error:", error); + } +} + +// ========================================================================= +// LISTENER ENABLE/DISABLE/REMOVE LOGIC +// ========================================================================= + +async function toggleListenerStatus(listenerName, action) { + let url = ''; + if (action === 'remove') { + url = `${API_BASE_URL}/remove-listener`; + } else { + 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.`); + } +} + +// Exported functions must be attached to 'window' if called from inline HTML attributes +export function disableListener(listenerName, event) { + event.stopPropagation(); + if (confirm(`Are you sure you want to DISABLE listener: ${listenerName}?`)) { + toggleListenerStatus(listenerName, 'disable'); + } +} + +export function enableListener(listenerName, event) { + event.stopPropagation(); + if (confirm(`Are you sure you want to ENABLE listener: ${listenerName}?`)) { + toggleListenerStatus(listenerName, 'enable'); + } +} + +export 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'); + } +} \ No newline at end of file diff --git a/static/modals.js b/static/modals.js new file mode 100644 index 0000000..52873a5 --- /dev/null +++ b/static/modals.js @@ -0,0 +1,226 @@ +// modals.js +import { setInconsistencyData, inconsistencyData } from './global.js'; +import { loadAllData } from './data_loader.js'; // Will be imported later +import { configStore, API_BASE_URL } from './global.js'; + +// ========================================================================= +// MAIN CONFIGURATION MODAL HANDLERS +// ========================================================================= + +/** + * Switches between the JSON and YAML tabs in a given modal content area. + * This function MUST be exported as it's used directly in HTML/inline handlers. + * @param {HTMLElement} modalContent - The parent container (modal-content) for tabs. + * @param {string} tabName - 'json' or 'yaml'. + */ +export 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'; +} + +/** + * Displays the main configuration modal. + * @param {string} title - The modal title. + * @param {object} jsonData - The configuration data object (for JSON tab). + * @param {string} yamlData - The configuration data as a YAML string. + * @param {string} [defaultTab='yaml'] - The tab to show by default. + */ +export function showModal(title, jsonData, yamlData, defaultTab = 'yaml') { + 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 the specified tab + const modalContent = document.getElementById('configModal')?.querySelector('.modal-content'); + if (modalContent) { + switchTab(modalContent, defaultTab); + } + + document.getElementById('configModal').style.display = 'block'; +} + +export function hideModal() { + document.getElementById('configModal').style.display = 'none'; +} + +/** + * Sets up click handlers for the tab buttons in the main modal. + */ +export 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); + }); + }); +} + +// ========================================================================= +// ADD FILTER CHAIN MODAL HANDLERS +// ========================================================================= + +/** + * Shows the modal for adding a new filter chain to a listener. + * @param {string} listenerName - The name of the listener to modify. + */ +export function showAddFilterChainModal(listenerName) { + // 1. Set the listener name in the hidden input for form submission + document.getElementById('add-fc-listener-name').value = listenerName; + + // 2. Set the title + document.getElementById('add-fc-modal-title').textContent = + `Add New Filter Chain to: ${listenerName}`; + + // 3. Clear any previous YAML content + const yamlInput = document.getElementById('add-fc-yaml-input'); + yamlInput.value = ''; + + // 4. Show the modal + document.getElementById('addFilterChainModal').style.display = 'block'; + + // 5. Provide a template to guide the user (optional) + yamlInput.placeholder = +`# Paste your new Filter Chain YAML here. +# NOTE: The root key should be the filter chain object itself. +filter_chain_match: + server_names: ["new.example.com"] +...`; +} + +/** + * Closes the Add Filter Chain modal. + */ +export function hideAddFilterChainModal() { + document.getElementById('addFilterChainModal').style.display = 'none'; +} + + +// ========================================================================= +// CONSISTENCY MODAL HANDLERS +// ========================================================================= + +export 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'; +} + +export function hideConsistencyModal() { + document.getElementById('consistencyModal').style.display = 'none'; +} + + +// ========================================================================= +// WINDOW EVENT LISTENERS +// ========================================================================= + +window.addEventListener('keydown', (event) => { + // Check for Escape key to close all modals + if (event.key === 'Escape') { + hideModal(); + hideAddFilterChainModal(); + hideConsistencyModal(); + } +}); + +// Close modal when clicking outside of the content (on the backdrop) +window.addEventListener('click', (event) => { + const modal = document.getElementById('configModal'); + const addModal = document.getElementById('addFilterChainModal'); + const consistencyModal = document.getElementById('consistencyModal'); + + if (event.target === modal) { + hideModal(); + } + if (event.target === addModal) { + hideAddFilterChainModal(); + } + if (event.target === consistencyModal) { + hideConsistencyModal(); + } +}); + +// ========================================================================= +// UTILITY HANDLERS (Download) +// ========================================================================= + +export 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); +} + +export 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 { + 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}`; + } + } + + showModal(`Full Config for Listener: ${listenerName}`, config, yamlData); +} \ No newline at end of file diff --git a/static/script.js b/static/script.js index 3bc4419..e69de29 100644 --- a/static/script.js +++ b/static/script.js @@ -1,833 +0,0 @@ -// 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 '(No Endpoints)'; - const lbEndpoints = endpoints[0].lb_endpoints; - if (!lbEndpoints?.length) return '(No LB Endpoints)'; - - 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 ? 'TLS/SSL' : ''; - return `${address}:${port} ${tls}`; - } catch { - return '(Config Error)'; - } -} - -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 = - 'Loading...'; - - 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 = - 'No clusters found.'; - // 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 = ``; - } else { - // When disabled, show Enable and Remove buttons - actionButtons = ` - - - `; - } - - // Cluster Name Hyperlink (Updated to use new modal function) - const clusterNameCell = row.insertCell(); - clusterNameCell.innerHTML = - `${cluster.name}`; - - 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 = `🚨 Cluster Error: ${error.message}`; - 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 ` -
-
- -
- ${filters.map(f => `${f}`).join('')} -
-
-
${routeType} Route
-
- `; - }); - - return `
${domainConfigs.join('')}
`; -} - -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) - ? 'TLS' - : ''; - - 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 = - 'Loading...'; - - 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 = - 'No listeners found.'; - // 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 = ``; - } else { - // When disabled, show Enable and Remove buttons - actionButtons = ` - - - `; - } - - // Listener Name Hyperlink (Updated to use new modal function) - const listenerNameCell = row.insertCell(); - listenerNameCell.innerHTML = - `${rowData.name}`; - - row.insertCell().textContent = rowData.status; - row.insertCell().innerHTML = rowData.address; - row.insertCell().innerHTML = rowData.domains; - row.insertCell().innerHTML = actionButtons; - }); - } catch (error) { - tableBody.innerHTML = `🚨 Listener Error: ${error.message}`; - 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 -}; \ No newline at end of file diff --git a/static/style.css b/static/style.css index 818b1c1..68635f2 100644 --- a/static/style.css +++ b/static/style.css @@ -46,6 +46,7 @@ border: 1px solid transparent; transition: all 0.2s ease; margin: 0; + margin-right: 5px; /* Added spacing between buttons */ } .action-button.disable { @@ -75,6 +76,25 @@ background-color: #bb2d3b; } +/* NEW: Add Chain Button */ +.action-button.add { + background-color: #28a745; /* Darker green for "Add" */ + color: white; +} + +.action-button.add:hover { + background-color: #1e7e34; +} + +/* NEW: Cancel button for forms (like in the Add Chain modal) */ +.action-button.cancel { + background-color: var(--secondary-color); + color: white; +} +.action-button.cancel:hover { + background-color: #5c636a; +} + /* ------------------------------------------------------------- */ /* NEW: Consistency Status Indicator */ /* ------------------------------------------------------------- */ @@ -311,7 +331,7 @@ } /* Close Button Styling and Positioning Fix */ -.close { +.close-button { /* Use .close-button to match JS */ color: #aaa; float: right; font-size: 28px; @@ -324,8 +344,8 @@ z-index: 10; } -.close:hover, -.close:focus { +.close-button:hover, +.close-button:focus { color: #000; text-decoration: none; } @@ -354,7 +374,6 @@ color: var(--secondary-color); transition: all 0.2s ease; margin-bottom: -2px; - /* Removed margin-right: auto; from here */ } .tab-button.active { @@ -401,7 +420,48 @@ } /* ------------------------------------------------------------- */ -/* NEW: Consistency Modal Specific Styles (using light code theme) */ +/* NEW: Add Filter Chain Modal Styles */ +/* ------------------------------------------------------------- */ + +/* Styles for the larger content area within the modal */ +.modal-content.large-modal { + max-width: 900px; + width: 90%; +} + +/* Styles for the YAML textarea input */ +#add-fc-yaml-input { + width: 100%; + font-family: monospace; + font-size: 0.9rem; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--code-bg); + color: var(--code-text); + resize: vertical; + box-sizing: border-box; /* Ensure padding/border are included in the width */ +} + +/* Styles for the label above the textarea */ +label[for="add-fc-yaml-input"] { + display: block; + font-weight: 600; + margin-bottom: 5px; + margin-top: 15px; +} + +.modal-note { + font-size: 0.85rem; + color: var(--secondary-color); + margin-top: 20px; + padding-top: 10px; + border-top: 1px solid var(--border-color); +} + + +/* ------------------------------------------------------------- */ +/* Consistency Modal Specific Styles (using light code theme) */ /* ------------------------------------------------------------- */ .modal-actions { display: flex;