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 = `Disable `;
+ } else {
+ actionButtons = `
+ Enable
+ Remove
+ `;
+ }
+
+ 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 = `Disable `;
+ } else {
+ // When disabled, show Enable and Remove buttons
+ actionButtons = `
+ Enable
+ Remove
+ `;
+ }
+
+ // 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 `
+
+
+
+ ${allDomainsTitle}
+
+
+ ${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 += `Add Chain `;
+
+ // Existing logic for disable/enable/remove
+ if (rowData.status === 'Enabled') {
+ actionButtons += `Disable `;
+ } else {
+ // When disabled, show Enable and Remove buttons
+ actionButtons += `
+ Enable
+ Remove
+ `;
+ }
+
+ // 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 @@
-
+
+
+
+
+
Paste the YAML configuration for the new filter chain below and submit.
+
+
+
+
+
+
+
+
+
+
+
+