Newer
Older
EnvoyControlPlane / static / tools / filter_chain.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Envoy Filter Chain Configurator</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
    <style>
        /* Custom styles for better readability and structure */
        body { 
            font-family: "Inter", sans-serif; 
            background-color: #f0f2f5; /* Lighter background matching dashboard */
            color: #333; /* Darker text for readability */
        }
        #app-container { 
            max-width: 1000px; /* Slightly wider to match dashboard feel */
            background-color: #ffffff;
            /* Removed shadow-2xl and rounded-xl for a flatter, more integrated look */
            /* padding stays generous for content spacing */
            border-radius: 0.5rem; /* Subtle rounding like the table in the screenshot */
            box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); /* Subtle shadow like the table */
        }
        
        #yaml-output {
            background-color: #1f2937; /* Darker background for code */
            color: #e5e7eb; /* Lighter text for code */
            border: 1px solid #4b5563; /* Darker border for contrast */
            font-family: 'Consolas', 'Monaco', monospace;
            line-height: 1.4;
            min-height: 400px; 
            padding: 15px;
            box-shadow: none; /* No extra shadow on code output */
            border-radius: 0.5rem; /* Consistent border radius */
        }
        #yaml-output:disabled {
            cursor: default;
        }

        .optional-fields {
            transition: all 0.3s ease-in-out;
            border: 1px solid #e5e7eb; /* Subtle border for optional fields */
            background-color: #f9fafb; /* Very light background */
            border-radius: 0.5rem;
        }
        
        /* Disable styling for Simple Mode inputs when Advanced Mode is on */
        .simple-disabled {
            opacity: 0.5; /* Slightly less opaque */
            pointer-events: none;
            transition: opacity 0.3s ease;
        }

        /* Custom toggle switch styling for Advanced Mode */
        #advanced-mode-toggle:checked ~ .block {
            background-color: #16a34a; /* A shade of green matching common success states */
        }
        #advanced-mode-toggle:checked ~ .dot {
            transform: translateX(100%);
        }

        /* Hide the button since generation is now real-time */
        #generate-button {
            display: none; 
        }
        /* Style adjustments for better form readability */
        input[type="text"], textarea { 
            border: 1px solid #d1d5db; /* Lighter, more subtle border */
            background-color: #ffffff;
            transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 
            border-radius: 0.375rem; /* Slightly smaller border radius for inputs */
        }
        input[type="checkbox"] {
            border-color: #d1d5db;
            color: #2563eb; /* Blue checkbox accent */
        }
        input[type="text"]:focus, textarea:focus {
            border-color: #3b82f6; /* Blue focus ring */
            box-shadow: 0 0 0 1px #3b82f6; /* Subtle focus ring */
        }

        /* Adjustments for button styles */
        .btn-primary {
            background-color: #2563eb; /* Blue from dashboard button */
            color: #ffffff;
            font-weight: 500; /* Medium font weight */
            border-radius: 0.375rem;
            padding: 0.625rem 1rem; /* Adjust padding */
        }
        .btn-primary:hover {
            background-color: #1d4ed8; /* Darker blue on hover */
        }

        .btn-secondary-outline {
            background-color: transparent;
            color: #4b5563; /* Darker grey text */
            border: 1px solid #d1d5db;
            font-weight: 500;
            border-radius: 0.375rem;
            padding: 0.625rem 1rem;
        }
        .btn-secondary-outline:hover {
            background-color: #f3f4f6;
            border-color: #9ca3af;
        }

        .remove-domain-btn {
            color: #9ca3af; /* Grey for delete icon */
            border: none;
            background-color: transparent;
        }
        .remove-domain-btn:hover {
            color: #ef4444; /* Red on hover */
        }
    </style>
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    borderRadius: {
                        'xl': '0.75rem',
                    }
                }
            }
        }
    </script>
</head>
<body class="p-4 sm:p-8">
    <div id="app-container" class="mx-auto bg-white p-6 md:p-10">
        <h1 class="text-2xl font-semibold text-gray-800 mb-6 pb-4 border-b border-gray-200 flex items-center">
            Envoy Filter Chain Composer 
            <span class="ml-2 text-gray-400 text-base font-normal">
                <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
                    <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
                </svg>
            </span>
        </h1>
        <p class="text-gray-600 mb-8">Configure your Envoy filter chain for a Listener using simple inputs or switch to advanced editing mode.</p>

        <div class="space-y-6" id="simple-mode-inputs">
            <div class="grid md:grid-cols-2 gap-6">
                <div class="md:col-span-2">
                    <label class="block text-sm font-medium text-gray-700 mb-2">Service Domains (Server Names):</label>
                    <div id="domain-inputs-container" class="space-y-3">
                        <div class="flex items-center space-x-2 domain-group">
                            <input type="text" id="domain" value="audio.jerxie.com" data-is-primary="true" class="domain-input block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border" placeholder="Primary Domain (e.g., api.example.com)">
                            <button type="button" class="remove-domain-btn p-2 opacity-0 cursor-default" disabled>
                                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
                            </button>
                        </div>

                    </div>
                    <button type="button" id="add-domain-button" class="mt-3 btn-secondary-outline flex items-center" onclick="addDomainInput()">
                        <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
                        Add Domain
                    </button>
                </div>
            </div>
            
            <div>
                <label for="service-name" class="block text-sm font-medium text-gray-700">Cluster Name (e.g., _pcb_server):</label>
                <input type="text" id="service-name" value="_nas_audio" class="block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border">
            </div>

            <div class="flex flex-col sm:flex-row gap-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
                <div class="flex items-center">
                    <input id="enable-websocket" type="checkbox" class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
                    <label for="enable-websocket" class="ml-2 block text-sm font-medium text-gray-700 select-none">Enable WebSocket Upgrade</label>
                </div>
                <div class="flex items-center">
                    <input id="enable-tls" type="checkbox" checked class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
                    <label for="enable-tls" class="ml-2 block text-sm font-medium text-gray-700 select-none">Enable TLS (HTTPS)</label>
                </div>
            </div>

            <div id="tls-fields" class="optional-fields p-4">
                <h3 class="text-base font-semibold text-gray-800 mb-3">TLS Certificate Paths</h3>
                <p class="text-xs text-gray-500 mb-4">The 'DOMAIN_PLACEHOLDER' will be replaced by your **Primary Domain**.</p>
                
                <div class="space-y-4">
                    <div>
                        <label for="cert-chain" class="block text-sm font-medium text-gray-700">Certificate Chain Path:</label>
                        <input type="text" id="cert-chain" value="/etc/certs/downstream/DOMAIN_PLACEHOLDER/fullchain.pem" class="block w-full rounded-lg border-gray-300 shadow-sm p-2 border text-sm">
                    </div>
                    <div>
                        <label for="private-key" class="block text-sm font-medium text-gray-700">Private Key Path:</label>
                        <input type="text" id="private-key" value="/etc/certs/downstream/DOMAIN_PLACEHOLDER/privkey.pem" class="block w-full rounded-lg border-gray-300 shadow-sm p-2 border text-sm">
                    </div>
                </div>
            </div>
        </div>

        <div class="mt-8 flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
            <h2 class="text-base font-semibold text-gray-800">Advanced Editing Mode</h2>
            <label for="advanced-mode-toggle" class="flex items-center cursor-pointer select-none">
                <span class="text-sm font-medium text-gray-600 mr-3">Manual YAML Override</span>
                <div class="relative">
                    <input type="checkbox" id="advanced-mode-toggle" class="sr-only">
                    <div class="block bg-gray-400 w-12 h-6 rounded-full transition duration-300"></div>
                    <div class="dot absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition transform duration-300"></div>
                </div>
            </label>
        </div>

        <div class="flex justify-between items-center mt-10 mb-4">
            <h2 class="text-xl font-semibold text-gray-800">Composed YAML Output</h2>
            <button id="copy-yaml-button" class="btn-primary flex items-center" onclick="copyYamlToClipboard()">
                <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2.25A2.25 2.25 0 0121 7.25v2.25m-2.25 0v5.5A2.25 2.25 0 0118.75 17H5.25A2.25 2.25 0 013 14.75v-5.5m1.5-3.75h5.5" ></path></svg>
                Copy YAML
            </button>
        </div>
        <textarea id="yaml-output" rows="25" class="block w-full rounded-lg border-gray-300 p-4 font-mono text-sm resize-none"></textarea>
    </div>

    <script>
        // Global flag and cache for simple mode inputs
        let isAdvancedMode = false;
        let simpleInputs = []; 

        // --- Domain Management Functions ---
        function getDomainInputs() {
            // Get all inputs with the class 'domain-input', filter out empty values, and trim
            return Array.from(document.querySelectorAll('#domain-inputs-container .domain-input'))
                .map(input => input.value.trim())
                .filter(value => value.length > 0);
        }

        function addDomainInput() {
            const container = document.getElementById('domain-inputs-container');
            const newDomainGroup = document.createElement('div');
            newDomainGroup.className = 'flex items-center space-x-2 domain-group';
            newDomainGroup.innerHTML = `
                <input type="text" value="" class="domain-input block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border" placeholder="Additional Domain (e.g., api.local)">
                <button type="button" onclick="removeDomainInput(this)" class="remove-domain-btn p-2 transition duration-150">
                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
                </button>
            `;
            const newInput = newDomainGroup.querySelector('.domain-input');
            container.appendChild(newDomainGroup);
            
            // Attach the event listener to the new input
            newInput.addEventListener('input', generateYaml);
            
            // Re-populate the simpleInputs list for toggleAdvancedMode to work fully
            populateSimpleInputs();

            // Focus the new input
            newInput.focus();
            generateYaml(); // Regenerate YAML after adding a new input
        }

        function removeDomainInput(buttonElement) {
            const domainGroup = buttonElement.closest('.domain-group');
            if (domainGroup) {
                domainGroup.remove();
                populateSimpleInputs(); // Update the simpleInputs list
                generateYaml(); // Regenerate YAML after removing an input
            }
        }
        
        // --- Utility Function: Copy to Clipboard ---
        async function copyYamlToClipboard() {
            const outputElement = document.getElementById('yaml-output');
            const copyButton = document.getElementById('copy-yaml-button');
            const originalText = copyButton.innerHTML; // Get full HTML including SVG
            
            try {
                // Use the modern clipboard API
                await navigator.clipboard.writeText(outputElement.value);
                
                // Provide visual feedback
                copyButton.innerHTML = `<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Copied!`;
                copyButton.classList.add('bg-green-600', 'hover:bg-green-700');
                copyButton.classList.remove('bg-blue-600', 'hover:bg-blue-700');
                
                // Revert button text after a delay
                setTimeout(() => {
                    copyButton.innerHTML = originalText;
                    copyButton.classList.remove('bg-green-600', 'hover:bg-green-700');
                    copyButton.classList.add('bg-blue-600', 'hover:bg-blue-700');
                }, 2000);
            } catch (err) {
                console.error('Failed to copy YAML: ', err);
                alert('Failed to copy YAML. Your browser might require user interaction (e.g., focusing the textarea) for clipboard access.');
            }
        }
        
        // --- Core YAML Generation Logic ---
        function generateYaml() {
            // Stop automatic generation if user is manually editing
            if (isAdvancedMode) {
                return;
            }

            const allDomains = getDomainInputs();
            const primaryDomain = allDomains[0]; // Used for cert path substitution
            const serviceName = document.getElementById('service-name').value.trim();
            const enableWebsocket = document.getElementById('enable-websocket').checked;
            const enableTls = document.getElementById('enable-tls').checked;
            const certChain = document.getElementById('cert-chain').value.trim();
            const privateKey = document.getElementById('private-key').value.trim();
            const outputElement = document.getElementById('yaml-output');
            
            outputElement.value = 'Generating...';

            if (!primaryDomain || !serviceName) {
                outputElement.value = 'ERROR: At least one Domain and Cluster Name are required.';
                return;
            }
            
            // Generate the list of YAML entries for domains
            // serverNames: starts at 2 spaces, so domains need 4 spaces
            const serverNamesEntries = allDomains.map(d => `  - ${d}`).join('\n');
            // virtualHosts: domains: starts at 10 spaces, so domains need 12 spaces
            const routeDomainEntries = allDomains.map(d => `            - ${d}`).join('\n');
            
            // --- 1. Base YAML Structure (Rooted at filterChainMatch/filters) ---
            let yaml = `filterChainMatch:
  serverNames:
${serverNamesEntries}
filters:
  - name: envoy.filters.network.http_connection_manager
    typedConfig:
      '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
      httpFilters:
        - name: envoy.filters.http.router
          typedConfig:
            '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
      routeConfig:
        virtualHosts:
          - domains:
${routeDomainEntries}
            name: ${serviceName.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase()}_host
            routes:
              - match:
                  prefix: /
                route:
                  cluster: ${serviceName}
                  timeout: 0s
      statPrefix: ingress_http`;

            // --- 2. WebSocket Configuration (Optional) ---
            if (enableWebsocket) {
                // upgradeConfigs: starts at 6 spaces
                yaml += `
      upgradeConfigs:
        - upgradeType: websocket`;
            }
            
            // --- 3. TLS Configuration (Optional) ---
            if (enableTls) {
                const resolvedCertChain = certChain.replace(/DOMAIN_PLACEHOLDER/g, primaryDomain);
                const resolvedPrivateKey = privateKey.replace(/DOMAIN_PLACEHOLDER/g, primaryDomain);

                if (!resolvedCertChain || !resolvedPrivateKey) {
                    outputElement.value = 'ERROR: TLS is enabled, but Certificate Chain and Private Key paths are required.';
                    return;
                }
                
                // transportSocket: is at root level
                yaml += `
transportSocket:
  name: envoy.transport_sockets.tls
  typedConfig:
    '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
    commonTlsContext:
      tlsCertificates:
        - certificateChain:
            filename: ${resolvedCertChain}
          privateKey:
            filename: ${resolvedPrivateKey}`;
            }

            // Final Output
            outputElement.value = yaml.trim();
        }


        // --- Advanced Mode Toggle Logic ---
        function toggleAdvancedMode(isTogglingOn) {
            const yamlOutput = document.getElementById('yaml-output');
            const simpleInputsContainer = document.getElementById('simple-mode-inputs');
            
            isAdvancedMode = isTogglingOn;

            if (isTogglingOn) {
                // SWITCHING TO ADVANCED MODE (Editable Output, Disabled Inputs)
                
                // 1. Disable simple inputs visually and functionally
                simpleInputsContainer.classList.add('simple-disabled');
                simpleInputs.forEach(input => { input.disabled = true; });
                
                // 2. Enable the YAML output area for editing
                yamlOutput.disabled = false;
                yamlOutput.focus();

            } else {
                // SWITCHING TO SIMPLE MODE (Read-only Output, Enabled Inputs)
                
                // 1. Enable simple inputs visually and functionally
                simpleInputsContainer.classList.remove('simple-disabled');
                simpleInputs.forEach(input => { input.disabled = false; });
                
                // 2. Disable the YAML output area (it becomes the display)
                yamlOutput.disabled = true;
                
                // 3. IMPORTANT: Regenerate YAML immediately to overwrite any manual changes
                generateYaml(); 
            }
        }
        
        // --- Populate Inputs for Advanced Mode Toggle ---
        function populateSimpleInputs() {
            // Re-select all relevant inputs every time a domain is added/removed
            simpleInputs = [
                ...Array.from(document.querySelectorAll('#domain-inputs-container .domain-input')),
                document.getElementById('service-name'),
                document.getElementById('enable-websocket'),
                document.getElementById('enable-tls'),
                document.getElementById('cert-chain'),
                document.getElementById('private-key')
            ].filter(Boolean); // Filter out any null elements
        }


        // --- Event Listeners for UI interaction ---
        document.addEventListener('DOMContentLoaded', function() {
            const advancedModeToggle = document.getElementById('advanced-mode-toggle');
            const tlsFields = document.getElementById('tls-fields');
            const enableTlsCheckbox = document.getElementById('enable-tls');
            
            // 1. Initial population of the simpleInputs list
            populateSimpleInputs();

            // 2. Attach generateYaml to all initial inputs (domain inputs handled dynamically)
            simpleInputs.forEach(input => {
                // Skip domain-inputs here, as their listeners are managed in addDomainInput
                if (!input.classList.contains('domain-input')) {
                    const eventType = (input.type === 'checkbox' || input.tagName === 'SELECT') ? 'change' : 'input';
                    input.addEventListener(eventType, generateYaml);
                }
            });

                        // 5. Update domain placeholders in TLS paths (only tied to the primary domain)
            const updateTlsPathsAndGenerateYaml = () => {
                const primaryDomainInput = document.getElementById('domain');
                if (primaryDomainInput) { // Ensure primaryDomainInput exists
                    const newDomain = primaryDomainInput.value.trim();
                    document.getElementById('cert-chain').value = `/etc/certs/downstream/${newDomain}/fullchain.pem`;
                    document.getElementById('private-key').value = `/etc/certs/downstream/${newDomain}/privkey.pem`;
                }
                generateYaml(); 
            };
            
            // Call this once on load to set initial paths and generate YAML
            updateTlsPathsAndGenerateYaml();

            // Re-attach listeners for initial domain inputs specifically
            document.querySelectorAll('#domain-inputs-container .domain-input').forEach(input => {
                input.addEventListener('input', updateTlsPathsAndGenerateYaml);
            });
            
            // 3. Advanced Mode Toggle Listener
            advancedModeToggle.addEventListener('change', function() {
                toggleAdvancedMode(this.checked);
            });

            // 4. TLS Fields Visibility Listener
            enableTlsCheckbox.addEventListener('change', function() {
                tlsFields.classList.toggle('hidden', !this.checked);
                generateYaml();
            });
            // Initial setup for Advanced Mode (default to simple)
            toggleAdvancedMode(false); 
        });
    </script>
</body>
</html>