diff --git a/README.md b/README.md index 6bf21d0..31790b0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ The built-in editor provides multiple tabs for managing your compose stack: - **Docker Compose Integration** - Installs the Docker Compose CLI plugin (v5 by default) and manages stacks on your unRAID server. - **Web UI Management** - Create, edit, and manage Compose stacks directly from the unRAID dashboard. +- **Import Wizard** - Convert existing Docker Manager containers into a Compose stack using a guided flow. - **Stack Operations** - Start, stop, restart, update, pull/build, and remove stacks with one click (supports profiles and override files). - **Context Menu** - Rich context menu on every stack icon with state-aware actions (see [Context Menu](#context-menu) below). - **Container Context Menu** - Right-click individual containers to open a WebUI, console, or logs; start, stop, pause, resume, or restart individual containers without touching the whole stack. @@ -265,6 +266,7 @@ For detailed guides, see the [docs](docs/) folder: - [Getting Started](docs/getting-started.md) - [User Guide](docs/user-guide.md) +- [Import Wizard](docs/import-wizard.md) - [Configuration](docs/configuration.md) - [Profiles](docs/profiles.md) diff --git a/docs/import-wizard.md b/docs/import-wizard.md new file mode 100644 index 0000000..2c4377a --- /dev/null +++ b/docs/import-wizard.md @@ -0,0 +1,128 @@ +# Import Wizard + +The Import Wizard lets you convert existing Docker Manager containers into a Compose Manager stack with a guided 5-stage workflow. + +## Where to Find It + +1. Go to **Docker -> Compose**. +2. Click **Import from Docker Manager**. +3. Select one or more existing containers to import. + +## What the Wizard Imports + +For each selected container, Compose Manager reads Docker metadata and converts it into Compose service definitions, including: + +- Image name +- Container name +- Ports +- Environment variables +- Volumes and bind mounts +- Labels (including Unraid-specific WebUI/icon labels) +- Existing healthcheck (if present) +- Network mode and network attachments + +The wizard can also auto-detect likely healthchecks for common services when no healthcheck exists. + +## 5-Stage Workflow + +### Stage 1: Select Containers + +- Shows import candidates from Docker Manager. +- You can select individual containers or use **Select All**. +- At least one container is required to continue. + +### Stage 2: Select Options + +Configure stack-wide options: + +- **Stack Name** (required) +- **Create Stack Network** and network name +- **External Networks** to make available during import +- Post-import behavior: + - **Stop original containers** + - **Remove original containers** + - **Start imported stack** + +Notes: + +- If **Remove original containers** is enabled, **Stop original containers** is enforced. +- The stack name is sanitized to a safe folder-style name for preview. + +### Stage 3: Configure Containers + +Per-service configuration: + +- Container name (must be unique and non-empty) +- Network mode (default/bridge/host/none) +- Network attachments (stack network and selected external networks) +- Healthcheck command and timing settings +- Read-only port view with conflict indicators + +Validation behavior: + +- Duplicate or empty container names disable **Next**. +- Host port conflicts are highlighted so you can review them before import. + +### Stage 4: Configure Dependencies + +Define Compose `depends_on` relationships between imported services: + +- Add dependencies service-by-service +- Choose condition: + - `service_started` + - `service_healthy` (available only when target service has a healthcheck) + +Safety checks: + +- Dependency cycles are detected and block progress. +- A calculated startup order preview is shown when possible. + +### Stage 5: Review & Import + +Compose Manager generates the configuration and presents a final review screen with: + +- `compose.yaml` (with parse validation) +- `.env` content (when needed) +- Override content for labels/icons (when needed) +- Validation result and any parse errors +- Import summary (services, networks, healthchecks, dependencies) + +Click **Import** to write files and complete the transfer. + +## What Happens on Import + +When you confirm import: + +1. A new stack is created in the configured projects directory. +2. Generated files are written to that stack folder. +3. Selected source containers can be stopped and/or removed (based on your options). +4. The stack opens in the editor. +5. If selected, Compose Manager runs **Compose Up** for the imported stack. + +## Tips and Best Practices + +- Start with related containers that should live in the same stack. +- Review network mode carefully: `host`/`none` intentionally limit network attachments. +- Keep healthchecks when possible; they improve dependency sequencing and startup reliability. +- Resolve any port conflicts before starting the new stack if services must run in parallel. +- If this is a production workload, take a backup before removing original containers. + +## Troubleshooting + +### No containers appear in Stage 1 + +- Verify containers exist in Docker Manager and are readable by the plugin. + +### Cannot continue from Stage 3 + +- Fix duplicate or empty container names. + +### Cannot continue from Stage 4 + +- Remove circular dependencies until the cycle warning is gone. + +### Import fails at the final step + +- Re-check stack name validity. +- Re-open the wizard and re-import if source containers changed during the session. +- Check Unraid syslog/plugin logs for server-side error details. diff --git a/phpstan.neon b/phpstan.neon index 7c0251e..8b0e78f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -23,9 +23,14 @@ parameters: # Platform classes (Docker management) - messages: - "#DockerClient#" + - "#DockerTemplates#" - "#DockerUpdate#" - "#DockerUtil#" identifier: class.notFound + # Platform functions (Docker management) + - messages: + - "#xmlToCommand#" + identifier: function.notFound # Global variables from platform/defines.php - message: "#Variable \\$\\w+ might not be defined#" identifier: variable.undefined diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index e28d34a..e0d520b 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -229,8 +229,9 @@ function compose_manager_cpu_spec_count($cpuSpec) padding-right: 20px } - .dropdown-menu { - z-index: 100 !important; + .compose-modal-overlay .dropdown-menu, + #compose_stacks .dropdown-menu { + z-index: 100; } /* CPU & Memory load display (matches Docker manager usage-disk style) */ @@ -2208,6 +2209,1304 @@ function(data) { + // ========================================================================= + // Import Wizard — 5-Stage Flow (Basic / Advanced) + // ========================================================================= + + var importWizard = { + stage: 1, + advancedMode: (localStorage.getItem('compose_import_wizard_mode') === 'advanced'), + containerIds: [], + containers: [], // raw container list from getDockerContainersForImport + services: {}, // per-service metadata from generateImportData + portConflicts: [], + availableNetworks: [], + config: { + stackName: '', + stackDesc: '', + stopContainers: true, + removeContainers: true, + startStack: true, + containerNames: {}, + networkConfig: { + stackNetwork: { enabled: true, name: '' }, + externalNetworks: [], + perService: {} + }, + healthchecks: {}, + dependencies: {} + }, + result: null // finalizeImportCompose response + }; + + var iwDepIdCounter = 0; + + var WIZARD_STAGES_ALL = [ + { num: 1, label: 'Select Containers' }, + { num: 2, label: 'Stack Options' }, + { num: 3, label: 'Configure Services' }, + { num: 4, label: 'Dependencies', advancedOnly: true }, + { num: 5, label: 'Review & Import' } + ]; + + function iwGetVisibleStages() { + if (importWizard.advancedMode) return WIZARD_STAGES_ALL; + return WIZARD_STAGES_ALL.filter(function(s) { return !s.advancedOnly; }); + } + + function renderWizardStepper(activeStage) { + var stages = iwGetVisibleStages(); + var html = '
'; + stages.forEach(function(s, i) { + var cls = 'import-wizard-step'; + if (s.num < activeStage) cls += ' completed'; + else if (s.num === activeStage) cls += ' active'; + if (i > 0) html += '
'; + var displayNum = i + 1; + var icon = s.num < activeStage ? '' : displayNum; + html += '
' + icon + '' + composeEscapeHtml(s.label) + '
'; + }); + html += '
'; + return html; + } + + function iwSetAdvancedMode(isAdvanced) { + importWizard.advancedMode = !!isAdvanced; + localStorage.setItem('compose_import_wizard_mode', importWizard.advancedMode ? 'advanced' : 'basic'); + + var stage = importWizard.stage; + var visible = iwGetVisibleStages(); + var visibleNums = visible.map(function(s) { return s.num; }); + + // If current stage is hidden in the new mode, jump to nearest visible stage + if (visibleNums.indexOf(stage) === -1) { + // Find the closest visible stage (prefer previous, fall back to next) + var prev = null, next = null; + for (var i = 0; i < visibleNums.length; i++) { + if (visibleNums[i] < stage) prev = visibleNums[i]; + if (visibleNums[i] > stage && next === null) next = visibleNums[i]; + } + var target = prev || next || 1; + iwNavigateToStage(target); + return; + } + + // Current stage is still visible — re-render it to update fields + stepper + if (stage === 1) { $('#compose-import-stepper').html(renderWizardStepper(1)); } + else if (stage === 2) { iwSaveStage2(); renderImportStage2(); } + else if (stage === 3) { iwSaveStage3(); renderImportStage3(); } + else if (stage === 4) { renderImportStage4(); } + else if (stage === 5) { $('#compose-import-stepper').html(renderWizardStepper(5)); } + } + + function iwNavigateToStage(stageNum) { + if (stageNum === 1) renderImportStage1(); + else if (stageNum === 2) renderImportStage2(); + else if (stageNum === 3) renderImportStage3(); + else if (stageNum === 4) renderImportStage4(); + else if (stageNum === 5) renderImportStageReview(); + } + + function wizardFooter(opts) { + var left = ''; + var right = ''; + if (opts.back) { + left = ''; + } + if (opts.cancel !== false) { + right += ''; + } + if (opts.next) { + var disabledAttr = opts.nextDisabled ? ' disabled' : ''; + var label = opts.nextLabel || 'Next'; + right += ''; + } + if (opts.action) { + var actionDisabledAttr = opts.actionDisabled ? ' disabled' : ''; + right += ''; + } + return '
' + + '
' + left + '
' + right + '
'; + } + + function importFromDockerManager() { + // Reset wizard state + importWizard = { + stage: 1, + advancedMode: (localStorage.getItem('compose_import_wizard_mode') === 'advanced'), + containerIds: [], containers: [], services: {}, + portConflicts: [], availableNetworks: [], + config: { + stackName: '', stackDesc: '', + stopContainers: true, removeContainers: true, startStack: true, + containerNames: {}, + networkConfig: { stackNetwork: { enabled: true, name: '' }, externalNetworks: [], perService: {} }, + healthchecks: {}, dependencies: {} + }, + result: null + }; + iwDepIdCounter = 0; + + var modalHtml = '
' + + '
'; + + var existing = document.getElementById('compose-import-modal-overlay'); + if (existing) existing.remove(); + document.body.insertAdjacentHTML('beforeend', modalHtml); + + window.closeComposeImportModal = function() { + var overlay = document.getElementById('compose-import-modal-overlay'); + if (overlay) overlay.remove(); + }; + + // Initialize Compose-style toggle switch after DOM insertion + $('#iw-mode-toggle').switchButton({ + labels_placement: 'left', + on_label: 'Advanced View', + off_label: 'Basic View', + checked: importWizard.advancedMode + }); + $('#iw-mode-toggle').change(function() { + iwSetAdvancedMode($(this).is(':checked')); + }); + + renderImportStage1(); + } + + // ── Stage 1: Select Containers ────────────────────────────────────────── + function renderImportStage1() { + importWizard.stage = 1; + $('#compose-import-stepper').html(renderWizardStepper(1)); + $('#compose-import-modal-body').html('Loading Docker Manager containers...'); + $('#compose-import-modal-footer').html(''); + + $.post(caURL, {action: 'getDockerContainersForImport'}, function(data) { + var response; + try { response = JSON.parse(data); } catch (e) { response = { result:'error', message: 'Invalid response' }; } + if (response.result !== 'success') { + $('#compose-import-modal-body').html('
Failed to load containers.
'); + return; + } + importWizard.containers = response.containers || []; + if (!importWizard.containers.length) { + $('#compose-import-modal-body').html('
No Docker Manager containers found.
'); + return; + } + + var selectAll = ''; + + var rows = importWizard.containers.map(function(ct) { + var iconHtml = ct.Icon ? '' : ''; + var urlHtml = (ct.Url && /^https?:\/\//i.test(ct.Url)) ? + 'Link' : ''; + return '' + + '' + + '' + composeEscapeHtml(ct.Name) + '' + + '' + composeEscapeHtml(ct.Image) + '' + + '' + composeEscapeHtml(ct.Status) + '' + + '' + iconHtml + '' + + '' + urlHtml + ''; + }).join(''); + var table = selectAll + '
' + + '' + + '' + rows + '
NameImageStatusIconWebUI
'; + $('#compose-import-modal-body').html(table); + $('#compose-import-modal-footer').html(wizardFooter({ + next: 'importWizardStage1Next()', + nextLabel: 'Next' + })); + + // Select-all toggle + $('#cm-import-select-all').on('change', function() { + $('.cm-import-container').prop('checked', this.checked); + }); + }).fail(function() { + $('#compose-import-modal-body').html('
Failed to contact server.
'); + }); + } + + function importWizardStage1Next() { + var selected = []; + $('.cm-import-container:checked').each(function() { selected.push(this.value); }); + if (!selected.length) { + swal('No containers selected', 'Please select at least one container to import.', 'warning'); + return; + } + importWizard.containerIds = selected; + + // Fetch rich import data + $('#compose-import-modal-body').html('
Analyzing containers...
'); + $('#compose-import-modal-footer').html(''); + + $.post(caURL, {action: 'generateImportData', containerIds: JSON.stringify(selected)}, function(data) { + var response; + try { response = JSON.parse(data); } catch (e) { response = { result:'error', message: 'Invalid response' }; } + if (response.result !== 'success') { + $('#compose-import-modal-body').html('
' + composeEscapeHtml(response.message || 'Failed to analyze containers') + '
'); + $('#compose-import-modal-footer').html(wizardFooter({ back: 'renderImportStage1()' })); + return; + } + importWizard.services = response.services || {}; + importWizard.portConflicts = response.portConflicts || []; + importWizard.availableNetworks = response.networks || []; + + // Initialize defaults for wizard config from service data + var svcKeys = Object.keys(importWizard.services); + svcKeys.forEach(function(key) { + var meta = importWizard.services[key]; + if (!importWizard.config.containerNames[key]) { + importWizard.config.containerNames[key] = meta.containerName || key; + } + if (!importWizard.config.networkConfig.perService[key]) { + importWizard.config.networkConfig.perService[key] = { + networkMode: meta.networkMode || 'default', + attachStackNet: !meta.networkMode || meta.networkMode === 'default', + externalNets: meta.networks || [], + ipv4Addresses: meta.networkIPs || {} + }; + } + // Auto-seed discovered service networks into global externalNetworks list + var svcNets = meta.networks || []; + svcNets.forEach(function(netName) { + if (importWizard.config.networkConfig.externalNetworks.indexOf(netName) === -1) { + importWizard.config.networkConfig.externalNetworks.push(netName); + } + }); + // Initialize healthcheck from service data + if (!importWizard.config.healthchecks.hasOwnProperty(key)) { + var hcData = null; + if (meta.healthcheck) { + hcData = meta.healthcheck; + } else if (meta.guessedHealthcheck) { + hcData = meta.guessedHealthcheck; + } + if (hcData) { + // Store original test type (CMD/CMD-SHELL) and command text for comparison on save + var origType = 'CMD-SHELL'; + var origCmd = ''; + if (Array.isArray(hcData.test) && hcData.test.length > 0) { + origType = hcData.test[0]; + origCmd = hcData.test.slice(1).join(' '); + } else if (typeof hcData.test === 'string') { + origCmd = hcData.test; + } + hcData.__originalTestType = origType; + hcData.__originalTestCmd = origCmd; + importWizard.config.healthchecks[key] = hcData; + } else { + importWizard.config.healthchecks[key] = null; + } + } + if (!importWizard.config.dependencies[key]) { + importWizard.config.dependencies[key] = []; + } + }); + + renderImportStage2(); + }).fail(function() { + $('#compose-import-modal-body').html('
Failed to contact server.
'); + $('#compose-import-modal-footer').html(wizardFooter({ back: 'renderImportStage1()' })); + }); + } + + // ── Stage 2: Select Options ───────────────────────────────────────────── + function renderImportStage2() { + importWizard.stage = 2; + $('#compose-import-stepper').html(renderWizardStepper(2)); + + var cfg = importWizard.config; + var netCfg = cfg.networkConfig; + + // Sanitize stack name preview helper + function sanitizeName(name) { + return name.toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/^_+|_+$/g, ''); + } + + var html = ''; + + // Stack name + html += '
' + + '' + + '' + + '
' + + '
'; + + // Stack Network + var stackNetChecked = netCfg.stackNetwork.enabled ? ' checked' : ''; + html += '
' + + '' + + '
'; + + // External Networks (Advanced) + var existingNets = importWizard.availableNetworks.filter(function(n) { + return n.name !== 'bridge' && n.name !== 'host' && n.name !== 'none'; + }); + var selectedExt = netCfg.externalNetworks || []; + html += '
' + + '
External Networks
'; + if (existingNets.length) { + html += '
'; + existingNets.forEach(function(net) { + var checked = selectedExt.indexOf(net.name) >= 0 ? ' checked' : ''; + html += ''; + }); + html += '
'; + } else { + html += '
No custom Docker networks found.
'; + } + html += '
' + + '' + + '' + + '
'; + + $('#compose-import-modal-body').html(html); + // Apply advanced/basic visibility + if (!importWizard.advancedMode) $('.iw-advanced-section').hide(); + $('#compose-import-modal-footer').html(wizardFooter({ + back: 'renderImportStage1()', + next: 'importWizardStage2Next()' + })); + + // Wire up events + $('#iw-stack-name').on('input', function() { + var val = this.value; + var san = sanitizeName(val); + $('#iw-stack-name-preview').text(san ? 'Folder: ' + san : ''); + // Update default stack net name + if ($('#iw-stack-net-name').val() === '' || $('#iw-stack-net-name').data('auto')) { + $('#iw-stack-net-name').val(san ? san + '_net' : '').data('auto', true); + } + }).trigger('input'); + $('#iw-stack-net-name').on('input', function() { $(this).data('auto', false); }); + $('#iw-stack-net-enabled').on('change', function() { + $('#iw-stack-net-details').toggle(this.checked); + }); + } + + function iwSaveStage2() { + var cfg = importWizard.config; + var el = document.getElementById('iw-stack-name'); + if (el) cfg.stackName = el.value.trim(); + var netCfg = cfg.networkConfig; + var netEl = document.getElementById('iw-stack-net-enabled'); + if (netEl) netCfg.stackNetwork.enabled = netEl.checked; + var nameEl = document.getElementById('iw-stack-net-name'); + if (nameEl) netCfg.stackNetwork.name = nameEl.value.trim(); + var extNets = []; + $('.iw-ext-net-cb:checked').each(function() { extNets.push(this.value); }); + netCfg.externalNetworks = extNets; + } + + function iwAddCustomNetwork() { + var name = document.getElementById('iw-ext-net-custom').value.trim(); + if (!name) return; + // Check if already exists + var exists = false; + $('.iw-ext-net-cb').each(function() { if (this.value === name) exists = true; }); + if (exists) { document.getElementById('iw-ext-net-custom').value = ''; return; } + var label = ''; + if ($('#iw-ext-nets').length) { + $('#iw-ext-nets').append(label); + } else { + // Create the container if it didn't exist + var container = '
' + label + '
'; + $('.import-ext-net-add').before(container); + } + document.getElementById('iw-ext-net-custom').value = ''; + } + + function importWizardStage2Next() { + iwSaveStage2(); + if (!importWizard.config.stackName) { + swal('Stack name required', 'Please enter a stack name.', 'warning'); + return; + } + renderImportStage3(); + } + + // ── Stage 3: Configure Containers ─────────────────────────────────────── + function renderImportStage3() { + importWizard.stage = 3; + $('#compose-import-stepper').html(renderWizardStepper(3)); + + var cfg = importWizard.config; + var services = importWizard.services; + var svcKeys = Object.keys(services); + var netCfg = cfg.networkConfig; + var html = ''; + + // Port conflict banner + if (importWizard.portConflicts.length) { + html += '
'; + importWizard.portConflicts.forEach(function(c) { + html += '
Host port ' + composeEscapeHtml(c.hostPort + '/' + c.protocol) + + ' is mapped by: ' + c.services.map(composeEscapeHtml).join(', ') + '
'; + }); + html += '
'; + } + + // Build conflict lookup for ports + var portConflictMap = {}; + importWizard.portConflicts.forEach(function(c) { + c.services.forEach(function(svc) { + if (!portConflictMap[svc]) portConflictMap[svc] = []; + portConflictMap[svc].push(c); + }); + }); + + svcKeys.forEach(function(key, idx) { + var meta = services[key]; + var cName = cfg.containerNames[key] || meta.containerName || key; + var perSvc = netCfg.perService[key] || {}; + var netMode = perSvc.networkMode || 'default'; + var isNetModeRestricted = (netMode === 'host' || netMode === 'none' || netMode === 'bridge'); + var attachStackNet = perSvc.attachStackNet !== false; + var svcExtNets = perSvc.externalNets || []; + var hc = cfg.healthchecks[key]; + var hcSource = meta.healthcheckSource || 'none'; + if (hc && hcSource === 'none') hcSource = 'auto'; + + // Card + var expanded = idx === 0 ? ' expanded' : ''; + html += '
'; + + // Header + var iconImg = meta.icon ? '' : ''; + html += '
' + + iconImg + + '' + composeEscapeHtml(key) + '' + + '' + composeEscapeHtml(meta.image) + '' + + '' + + '
'; + + // Body + html += '
'; + + // Row 1: Container name + network mode + html += '
'; + html += '
' + + '' + + '
'; + html += '
' + + '
'; + html += '
'; + + // Row 2: Network attachments (Advanced) + // Build union of global external networks and per-service networks + var allExtNets = (netCfg.externalNetworks || []).slice(); + svcExtNets.forEach(function(n) { + if (allExtNets.indexOf(n) === -1) allExtNets.push(n); + }); + var stackNetAvailable = netCfg.stackNetwork.enabled && netCfg.stackNetwork.name; + var hasExtNets = allExtNets.length > 0; + if (stackNetAvailable || hasExtNets) { + html += '
'; + html += '
'; + if (isNetModeRestricted) { + html += '
Network attachments unavailable in ' + composeEscapeHtml(netMode) + ' mode
'; + } + if (stackNetAvailable) { + var stackChecked = (attachStackNet && !isNetModeRestricted) ? ' checked' : ''; + var stackDisabled = isNetModeRestricted ? ' disabled' : ''; + var stackDisabledCls = isNetModeRestricted ? ' class="disabled"' : ''; + html += ' ' + + composeEscapeHtml(netCfg.stackNetwork.name) + ' (stack)'; + } + allExtNets.forEach(function(en) { + var extChecked = (svcExtNets.indexOf(en) >= 0 && !isNetModeRestricted) ? ' checked' : ''; + var extDisabled = isNetModeRestricted ? ' disabled' : ''; + var extDisabledCls = isNetModeRestricted ? ' class="disabled"' : ''; + html += ' ' + + composeEscapeHtml(en) + ' (external)'; + }); + html += '
'; + + // Static IP fields for attached networks + var svcIPs = perSvc.ipv4Addresses || {}; + var attachedNets = []; + if (stackNetAvailable && attachStackNet && !isNetModeRestricted) attachedNets.push(netCfg.stackNetwork.name); + svcExtNets.forEach(function(n) { if (!isNetModeRestricted) attachedNets.push(n); }); + if (attachedNets.length > 0) { + html += '
'; + attachedNets.forEach(function(netName) { + var ipVal = svcIPs[netName] || ''; + html += '
' + + '' + + '' + + '
'; + }); + html += '
'; + } + html += '
'; + } + + // Ports (read-only) + if (meta.ports && meta.ports.length) { + var conflicts = portConflictMap[key] || []; + html += '
'; + html += '
'; + meta.ports.forEach(function(p) { + var displayIp = (p.hostIp && p.hostIp !== '0.0.0.0') ? (p.hostIp.indexOf(':') !== -1 ? '[' + p.hostIp + ']' : p.hostIp) : ''; + var portStr = (displayIp ? displayIp + ':' : '') + (p.hostPort ? p.hostPort + ':' : '') + p.containerPort + '/' + p.protocol; + var isConflict = false; + conflicts.forEach(function(c) { + if (c.hostPort === p.hostPort && c.protocol === p.protocol) isConflict = true; + }); + html += '' + composeEscapeHtml(portStr); + if (isConflict) { + var otherSvcs = []; + conflicts.forEach(function(c) { + if (c.hostPort === p.hostPort && c.protocol === p.protocol) { + c.services.forEach(function(s) { if (s !== key) otherSvcs.push(s); }); + } + }); + html += ' '; + } + html += ''; + }); + html += '
'; + } + + // Healthcheck section (Advanced) + var hcSourceLabel = hcSource === 'image' ? 'From Image' : (hcSource === 'auto' ? 'Auto-Detected' : 'None'); + var hcSourceClass = hcSource === 'image' ? 'from-image' : (hcSource === 'auto' ? 'auto-detected' : 'none'); + var hcExpanded = hc ? ' expanded' : ''; + html += '
'; + html += '
' + + ' Healthcheck ' + hcSourceLabel + '' + + '
'; + + html += '
'; + + var testCmd = ''; + if (hc && hc.test) { + testCmd = Array.isArray(hc.test) ? (hc.test.length > 1 ? hc.test.slice(1).join(' ') : hc.test[0]) : hc.test; + } + var origTestCmd = (hc && hc.__originalTestCmd) || ''; + html += '
' + + '
'; + html += '
' + + '
'; + html += '
' + + '
'; + html += '
' + + '
'; + html += '
' + + '
'; + html += '
' + + '
'; + html += '
'; // healthcheck section + + html += '
'; // card body + card + }); + + $('#compose-import-modal-body').html(html); + // Apply advanced/basic visibility + if (!importWizard.advancedMode) $('.iw-advanced-section').hide(); + $('#compose-import-modal-footer').html(wizardFooter({ + back: 'iwSaveStage3(); renderImportStage2()', + next: 'importWizardStage3Next()', + nextDisabled: false + })); + + // Wire events + $('#compose-import-modal-body').off('click.iwHcRm').on('click.iwHcRm', '.iw-remove-hc-btn', function() { + iwRemoveHealthcheck($(this).data('svc')); + }); + $('.iw-container-name').on('input', iwValidateContainerNames); + $('.iw-net-mode').on('change', function() { + var svc = $(this).data('service'); + var mode = this.value; + var restricted = (mode === 'host' || mode === 'none' || mode === 'bridge'); + var $nets = $('.iw-svc-nets[data-service="' + svc + '"]'); + $nets.find('input[type="checkbox"]').prop('disabled', restricted); + if (restricted) { + $nets.find('input[type="checkbox"]').prop('checked', false); + $nets.find('label').addClass('disabled'); + } else { + $nets.find('label').removeClass('disabled'); + } + iwRefreshIpFields(svc); + }); + $('.iw-attach-stack, .iw-attach-ext').on('change', function() { + iwRefreshIpFields($(this).data('service')); + }); + iwValidateContainerNames(); + } + + function iwRefreshIpFields(svc) { + var $container = $('.import-ip-fields[data-service="' + svc + '"]'); + if (!$container.length) return; + // Gather currently checked networks + var nets = []; + var $card = $container.closest('.import-service-card'); + var mode = $card.find('.iw-net-mode').val(); + var restricted = (mode === 'host' || mode === 'none' || mode === 'bridge'); + if (!restricted) { + $card.find('.iw-attach-stack:checked').each(function() { + var netCfg = importWizard.config.networkConfig; + if (netCfg.stackNetwork.enabled && netCfg.stackNetwork.name) { + nets.push(netCfg.stackNetwork.name); + } + }); + $card.find('.iw-attach-ext:checked').each(function() { nets.push(this.value); }); + } + // Save existing IP values + var existingIPs = {}; + $container.find('.iw-ipv4-addr').each(function() { + existingIPs[$(this).data('network')] = this.value; + }); + // Also check saved config + var savedIPs = (importWizard.config.networkConfig.perService[svc] || {}).ipv4Addresses || {}; + // Rebuild IP fields + var html = ''; + nets.forEach(function(netName) { + var ipVal = existingIPs[netName] || savedIPs[netName] || ''; + html += '
' + + '' + + '' + + '
'; + }); + $container.html(html); + } + + function iwToggleCard(headerEl) { + $(headerEl).closest('.import-service-card').toggleClass('expanded'); + } + + function iwToggleHealthcheck(headerEl) { + $(headerEl).closest('.import-healthcheck-section').toggleClass('expanded'); + } + + function iwRemoveHealthcheck(svc) { + importWizard.config.healthchecks[svc] = null; + var $section = $('.import-healthcheck-section[data-service="' + svc + '"]'); + $section.removeClass('expanded'); + $section.find('.iw-hc-test').val(''); + $section.find('.import-healthcheck-source').text('None').attr('class', 'import-healthcheck-source none'); + } + + function iwValidateContainerNames() { + var names = {}; + var hasDuplicates = false; + $('.iw-container-name').each(function() { + var svc = $(this).data('service'); + var val = this.value.trim(); + if (!names[val]) names[val] = []; + names[val].push(svc); + }); + $('.iw-container-name').each(function() { + var svc = $(this).data('service'); + var val = this.value.trim(); + var $err = $('#iw-name-error-' + svc); + if (val === '') { + $(this).addClass('import-name-error'); + $err.text('Container name cannot be empty'); + hasDuplicates = true; + } else if (names[val].length > 1) { + $(this).addClass('import-name-error'); + $err.text('Duplicate name — conflicts with: ' + names[val].filter(function(s) { return s !== svc; }).join(', ')); + hasDuplicates = true; + } else { + $(this).removeClass('import-name-error'); + $err.text(''); + } + }); + $('#import-wizard-next-btn').prop('disabled', hasDuplicates); + } + + function iwSaveStage3() { + var cfg = importWizard.config; + // Save container names + $('.iw-container-name').each(function() { + cfg.containerNames[$(this).data('service')] = this.value.trim(); + }); + // Save network modes & attachments + $('.iw-net-mode').each(function() { + var svc = $(this).data('service'); + if (!cfg.networkConfig.perService[svc]) cfg.networkConfig.perService[svc] = {}; + cfg.networkConfig.perService[svc].networkMode = this.value; + }); + $('.iw-attach-stack').each(function() { + var svc = $(this).data('service'); + if (!cfg.networkConfig.perService[svc]) cfg.networkConfig.perService[svc] = {}; + cfg.networkConfig.perService[svc].attachStackNet = this.checked; + }); + Object.keys(importWizard.services).forEach(function(svc) { + var extNets = []; + $('.iw-attach-ext[data-service="' + svc + '"]:checked').each(function() { extNets.push(this.value); }); + if (!cfg.networkConfig.perService[svc]) cfg.networkConfig.perService[svc] = {}; + cfg.networkConfig.perService[svc].externalNets = extNets; + // Save static IP addresses + var ips = {}; + $('.iw-ipv4-addr[data-service="' + svc + '"]').each(function() { + var net = $(this).data('network'); + var ip = this.value.trim(); + if (net && ip) ips[net] = ip; + }); + cfg.networkConfig.perService[svc].ipv4Addresses = ips; + }); + // Save healthchecks + Object.keys(importWizard.services).forEach(function(svc) { + var $input = $('.iw-hc-test[data-service="' + svc + '"]'); + var testCmd = $input.val(); + if (testCmd && testCmd.trim()) { + // Preserve original test type (CMD/CMD-SHELL) if command text unchanged + var origCmd = $input.data('original-cmd') || ''; + var existingHc = cfg.healthchecks[svc]; + var testType = 'CMD-SHELL'; + if (testCmd.trim() === origCmd && existingHc && existingHc.__originalTestType) { + testType = existingHc.__originalTestType; + } + cfg.healthchecks[svc] = { + test: [testType, testCmd.trim()], + interval: $('.iw-hc-interval[data-service="' + svc + '"]').val() || '30s', + timeout: $('.iw-hc-timeout[data-service="' + svc + '"]').val() || '10s', + retries: parseInt($('.iw-hc-retries[data-service="' + svc + '"]').val()) || 3, + start_period: $('.iw-hc-start-period[data-service="' + svc + '"]').val() || '10s' + }; + } else if (cfg.healthchecks[svc] !== null) { + // Only nullify if not already explicitly removed + cfg.healthchecks[svc] = null; + } + }); + } + + function importWizardStage3Next() { + iwSaveStage3(); + // Check for name validation + var hasDuplicates = false; + var names = {}; + Object.keys(importWizard.config.containerNames).forEach(function(k) { + var n = importWizard.config.containerNames[k]; + if (!n || names[n]) hasDuplicates = true; + names[n] = true; + }); + if (hasDuplicates) { + swal('Name conflicts', 'Please resolve container name conflicts before continuing.', 'warning'); + return; + } + // In basic mode, skip dependencies and go straight to review + if (!importWizard.advancedMode) { + renderImportStageReview(); + } else { + renderImportStage4(); + } + } + + // ── Stage 4: Configure Dependencies ───────────────────────────────────── + function renderImportStage4() { + importWizard.stage = 4; + $('#compose-import-stepper').html(renderWizardStepper(4)); + + var svcKeys = Object.keys(importWizard.services); + var deps = importWizard.config.dependencies; + var html = ''; + + if (svcKeys.length < 2) { + html += '
' + + '' + + 'Single container — no dependencies to configure.
'; + $('#compose-import-modal-body').html(html); + $('#compose-import-modal-footer').html(wizardFooter({ + back: 'renderImportStage3()', + next: 'importWizardStage4Next()', + nextLabel: 'Next' + })); + return; + } + + html += ''; + + html += ''; + svcKeys.forEach(function(key) { + var svcDeps = deps[key] || []; + html += ''; + html += ''; + }); + html += '
ServiceDepends On
' + composeEscapeHtml(key) + '
'; + if (svcDeps.length) { + svcDeps.forEach(function(d) { + html += iwRenderDepRow(key, svcKeys, d, iwDepIdCounter++); + }); + } + html += '
'; + html += ''; + html += '
'; + + html += ''; + + $('#compose-import-modal-body').html(html); + $('#compose-import-modal-footer').html(wizardFooter({ + back: 'iwSaveDeps(); renderImportStage3()', + next: 'importWizardStage4Next()' + })); + + iwWireDepEvents($('#compose-import-modal-body')); + iwCheckDependencyCycles(); + } + + function iwRenderDepRow(svc, allKeys, dep, index) { + var targetSvc = dep ? dep.service : ''; + var condition = dep ? dep.condition : 'service_started'; + var otherKeys = allKeys.filter(function(k) { return k !== svc; }); + var html = '
'; + html += ''; + + var hasHC = !!importWizard.config.healthchecks[targetSvc]; + html += ''; + + html += ''; + html += '
'; + return html; + } + + function iwUpdateConditionDropdown($row) { + var targetSvc = $row.find('.iw-dep-target').val(); + var $cond = $row.find('.iw-dep-condition'); + var hasHC = !!(targetSvc && importWizard.config.healthchecks[targetSvc]); + var $healthy = $cond.find('option[value="service_healthy"]'); + if (hasHC) { + $healthy.prop('disabled', false).text('service_healthy'); + } else { + $healthy.prop('disabled', true).text('service_healthy (no healthcheck)'); + if ($cond.val() === 'service_healthy') { + $cond.val('service_started'); + } + } + } + + function iwWireDepEvents($container) { + $container.find('.iw-dep-target').off('change.iw').on('change.iw', function() { + iwUpdateConditionDropdown($(this).closest('.import-dep-row')); + iwCheckDependencyCycles(); + }); + $container.find('.iw-dep-condition').off('change.iw').on('change.iw', function() { + iwCheckDependencyCycles(); + }); + $container.off('click.iwAdd').on('click.iwAdd', '.import-dep-add', function() { + iwAddDep($(this).data('svc')); + }); + $container.off('click.iwRm').on('click.iwRm', '.import-dep-remove', function() { + iwRemoveDep($(this).data('svc'), $(this).data('index')); + }); + } + + function iwAddDep(svc) { + var $entries = $('.iw-dep-entries[data-service="' + svc + '"]'); + var allKeys = Object.keys(importWizard.services); + var index = iwDepIdCounter++; + $entries.append(iwRenderDepRow(svc, allKeys, null, index)); + iwWireDepEvents($entries); + } + + function iwRemoveDep(svc, index) { + var $entries = $('.iw-dep-entries[data-service="' + svc + '"]'); + $entries.find('.import-dep-row[data-index="' + index + '"]').remove(); + iwCheckDependencyCycles(); + } + + function iwCollectDeps() { + var deps = {}; + Object.keys(importWizard.services).forEach(function(svc) { + deps[svc] = []; + $('.iw-dep-entries[data-service="' + svc + '"] .import-dep-row').each(function() { + var target = $(this).find('.iw-dep-target').val(); + var condition = $(this).find('.iw-dep-condition').val(); + if (target) { + deps[svc].push({ service: target, condition: condition || 'service_started' }); + } + }); + }); + return deps; + } + + function iwSaveDeps() { + importWizard.config.dependencies = iwCollectDeps(); + } + + function iwCheckDependencyCycles() { + var deps = iwCollectDeps(); + var cycle = iwDetectCycle(deps); + var $err = $('#iw-dep-cycle-error'); + var $order = $('#iw-startup-order'); + var $next = $('#import-wizard-next-btn'); + + if (cycle) { + $err.text('Dependency cycle detected: ' + cycle.join(' → ')).show(); + $next.prop('disabled', true); + $order.hide(); + } else { + $err.hide(); + $next.prop('disabled', false); + // Show startup order via topological sort + var order = iwTopologicalSort(deps); + if (order.length > 1) { + $order.html('Startup order: ' + order.map(composeEscapeHtml).join(' → ')).show(); + } else { + $order.hide(); + } + } + } + + /** + * Detect cycles in the dependency graph using DFS. + * Returns the cycle path as an array, or null if no cycle. + */ + function iwDetectCycle(deps) { + var WHITE = 0, GRAY = 1, BLACK = 2; + var color = {}; + var parent = {}; + var allNodes = Object.keys(deps); + allNodes.forEach(function(n) { color[n] = WHITE; }); + + for (var i = 0; i < allNodes.length; i++) { + if (color[allNodes[i]] === WHITE) { + var result = dfs(allNodes[i]); + if (result) return result; + } + } + return null; + + function dfs(u) { + color[u] = GRAY; + var edges = (deps[u] || []).map(function(d) { return d.service; }).filter(function(s) { return s; }); + for (var j = 0; j < edges.length; j++) { + var v = edges[j]; + if (color[v] === GRAY) { + // Found cycle — reconstruct path + var path = [v, u]; + var cur = u; + while (cur !== v && parent[cur]) { + cur = parent[cur]; + path.push(cur); + } + return path.reverse(); + } + if (color[v] === WHITE) { + parent[v] = u; + var res = dfs(v); + if (res) return res; + } + } + color[u] = BLACK; + return null; + } + } + + /** + * Topological sort via Kahn's algorithm. Returns ordered array of service names. + */ + function iwTopologicalSort(deps) { + var inDegree = {}; + var adjList = {}; + var allNodes = Object.keys(deps); + allNodes.forEach(function(n) { inDegree[n] = 0; adjList[n] = []; }); + allNodes.forEach(function(n) { + (deps[n] || []).forEach(function(d) { + if (d.service && adjList[d.service]) { + adjList[d.service].push(n); + inDegree[n] = (inDegree[n] || 0) + 1; + } + }); + }); + var queue = allNodes.filter(function(n) { return inDegree[n] === 0; }); + var order = []; + while (queue.length) { + var node = queue.shift(); + order.push(node); + (adjList[node] || []).forEach(function(neighbor) { + inDegree[neighbor]--; + if (inDegree[neighbor] === 0) queue.push(neighbor); + }); + } + return order; + } + + function importWizardStage4Next() { + iwSaveDeps(); + var cycle = iwDetectCycle(importWizard.config.dependencies); + if (cycle) { + swal('Dependency cycle', 'Please resolve the dependency cycle before continuing.', 'warning'); + return; + } + renderImportStageReview(); + } + + // ── Stage 5: Review & Import (merged generating + validation) ─────────── + function renderImportStageReview() { + importWizard.stage = 5; + $('#compose-import-stepper').html(renderWizardStepper(5)); + $('#compose-import-modal-body').html( + '
Generating compose configuration...
' + ); + $('#compose-import-modal-footer').html(''); + + var cfg = importWizard.config; + // Strip __-prefixed metadata keys from healthchecks before sending + var cleanHC = {}; + Object.keys(cfg.healthchecks).forEach(function(svc) { + var hc = cfg.healthchecks[svc]; + if (hc && typeof hc === 'object') { + var clean = {}; + Object.keys(hc).forEach(function(k) { if (k.indexOf('__') !== 0) clean[k] = hc[k]; }); + cleanHC[svc] = clean; + } else { + cleanHC[svc] = hc; + } + }); + $.post(caURL, { + action: 'finalizeImportCompose', + containerIds: JSON.stringify(importWizard.containerIds), + containerNames: JSON.stringify(cfg.containerNames), + networkConfig: JSON.stringify(cfg.networkConfig), + healthchecks: JSON.stringify(cleanHC), + dependencies: JSON.stringify(cfg.dependencies) + }, function(data) { + var response; + try { response = JSON.parse(data); } catch (e) { response = { result:'error', message: 'Invalid response' }; } + if (response.result !== 'success') { + $('#compose-import-modal-body').html('
' + + composeEscapeHtml(response.message || 'Failed to generate compose configuration') + '
'); + var backTarget = importWizard.advancedMode ? 'renderImportStage4()' : 'renderImportStage3()'; + $('#compose-import-modal-footer').html(wizardFooter({ back: backTarget })); + return; + } + importWizard.result = response; + iwRenderReviewContent(); + }).fail(function() { + $('#compose-import-modal-body').html('
Communication error
'); + var backTarget = importWizard.advancedMode ? 'renderImportStage4()' : 'renderImportStage3()'; + $('#compose-import-modal-footer').html(wizardFooter({ back: backTarget })); + }); + } + + function iwRenderReviewContent() { + var result = importWizard.result; + var validation = result.validation || { valid: true, errors: [] }; + var cfg = importWizard.config; + var svcKeys = Object.keys(importWizard.services); + var html = ''; + + // Validation bar + if (validation.valid) { + html += '
Compose configuration is valid
'; + } else { + html += '
Validation errors found
'; + html += ''; + } + + // Summary panel + var hcCount = 0; + var depCount = 0; + Object.keys(cfg.healthchecks).forEach(function(k) { if (cfg.healthchecks[k]) hcCount++; }); + Object.keys(cfg.dependencies).forEach(function(k) { if (cfg.dependencies[k] && cfg.dependencies[k].length) depCount++; }); + + html += '
' + + '
Stack' + composeEscapeHtml(cfg.stackName) + '
' + + '
Services' + svcKeys.length + '
' + + '
Networks' + + (cfg.networkConfig.stackNetwork.enabled ? composeEscapeHtml(cfg.networkConfig.stackNetwork.name) : 'None') + + (cfg.networkConfig.externalNetworks.length ? ' + ' + cfg.networkConfig.externalNetworks.length + ' external' : '') + + '
' + + '
Healthchecks' + hcCount + '
' + + '
Dependencies' + depCount + '
' + + '
'; + + // Port conflict warnings + if (importWizard.portConflicts.length) { + html += '
'; + importWizard.portConflicts.forEach(function(c) { + html += '
Port ' + composeEscapeHtml(c.hostPort + '/' + c.protocol) + + ': ' + c.services.map(composeEscapeHtml).join(', ') + '
'; + }); + html += '
'; + } + + // YAML preview + html += '
' + + '
compose.yaml
' + + '
' + composeEscapeHtml(result.composeYml || '') + '
' + + '
'; + + // Env preview + if (result.env) { + html += '
.env file' + + '
' + composeEscapeHtml(result.env) + '
'; + } + + // Override preview + if (result.override) { + html += '
Override file (icons/labels)' + + '
' + composeEscapeHtml(result.override) + '
'; + } + + // After Import actions — always visible + html += '
' + + '
After Import
' + + '' + + '' + + '' + + '
'; + + $('#compose-import-modal-body').html(html); + + var backTarget = importWizard.advancedMode ? 'renderImportStage4()' : 'renderImportStage3()'; + $('#compose-import-modal-footer').html(wizardFooter({ + back: backTarget, + action: 'importWizardConfirmAndPerform()', + actionLabel: 'Import', + actionDisabled: !validation.valid + })); + + // Wire after-import checkbox constraints + $('#iw-remove-containers').on('change', function() { + if (this.checked) $('#iw-stop-containers').prop('checked', true); + if (!this.checked) $('#iw-start-stack').prop('checked', false); + }); + $('#iw-stop-containers').on('change', function() { + if (!this.checked) { + $('#iw-remove-containers').prop('checked', false); + $('#iw-start-stack').prop('checked', false); + } + }); + $('#iw-start-stack').on('change', function() { + if (this.checked) { + $('#iw-stop-containers').prop('checked', true); + $('#iw-remove-containers').prop('checked', true); + } + }); + } + + // ── Final: Confirm & Perform Import ───────────────────────────────────── + function importWizardConfirmAndPerform() { + // Save after-import options from the review stage + var cfg = importWizard.config; + cfg.stopContainers = document.getElementById('iw-stop-containers').checked; + cfg.removeContainers = document.getElementById('iw-remove-containers').checked; + cfg.startStack = document.getElementById('iw-start-stack').checked; + + var containerCount = importWizard.containerIds.length; + + // Build confirmation message for destructive actions + if (cfg.stopContainers || cfg.removeContainers) { + var actions = []; + if (cfg.stopContainers) actions.push('stop'); + if (cfg.removeContainers) actions.push('remove'); + var actionText = actions.join(' and '); + var msg = 'This will ' + actionText + ' ' + containerCount + ' original container' + (containerCount !== 1 ? 's' : ''); + if (cfg.startStack) msg += ', then start the imported stack'; + msg += '. Continue?'; + + swal({ + title: 'Confirm Import', + text: msg, + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Yes, import', + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) importWizardPerform(); + }); + } else { + importWizardPerform(); + } + } + + function importWizardPerform() { + var cfg = importWizard.config; + var result = importWizard.result; + + // Close wizard immediately and show a loading spinner overlay + closeComposeImportModal(); + var spinnerHtml = '
' + + '
' + + '' + + 'Importing stack...' + + '
'; + document.body.insertAdjacentHTML('beforeend', spinnerHtml); + + $.post(caURL, { + action: 'performImportTransfer', + stackName: cfg.stackName, + stackDesc: cfg.stackDesc, + stopContainers: cfg.stopContainers ? '1' : '0', + removeContainers: cfg.removeContainers ? '1' : '0', + startStack: cfg.startStack ? '1' : '0', + composeYml: result.composeYml || '', + env: result.env || '', + override: result.override || '', + containerIds: JSON.stringify(importWizard.containerIds) + }, function(data) { + var spinner = document.getElementById('compose-import-spinner-overlay'); + if (spinner) spinner.remove(); + + var response; + try { response = JSON.parse(data); } catch (e) { response = { result:'error', message: 'Invalid response' }; } + if (response.result === 'success') { + composeLoadlist(); + openEditorModalByProject(response.project, response.projectName); + if (response.startStack === 1 && response.projectPath) { + ComposeUpConfirmed(response.projectPath, "", true, true); + } + swal('Imported', 'Stack imported successfully.', 'success'); + } else { + swal('Import failed', response.message || 'Unknown error', 'error'); + } + }).fail(function() { + var spinner = document.getElementById('compose-import-spinner-overlay'); + if (spinner) spinner.remove(); + swal('Import failed', 'Communication error', 'error'); + }); + } + + // Keep legacy function names working (old callers) + function composeImportPreview() { importWizardStage1Next(); } + function composeImportPerform() { importWizardPerform(); } + // Keep old stage names as aliases for any callers + function renderImportStage5() { renderImportStageReview(); } + function renderImportStage6() { iwRenderReviewContent(); } + function stripTags(string) { return string.replace(/(<([^>]+)>)/ig, ""); } @@ -6090,6 +7389,7 @@ function addComposeStackContext(elementId) {