Last active
September 25, 2025 07:48
-
-
Save TheLustriVA/12e34c83de3c3749fa453d3088fe949d to your computer and use it in GitHub Desktop.
A quick one-page web app for converting Docker Compose files to Portainer and Yacht templates
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Compose Converter</title> | |
| <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://unpkg.com/[email protected]/dist/aos.css" rel="stylesheet"> | |
| <script src="https://unpkg.com/[email protected]/dist/aos.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js-yaml.min.js"></script> | |
| </head> | |
| <body class="bg-gray-900 text-gray-100 min-h-screen"> | |
| <!-- Header --> | |
| <header class="bg-gradient-to-r from-blue-800 to-purple-800 text-white shadow-lg"> | |
| <div class="container mx-auto px-4 py-6"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex items-center space-x-3"> | |
| <i data-feather="layers" class="w-8 h-8"></i> | |
| <h1 class="text-2xl font-bold">Compose Converter</h1> | |
| </div> | |
| <p class="text-blue-200">Convert Docker Compose to Portainer & Yacht Templates</p> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="container mx-auto px-4 py-8 max-w-7xl"> | |
| <!-- Input Section --> | |
| <section class="bg-gray-800 rounded-xl shadow-lg p-6 mb-8" data-aos="fade-up"> | |
| <h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center"> | |
| <i data-feather="upload-cloud" class="w-5 h-5 mr-2 text-blue-600"></i> | |
| Input Docker Compose | |
| </h2> | |
| <!-- File Upload --> | |
| <div class="mb-4"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Upload docker-compose.yml file</label> | |
| <div class="flex items-center justify-center w-full"> | |
| <label for="file-upload" class="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-600 border-dashed rounded-lg cursor-pointer bg-gray-700 hover:bg-gray-600 transition-colors"> | |
| <div class="flex flex-col items-center justify-center pt-5 pb-6"> | |
| <i data-feather="upload" class="w-8 h-8 mb-3 text-gray-300"></i> | |
| <p class="mb-2 text-sm text-gray-300">Click to upload or drag and drop</p> | |
| <p class="text-xs text-gray-400">YAML files only</p> | |
| </div> | |
| <input id="file-upload" type="file" class="hidden" accept=".yml,.yaml" /> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Or Divider --> | |
| <div class="flex items-center my-6"> | |
| <div class="flex-grow border-t border-gray-600"></div> | |
| <span class="flex-shrink mx-4 text-gray-400">or</span> | |
| <div class="flex-grow border-t border-gray-600"></div> | |
| </div> | |
| <!-- Text Input --> | |
| <div class="mb-4"> | |
| <label class="block text-sm font-medium text-gray-300 mb-2">Paste docker-compose.yml content</label> | |
| <textarea | |
| id="compose-input" | |
| class="w-full h-64 px-4 py-2 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm bg-gray-700 text-gray-100" | |
| placeholder="version: '3.8' | |
| services: | |
| web: | |
| image: nginx:alpine | |
| ports: | |
| - '80:80'" | |
| ></textarea> | |
| </div> | |
| <!-- Action Buttons --> | |
| <div class="flex flex-wrap gap-4"> | |
| <button | |
| id="validate-btn" | |
| class="px-6 py-2 bg-green-700 text-white rounded-lg hover:bg-green-600 transition-colors flex items-center" | |
| > | |
| <i data-feather="check-circle" class="w-4 h-4 mr-2"></i> | |
| Validate YAML | |
| </button> | |
| <button | |
| id="convert-btn" | |
| class="px-6 py-2 bg-blue-700 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center disabled:bg-gray-600 disabled:cursor-not-allowed" | |
| disabled | |
| > | |
| <i data-feather="refresh-cw" class="w-4 h-4 mr-2"></i> | |
| Convert Templates | |
| </button> | |
| </div> | |
| <!-- Validation Status --> | |
| <div id="validation-status" class="mt-4 hidden"></div> | |
| </section> | |
| <!-- Output Section --> | |
| <section class="grid md:grid-cols-2 gap-8" data-aos="fade-up" data-aos-delay="200"> | |
| <!-- Portainer Template --> | |
| <div class="bg-gray-800 rounded-xl shadow-lg p-6"> | |
| <h2 class="text-xl font-semibold text-gray-100 mb-4 flex items-center"> | |
| <i data-feather="anchor" class="w-5 h-5 mr-2 text-blue-600"></i> | |
| Portainer Template | |
| </h2> | |
| <textarea | |
| id="portainer-output" | |
| class="w-full h-64 px-4 py-2 border border-gray-600 rounded-lg font-mono text-sm bg-gray-700 text-gray-100" | |
| readonly | |
| placeholder="Generated Portainer template will appear here..." | |
| ></textarea> | |
| <div class="flex gap-2 mt-4"> | |
| <button | |
| id="copy-portainer" | |
| class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors flex items-center justify-center" | |
| > | |
| <i data-feather="copy" class="w-4 h-4 mr-2"></i> | |
| Copy | |
| </button> | |
| <button | |
| id="download-portainer" | |
| class="flex-1 px-4 py-2 bg-blue-700 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center justify-center" | |
| > | |
| <i data-feather="download" class="w-4 h-4 mr-2"></i> | |
| Download | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Yacht Template --> | |
| <div class="bg-gray-800 rounded-xl shadow-lg p-6"> | |
| <h2 class="text-xl font-semibold text-gray-100 mb-4 flex items-center"> | |
| <i data-feather="ship" class="w-5 h-5 mr-2 text-purple-600"></i> | |
| Yacht Template | |
| </h2> | |
| <textarea | |
| id="yacht-output" | |
| class="w-full h-64 px-4 py-2 border border-gray-600 rounded-lg font-mono text-sm bg-gray-700 text-gray-100" | |
| readonly | |
| placeholder="Generated Yacht template will appear here..." | |
| ></textarea> | |
| <div class="flex gap-2 mt-4"> | |
| <button | |
| id="copy-yacht" | |
| class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors flex items-center justify-center" | |
| > | |
| <i data-feather="copy" class="w-4 h-4 mr-2"></i> | |
| Copy | |
| </button> | |
| <button | |
| id="download-yacht" | |
| class="flex-1 px-4 py-2 bg-purple-700 text-white rounded-lg hover:bg-purple-600 transition-colors flex items-center justify-center" | |
| > | |
| <i data-feather="download" class="w-4 h-4 mr-2"></i> | |
| Download | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Footer --> | |
| <footer class="bg-gray-950 text-white mt-16 py-8"> | |
| <div class="container mx-auto px-4 text-center items-center"> | |
| <p class="text-gray-300">Made with <i data-feather="heart" class="w-4 h-4 inline text-red-500"></i> for the Docker Community</p> | |
| <p class="text-gray-300">Code available as a <a href="https://gist.github.com/TheLustriVA/12e34c83de3c3749fa453d3088fe949d">Gist</a> | |
| </p> | |
| <p class="text-gray-300"><a href="https://gist.github.com/TheLustriVA/12e34c83de3c3749fa453d3088fe949d">Compose Converter</a> by <a href="https://github.com/TheLustriVa">Kieran Bicheno</a> is marked <a href="https://creativecommons.org/publicdomain/zero/1.0/">CC0 1.0</a></p> | |
| <p class="text-gray-300"> | |
| <img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width: 1em;max-height:1em;margin-left: .2em;"> | |
| <img src="https://mirrors.creativecommons.org/presskit/icons/zero.svg" alt="" style="max-width: 1em;max-height:1em;margin-left: .2em;"> | |
| </p> | |
| </div> | |
| </footer> | |
| <script> | |
| // Initialize Feather Icons | |
| feather.replace(); | |
| // Initialize AOS | |
| AOS.init({ | |
| duration: 800, | |
| once: true | |
| }); | |
| // State | |
| let isValidYAML = false; | |
| let composeData = null; | |
| // DOM Elements | |
| const fileUpload = document.getElementById('file-upload'); | |
| const composeInput = document.getElementById('compose-input'); | |
| const validateBtn = document.getElementById('validate-btn'); | |
| const convertBtn = document.getElementById('convert-btn'); | |
| const validationStatus = document.getElementById('validation-status'); | |
| const portainerOutput = document.getElementById('portainer-output'); | |
| const yachtOutput = document.getElementById('yacht-output'); | |
| const copyPortainer = document.getElementById('copy-portainer'); | |
| const downloadPortainer = document.getElementById('download-portainer'); | |
| const copyYacht = document.getElementById('copy-yacht'); | |
| const downloadYacht = document.getElementById('download-yacht'); | |
| // File Upload Handler | |
| fileUpload.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (file && (file.name.endsWith('.yml') || file.name.endsWith('.yaml'))) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| composeInput.value = e.target.result; | |
| validateYAML(); | |
| }; | |
| reader.readAsText(file); | |
| } else { | |
| showValidationStatus('Please select a valid YAML file', 'error'); | |
| } | |
| }); | |
| // Validate YAML | |
| function validateYAML() { | |
| try { | |
| const yaml = composeInput.value.trim(); | |
| if (!yaml) { | |
| throw new Error('YAML content is empty'); | |
| } | |
| composeData = jsyaml.load(yaml); | |
| if (!composeData.services) { | |
| throw new Error('No services found in docker-compose.yml'); | |
| } | |
| isValidYAML = true; | |
| convertBtn.disabled = false; | |
| showValidationStatus('✅ Valid docker-compose.yml', 'success'); | |
| return true; | |
| } catch (error) { | |
| isValidYAML = false; | |
| convertBtn.disabled = true; | |
| showValidationStatus(`❌ ${error.message}`, 'error'); | |
| return false; | |
| } | |
| } | |
| // Show Validation Status | |
| function showValidationStatus(message, type) { | |
| validationStatus.innerHTML = `<div class="p-3 rounded-lg ${ | |
| type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' | |
| }">${message}</div>`; | |
| validationStatus.classList.remove('hidden'); | |
| } | |
| // Validate Button Click | |
| validateBtn.addEventListener('click', () => { | |
| validateYAML(); | |
| }); | |
| // Convert Button Click | |
| convertBtn.addEventListener('click', () => { | |
| if (!isValidYAML) return; | |
| const portainerTemplate = generatePortainerTemplate(composeData); | |
| const yachtTemplate = generateYachtTemplate(composeData); | |
| portainerOutput.value = JSON.stringify(portainerTemplate, null, 2); | |
| yachtOutput.value = JSON.stringify(yachtTemplate, null, 2); | |
| }); | |
| // Generate Portainer Template | |
| function generatePortainerTemplate(data) { | |
| const services = data.services || {}; | |
| const template = { | |
| version: "3.8", | |
| templates: [] | |
| }; | |
| Object.entries(services).forEach(([name, service]) => { | |
| const templateItem = { | |
| type: 1, | |
| title: name, | |
| name: name, | |
| description: `Service: ${name}`, | |
| logo: "https://portainer.github.io/logos/nginx.png", | |
| image: service.image || "", | |
| ports: [], | |
| volumes: [], | |
| env: [] | |
| }; | |
| // Handle ports | |
| if (service.ports) { | |
| templateItem.ports = service.ports.map(port => { | |
| const portStr = port.toString(); | |
| const [host, container] = portStr.split(':'); | |
| return { | |
| host: host || container, | |
| container: container || host | |
| }; | |
| }); | |
| } | |
| // Handle volumes | |
| if (service.volumes) { | |
| templateItem.volumes = service.volumes.map(vol => ({ | |
| container: vol.toString(), | |
| bind: vol.toString() | |
| })); | |
| } | |
| // Handle environment | |
| if (service.environment) { | |
| if (Array.isArray(service.environment)) { | |
| templateItem.env = service.environment.map(env => { | |
| const [name, value] = env.split('='); | |
| return { name, value: value || "" }; | |
| }); | |
| } else if (typeof service.environment === 'object') { | |
| templateItem.env = Object.entries(service.environment).map(([name, value]) => ({ | |
| name, | |
| value: value || "" | |
| })); | |
| } | |
| } | |
| template.templates.push(templateItem); | |
| }); | |
| return template; | |
| } | |
| // Generate Yacht Template | |
| function generateYachtTemplate(data) { | |
| const services = data.services || {}; | |
| const template = { | |
| name: "Converted Stack", | |
| description: "Converted from docker-compose.yml", | |
| version: "1.0", | |
| services: [] | |
| }; | |
| Object.entries(services).forEach(([name, service]) => { | |
| const serviceItem = { | |
| name: name, | |
| image: service.image || "", | |
| ports: [], | |
| volumes: [], | |
| environment: {}, | |
| restart: service.restart || "unless-stopped" | |
| }; | |
| // Handle ports | |
| if (service.ports) { | |
| serviceItem.ports = service.ports.map(port => port.toString()); | |
| } | |
| // Handle volumes | |
| if (service.volumes) { | |
| serviceItem.volumes = service.volumes.map(vol => vol.toString()); | |
| } | |
| // Handle environment | |
| if (service.environment) { | |
| if (Array.isArray(service.environment)) { | |
| service.environment.forEach(env => { | |
| const [key, value] = env.split('='); | |
| serviceItem.environment[key] = value || ""; | |
| }); | |
| } else if (typeof service.environment === 'object') { | |
| serviceItem.environment = service.environment; | |
| } | |
| } | |
| template.services.push(serviceItem); | |
| }); | |
| return template; | |
| } | |
| // Copy to Clipboard | |
| function copyToClipboard(text, button) { | |
| navigator.clipboard.writeText(text).then(() => { | |
| const originalHTML = button.innerHTML; | |
| button.innerHTML = '<i data-feather="check" class="w-4 h-4 mr-2"></i>Copied!'; | |
| button.classList.add('bg-green-600'); | |
| setTimeout(() => { | |
| button.innerHTML = originalHTML; | |
| button.classList.remove('bg-green-600'); | |
| feather.replace(); | |
| }, 2000); | |
| }); | |
| } | |
| copyPortainer.addEventListener('click', () => { | |
| copyToClipboard(portainerOutput.value, copyPortainer); | |
| }); | |
| copyYacht.addEventListener('click', () => { | |
| copyToClipboard(yachtOutput.value, copyYacht); | |
| }); | |
| // Download JSON | |
| function downloadJSON(content, filename) { | |
| const blob = new Blob([content], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| downloadPortainer.addEventListener('click', () => { | |
| downloadJSON(portainerOutput.value, 'portainer-template.json'); | |
| }); | |
| downloadYacht.addEventListener('click', () => { | |
| downloadJSON(yachtOutput.value, 'yacht-template.json'); | |
| }); | |
| // Auto-validate on input | |
| let validationTimeout; | |
| composeInput.addEventListener('input', () => { | |
| clearTimeout(validationTimeout); | |
| validationTimeout = setTimeout(() => { | |
| if (composeInput.value.trim()) { | |
| validateYAML(); | |
| } | |
| }, 1000); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment