<!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>