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 = ' Back ';
+ }
+ if (opts.cancel !== false) {
+ right += 'Cancel ';
+ }
+ if (opts.next) {
+ var disabledAttr = opts.nextDisabled ? ' disabled' : '';
+ var label = opts.nextLabel || 'Next';
+ right += '' + composeEscapeHtml(label) + ' ';
+ }
+ if (opts.action) {
+ var actionDisabledAttr = opts.actionDisabled ? ' disabled' : '';
+ right += '' + composeEscapeHtml(opts.actionLabel || 'Import') + ' ';
+ }
+ 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 = '' +
+ '
' +
+ '' +
+ '
' +
+ '
Loading Docker Manager containers...
' +
+ '' +
+ '
';
+
+ 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 = '' +
+ ' Select All ';
+
+ 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 + '' +
+ 'Name Image Status Icon WebUI ' +
+ '' + rows + '
';
+ $('#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 Name * ' +
+ '
' +
+ '
' +
+ '
';
+
+ // 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 += ' ' +
+ composeEscapeHtml(net.name) + ' (' + composeEscapeHtml(net.driver) + ') ';
+ });
+ html += '
';
+ } else {
+ html += '
No custom Docker networks found.
';
+ }
+ html += '
' +
+ ' ' +
+ 'Add ' +
+ '
';
+
+ $('#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 = ' ' +
+ composeEscapeHtml(name) + ' (custom) ';
+ 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 += '';
+
+ // Body
+ html += '
';
+
+ // Row 1: Container name + network mode
+ html += '
';
+ html += '
Container Name ' +
+ '
' +
+ '
';
+ html += '
Network Mode ' +
+ '' +
+ 'Default (compose managed) ' +
+ 'Bridge ' +
+ 'Host ' +
+ 'None ' +
+ '
';
+ 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 += '
Network Attachments ';
+ 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 += '
' +
+ '' + composeEscapeHtml(netName) + ' IP: ' +
+ ' ' +
+ '
';
+ });
+ html += '
';
+ }
+ html += '
';
+ }
+
+ // Ports (read-only)
+ if (meta.ports && meta.ports.length) {
+ var conflicts = portConflictMap[key] || [];
+ html += '
Ports ';
+ 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 += '';
+
+ 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 += '' +
+ '' + composeEscapeHtml(netName) + ' IP: ' +
+ ' ' +
+ '
';
+ });
+ $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 += 'Service Depends On ';
+ svcKeys.forEach(function(key) {
+ var svcDeps = deps[key] || [];
+ html += '' + composeEscapeHtml(key) + ' ';
+ html += '';
+ if (svcDeps.length) {
+ svcDeps.forEach(function(d) {
+ html += iwRenderDepRow(key, svcKeys, d, iwDepIdCounter++);
+ });
+ }
+ html += '
';
+ html += ' Add Dependency ';
+ 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 += '';
+ html += '-- Select -- ';
+ otherKeys.forEach(function(k) {
+ html += '' + composeEscapeHtml(k) + ' ';
+ });
+ html += ' ';
+
+ var hasHC = !!importWizard.config.healthchecks[targetSvc];
+ html += '';
+ html += 'service_started ';
+ html += 'service_healthy' + (!hasHC ? ' (no healthcheck)' : '') + ' ';
+ 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 += '';
+ validation.errors.forEach(function(e) {
+ html += '' + composeEscapeHtml(e) + ' ';
+ });
+ 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 += '';
+
+ $('#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) {
+
diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php
index 6b7c68a..8822d25 100644
--- a/source/compose.manager/include/Exec.php
+++ b/source/compose.manager/include/Exec.php
@@ -77,6 +77,398 @@ function getPostScript(): string
clientDebug("Created stack: $stackName", null, 'user', 'info', 'stack');
echo json_encode(['result' => 'success', 'message' => '', 'project' => $stack->projectFolder, 'projectName' => $stack->getName()]);
+ break;
+ case 'getDockerContainersForImport':
+ $candidates = getDockerManagerImportCandidates();
+ echo json_encode(['result' => 'success', 'containers' => $candidates]);
+ break;
+
+ case 'generateImportData':
+ // Rich import data for the 5-stage wizard — returns per-service metadata, port conflicts, networks
+ $containerIds = [];
+ if (!empty($_POST['containerIds'])) {
+ $data = json_decode($_POST['containerIds'], true);
+ if (is_array($data)) {
+ $containerIds = $data;
+ }
+ }
+ if (empty($containerIds)) {
+ echo json_encode(['result' => 'error', 'message' => 'No containers selected']);
+ break;
+ }
+
+ $importResult = buildImportServicesFromIds($containerIds);
+ $services = $importResult['services'];
+ $inspectData = $importResult['inspectData'];
+
+ if ($services === []) {
+ echo json_encode(['result' => 'error', 'message' => 'No valid Docker Manager containers found for import']);
+ break;
+ }
+
+ // Build per-service metadata for the wizard from inspect data
+ $servicesMeta = [];
+ foreach ($services as $name => $service) {
+ $info = $inspectData[$name];
+ $converted = dockerContainerToComposeService($info);
+ $origName = $converted['originalName'] ?? $name;
+
+ $serviceIcon = $info['Config']['Labels']['net.unraid.docker.icon'] ?? '';
+ $serviceWebui = $info['Config']['Labels']['net.unraid.docker.webui'] ?? '';
+
+ // Parse ports into structured data for the frontend
+ $parsedPorts = [];
+ if (!empty($service['ports'])) {
+ foreach ($service['ports'] as $portStr) {
+ $parsedPorts[] = parsePortMapping($portStr);
+ }
+ }
+
+ $meta = [
+ 'originalName' => $origName,
+ 'containerName' => $service['container_name'] ?? $name,
+ 'image' => $service['image'] ?? '',
+ 'ports' => $parsedPorts,
+ 'exposedPorts' => array_keys($service['__exposed_ports'] ?? []),
+ 'icon' => $serviceIcon,
+ 'webui' => $serviceWebui,
+ 'networkMode' => $service['network_mode'] ?? null,
+ 'networks' => $service['networks'] ?? [],
+ 'networkIPs' => $service['__network_ips'] ?? [],
+ ];
+
+ // Healthcheck info
+ if (!empty($service['healthcheck'])) {
+ $meta['healthcheck'] = $service['healthcheck'];
+ $meta['healthcheckSource'] = $service['__healthcheck_source'] ?? 'image';
+ } elseif (!empty($service['__guessed_healthcheck'])) {
+ $meta['guessedHealthcheck'] = $service['__guessed_healthcheck'];
+ $meta['healthcheckSource'] = 'auto';
+ } else {
+ $meta['healthcheckSource'] = 'none';
+ }
+
+ $servicesMeta[$name] = $meta;
+ }
+
+ // Detect port conflicts across all services
+ $portConflicts = detectPortConflicts($services);
+
+ // Get available Docker networks
+ $dockerNetworks = getDockerNetworks();
+
+ echo json_encode([
+ 'result' => 'success',
+ 'services' => $servicesMeta,
+ 'portConflicts' => $portConflicts,
+ 'networks' => $dockerNetworks,
+ ]);
+ break;
+
+ case 'finalizeImportCompose':
+ // Accept full wizard config and generate final compose YAML with validation
+ $containerIds = [];
+ if (!empty($_POST['containerIds'])) {
+ $data = json_decode($_POST['containerIds'], true);
+ if (is_array($data)) {
+ $containerIds = $data;
+ }
+ }
+ if (empty($containerIds)) {
+ echo json_encode(['result' => 'error', 'message' => 'No containers selected']);
+ break;
+ }
+
+ // Decode wizard configuration
+ $containerNames = [];
+ if (!empty($_POST['containerNames'])) {
+ $data = json_decode($_POST['containerNames'], true);
+ if (is_array($data)) {
+ $containerNames = $data;
+ }
+ }
+ $networkConfig = [];
+ if (!empty($_POST['networkConfig'])) {
+ $data = json_decode($_POST['networkConfig'], true);
+ if (is_array($data)) {
+ $networkConfig = $data;
+ }
+ }
+ $healthchecks = [];
+ if (!empty($_POST['healthchecks'])) {
+ $data = json_decode($_POST['healthchecks'], true);
+ if (is_array($data)) {
+ $healthchecks = $data;
+ }
+ }
+ $dependencies = [];
+ if (!empty($_POST['dependencies'])) {
+ $data = json_decode($_POST['dependencies'], true);
+ if (is_array($data)) {
+ $dependencies = $data;
+ }
+ }
+
+ // Re-fetch and convert containers using shared helper
+ $importResult = buildImportServicesFromIds($containerIds);
+ $services = $importResult['services'];
+
+ if (empty($services)) {
+ echo json_encode(['result' => 'error', 'message' => 'No valid containers found']);
+ break;
+ }
+
+ // Apply wizard configuration to services
+ $wizardConfig = [
+ 'containerNames' => $containerNames,
+ 'networkConfig' => $networkConfig,
+ 'healthchecks' => $healthchecks,
+ 'dependencies' => $dependencies,
+ ];
+
+ $env = dockerResolveEnvAndCompose($services);
+ $composeYml = dockerServicesToComposeYml($services, $wizardConfig);
+ $override = dockerAddOverrideIcons($services);
+
+ // Validate the generated YAML
+ $validation = ['valid' => true, 'errors' => []];
+ try {
+ if (function_exists('yaml_parse')) {
+ $parsed = yaml_parse($composeYml);
+ if ($parsed === false) {
+ $validation['valid'] = false;
+ $validation['errors'][] = 'Generated YAML is not valid';
+ }
+ }
+ } catch (\Throwable $e) {
+ $validation['valid'] = false;
+ $validation['errors'][] = 'YAML parse error: ' . $e->getMessage();
+ }
+
+ echo json_encode([
+ 'result' => 'success',
+ 'composeYml' => $composeYml,
+ 'env' => $env,
+ 'override' => $override,
+ 'validation' => $validation,
+ ]);
+ break;
+
+ case 'performImportTransfer':
+ $stackName = trim($_POST['stackName'] ?? '');
+ $stackDesc = trim($_POST['stackDesc'] ?? '');
+ $stopContainers = (!empty($_POST['stopContainers']) && $_POST['stopContainers'] === '1');
+ $removeContainers = (!empty($_POST['removeContainers']) && $_POST['removeContainers'] === '1');
+ $startStack = (!empty($_POST['startStack']) && $_POST['startStack'] === '1');
+ $composeYml = trim($_POST['composeYml'] ?? '');
+ $env = trim($_POST['env'] ?? '');
+ $override = trim($_POST['override'] ?? '');
+
+ if ($stackName === '') {
+ echo json_encode(['result' => 'error', 'message' => 'Stack name is required']);
+ break;
+ }
+
+ if ($composeYml === '') {
+ echo json_encode(['result' => 'error', 'message' => 'Compose content is required']);
+ break;
+ }
+
+ if ($removeContainers && !$stopContainers) {
+ // Enforce logical dependency (remove implies stop)
+ $stopContainers = true;
+ }
+
+ if ($startStack && !$removeContainers) {
+ echo json_encode(['result' => 'error', 'message' => 'Cannot start imported stack without removing original containers — container names would conflict']);
+ break;
+ }
+
+ try {
+ $stackInfo = StackInfo::createNew($compose_root, $stackName, $stackDesc);
+ } catch (\RuntimeException $e) {
+ clientDebug('[stack] Failed to create import stack: ' . $e->getMessage(), null, 'daemon', 'error');
+ $userMessage = match (true) {
+ str_contains($e->getMessage(), 'cannot be empty') => 'Stack name cannot be empty.',
+ str_contains($e->getMessage(), 'empty folder name') => 'Invalid stack name.',
+ str_contains($e->getMessage(), 'unique folder name') => 'Could not create a unique folder for this stack.',
+ str_contains($e->getMessage(), 'escape compose root') => 'Invalid stack name.',
+ str_contains($e->getMessage(), 'Invalid compose root') => 'Server configuration error.',
+ default => 'Failed to create stack. Check server logs for details.',
+ };
+ echo json_encode(['result' => 'error', 'message' => $userMessage]);
+ break;
+ }
+
+ // Helper to clean up the newly created stack folder on failure
+ $cleanupStack = function () use ($stackInfo) {
+ if (is_dir($stackInfo->path)) {
+ exec('rm -rf ' . escapeshellarg($stackInfo->path));
+ \StackInfo::clearCache();
+ }
+ };
+
+ $composePath = $stackInfo->composeFilePath;
+ $envPath = $stackInfo->getEnvFilePath() ?? $stackInfo->composeSource . '/.env';
+ $overridePath = $stackInfo->getOverridePath();
+
+ // Validate that the compose content is parseable YAML before writing
+ try {
+ if (function_exists('yaml_parse')) {
+ $parsed = yaml_parse($composeYml);
+ if ($parsed === false) {
+ throw new \Exception('Invalid YAML');
+ }
+ }
+ } catch (\Throwable $e) {
+ $cleanupStack();
+ echo json_encode(['result' => 'error', 'message' => 'Generated compose YAML is invalid']);
+ break;
+ }
+
+ if (file_put_contents($composePath, $composeYml) === false) {
+ $cleanupStack();
+ echo json_encode(['result' => 'error', 'message' => 'Failed to write compose file']);
+ break;
+ }
+ if ($env !== '') {
+ if (file_put_contents($envPath, $env) === false) {
+ $cleanupStack();
+ echo json_encode(['result' => 'error', 'message' => 'Failed to write .env file']);
+ break;
+ }
+ }
+ if ($override !== '') {
+ if (file_put_contents($overridePath, $override) === false) {
+ $cleanupStack();
+ echo json_encode(['result' => 'error', 'message' => 'Failed to write override file']);
+ break;
+ }
+ }
+
+ $containerIds = [];
+ if (!empty($_POST['containerIds'])) {
+ $data = json_decode($_POST['containerIds'], true);
+ if (is_array($data)) {
+ $containerIds = $data;
+ }
+ }
+
+ // ── Two-phase atomic container operations with full rollback ──
+ if (($stopContainers || $removeContainers) && !empty($containerIds)) {
+ require_once('/usr/local/emhttp/plugins/dynamix.docker.manager/include/Helpers.php');
+ $dockerClient = new \DockerClient();
+ $dockerTemplates = new \DockerTemplates();
+
+ // Pre-snapshot: record each container's state and XML template for rollback
+ $containerSnapshots = [];
+ foreach ($containerIds as $id) {
+ $id = trim($id);
+ if ($id === '') {
+ continue;
+ }
+ $details = $dockerClient->getContainerDetails($id);
+ if (!is_array($details)) {
+ continue;
+ }
+ $containerName = ltrim(trim($details['Name'] ?? ''), '/');
+ $state = strtolower($details['State']['Status'] ?? 'unknown');
+ $xmlTemplate = $containerName !== '' ? $dockerTemplates->getUserTemplate($containerName) : false;
+ $containerSnapshots[] = [
+ 'id' => $id,
+ 'name' => $containerName,
+ 'state' => $state, // running, paused, exited, etc.
+ 'xmlTemplate' => $xmlTemplate, // path or false
+ ];
+ }
+
+ // Rollback helper: restart containers that were stopped
+ $rollbackStopped = function (array $stoppedIds) {
+ foreach (array_reverse($stoppedIds) as $sid) {
+ exec('docker start ' . escapeshellarg($sid) . ' 2>&1');
+ }
+ };
+
+ // Rollback helper: recreate containers that were removed from XML templates
+ $rollbackRemoved = function (array $removedSnapshots) {
+ foreach (array_reverse($removedSnapshots) as $snap) {
+ if (empty($snap['xmlTemplate'])) {
+ clientDebug('[import] Cannot restore container ' . $snap['name'] . ': no XML template found', null, 'daemon', 'error');
+ continue;
+ }
+ $cmdResult = xmlToCommand($snap['xmlTemplate']);
+ if (!is_array($cmdResult) || empty($cmdResult[0])) {
+ clientDebug('[import] Failed to generate recreate command for ' . $snap['name'], null, 'daemon', 'error');
+ continue;
+ }
+ $cmd = $cmdResult[0];
+ if ($snap['state'] === 'running') {
+ $cmd = str_replace('/docker create ', '/docker run -d ', $cmd);
+ }
+ exec($cmd . ' 2>&1', $cmdOutput, $exitCode);
+ if ($exitCode !== 0) {
+ clientDebug('[import] Failed to recreate container ' . $snap['name'] . ': exit ' . $exitCode, null, 'daemon', 'error');
+ }
+ }
+ };
+
+ // Phase 1: STOP all running/paused containers
+ $stoppedIds = [];
+ $stopFailed = false;
+ $failedName = '';
+ foreach ($containerSnapshots as $snap) {
+ if ($snap['state'] !== 'running' && $snap['state'] !== 'paused') {
+ continue; // Already stopped
+ }
+ exec('docker stop ' . escapeshellarg($snap['id']) . ' 2>&1', $stopOutput, $exitCode);
+ if ($exitCode !== 0) {
+ $stopFailed = true;
+ $failedName = $snap['name'];
+ break;
+ }
+ $stoppedIds[] = $snap['id'];
+ }
+
+ if ($stopFailed) {
+ $rollbackStopped($stoppedIds);
+ $cleanupStack();
+ echo json_encode(['result' => 'error', 'message' => 'Failed to stop container "' . $failedName . '". Import rolled back — no containers were changed.']);
+ break;
+ }
+
+ // Phase 2: REMOVE all containers (if requested)
+ if ($removeContainers) {
+ $removedSnapshots = [];
+ $rmFailed = false;
+ $failedName = '';
+ foreach ($containerSnapshots as $snap) {
+ exec('docker rm -f ' . escapeshellarg($snap['id']) . ' 2>&1', $rmOutput, $exitCode);
+ if ($exitCode !== 0) {
+ $rmFailed = true;
+ $failedName = $snap['name'];
+ break;
+ }
+ $removedSnapshots[] = $snap;
+ }
+
+ if ($rmFailed) {
+ $rollbackRemoved($removedSnapshots);
+ $cleanupStack();
+ echo json_encode(['result' => 'error', 'message' => 'Failed to remove container "' . $failedName . '". Import rolled back — containers restored from Docker Manager templates.']);
+ break;
+ }
+ }
+ }
+
+ echo json_encode([
+ 'result' => 'success',
+ 'message' => 'Stack imported successfully',
+ 'project' => $stackInfo->projectFolder,
+ 'projectName' => $stackInfo->getName(),
+ 'projectPath' => $stackInfo->path,
+ 'startStack' => $startStack ? 1 : 0
+ ]);
+
break;
case 'deleteStack':
$stackName = isset($_POST['stackName']) ? basename(trim($_POST['stackName'])) : "";
diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php
index 3b4d67d..eb96917 100644
--- a/source/compose.manager/include/Util.php
+++ b/source/compose.manager/include/Util.php
@@ -255,6 +255,921 @@ public static function hasTraversal(string $path): bool
}
}
+/**
+ * Import helper for Docker Manager containers.
+ */
+function getDockerManagerImportCandidates(): array
+{
+ require_once "/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php";
+
+ $dockerClient = new DockerClient();
+ $containers = $dockerClient->getDockerContainers();
+ $candidates = [];
+
+ foreach ($containers as $ct) {
+ if (!is_array($ct)) {
+ continue;
+ }
+ $manager = $ct['Manager'] ?? '';
+ if ($manager !== 'dockerman') {
+ continue;
+ }
+ $candidates[] = [
+ 'Id' => $ct['Id'] ?? '',
+ 'Name' => $ct['Name'] ?? '',
+ 'Image' => $ct['Image'] ?? '',
+ 'Status' => $ct['Status'] ?? '',
+ 'Running' => $ct['Running'] ?? false,
+ 'Icon' => $ct['Icon'] ?? '',
+ 'Url' => $ct['Url'] ?? '',
+ 'TSUrl' => $ct['TSUrl'] ?? '',
+ 'Labels' => $ct['Labels'] ?? [],
+ ];
+ }
+
+ return $candidates;
+}
+
+function getDockerManagerContainerInfo(string $id): array
+{
+ require_once "/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php";
+
+ $dockerClient = new DockerClient();
+ $info = $dockerClient->getContainerDetails($id);
+ if (!is_array($info)) {
+ return [];
+ }
+ return $info;
+}
+
+/**
+ * Known port → healthcheck command map.
+ * Keys are "port/proto", values are healthcheck test commands.
+ */
+function getKnownPortHealthchecks(): array
+{
+ return [
+ '80/tcp' => 'curl -f http://localhost:80/ || exit 1',
+ '443/tcp' => 'curl -fk https://localhost:443/ || exit 1',
+ '8080/tcp' => 'curl -f http://localhost:8080/ || exit 1',
+ '8443/tcp' => 'curl -fk https://localhost:8443/ || exit 1',
+ '3000/tcp' => 'curl -f http://localhost:3000/ || exit 1',
+ '9090/tcp' => 'curl -f http://localhost:9090/ || exit 1',
+ '3306/tcp' => 'mysqladmin ping -h localhost || exit 1',
+ '5432/tcp' => 'pg_isready -U postgres || exit 1',
+ '6379/tcp' => 'redis-cli ping || exit 1',
+ '27017/tcp' => 'mongosh --eval "db.runCommand(\"ping\")" --quiet || exit 1',
+ '9200/tcp' => 'curl -f http://localhost:9200/_cluster/health || exit 1',
+ '5672/tcp' => 'rabbitmq-diagnostics -q ping || exit 1',
+ '15672/tcp' => 'curl -f http://localhost:15672/ || exit 1',
+ '1883/tcp' => 'mosquitto_sub -t "\$SYS/#" -C 1 -W 3 || exit 1',
+ '8086/tcp' => 'curl -f http://localhost:8086/ping || exit 1',
+ '9000/tcp' => 'curl -f http://localhost:9000/ || exit 1',
+ ];
+}
+
+/**
+ * Known image name patterns → healthcheck commands (checked when port alone is ambiguous).
+ * Pattern is matched case-insensitively against the image name (without tag).
+ */
+function getImageHealthcheckPatterns(): array
+{
+ return [
+ 'mysql' => ['test' => ['CMD-SHELL', 'mysqladmin ping -h localhost || exit 1']],
+ 'mariadb' => ['test' => ['CMD-SHELL', 'mysqladmin ping -h localhost || exit 1']],
+ 'postgres' => ['test' => ['CMD-SHELL', 'pg_isready -U postgres || exit 1']],
+ 'redis' => ['test' => ['CMD-SHELL', 'redis-cli ping || exit 1']],
+ 'mongo' => ['test' => ['CMD-SHELL', 'mongosh --eval "db.runCommand(\"ping\")" --quiet || exit 1']],
+ 'nginx' => ['test' => ['CMD-SHELL', 'curl -f http://localhost/ || exit 1']],
+ 'httpd' => ['test' => ['CMD-SHELL', 'curl -f http://localhost/ || exit 1']],
+ 'apache' => ['test' => ['CMD-SHELL', 'curl -f http://localhost/ || exit 1']],
+ 'traefik' => ['test' => ['CMD-SHELL', 'traefik healthcheck --ping || exit 1']],
+ 'rabbitmq' => ['test' => ['CMD-SHELL', 'rabbitmq-diagnostics -q ping || exit 1']],
+ 'mosquitto' => ['test' => ['CMD-SHELL', 'mosquitto_sub -t "\\$SYS/#" -C 1 -W 3 || exit 1']],
+ 'influxdb' => ['test' => ['CMD-SHELL', 'curl -f http://localhost:8086/ping || exit 1']],
+ 'elasticsearch' => ['test' => ['CMD-SHELL', 'curl -f http://localhost:9200/_cluster/health || exit 1']],
+ 'memcached' => ['test' => ['CMD-SHELL', 'echo stats | nc localhost 11211 || exit 1']],
+ ];
+}
+
+/**
+ * Guess a healthcheck for a service based on its image name and exposed ports.
+ *
+ * @param string $image Full image string (e.g. "nginx:latest", "linuxserver/mariadb:10")
+ * @param array $exposedPorts Exposed ports from Config.ExposedPorts (e.g. {"80/tcp": {}, "443/tcp": {}})
+ * @param array|null $existingHealthcheck Existing healthcheck from Config.Healthcheck (docker inspect)
+ * @return array|null Compose-format healthcheck array, or null if no guess possible
+ */
+function guessHealthcheck(string $image, array $exposedPorts, ?array $existingHealthcheck = null): ?array
+{
+ // If the container already has a healthcheck defined in its image, convert it to compose format
+ if (!empty($existingHealthcheck) && !empty($existingHealthcheck['Test'])) {
+ $test = $existingHealthcheck['Test'];
+ $hc = ['test' => $test];
+ if (!empty($existingHealthcheck['Interval'])) {
+ // Docker stores intervals in nanoseconds; convert to compose duration string
+ $ns = $existingHealthcheck['Interval'];
+ $hc['interval'] = nanosToComposeDuration($ns);
+ }
+ if (!empty($existingHealthcheck['Timeout'])) {
+ $hc['timeout'] = nanosToComposeDuration($existingHealthcheck['Timeout']);
+ }
+ if (isset($existingHealthcheck['Retries'])) {
+ $hc['retries'] = (int)$existingHealthcheck['Retries'];
+ }
+ if (!empty($existingHealthcheck['StartPeriod'])) {
+ $hc['start_period'] = nanosToComposeDuration($existingHealthcheck['StartPeriod']);
+ }
+ return $hc;
+ }
+
+ $defaults = ['interval' => '30s', 'timeout' => '10s', 'retries' => 3, 'start_period' => '10s'];
+
+ // Try to match by exposed port first (most specific)
+ $portMap = getKnownPortHealthchecks();
+ foreach ($exposedPorts as $portProto => $_) {
+ if (isset($portMap[$portProto])) {
+ return array_merge($defaults, [
+ 'test' => ['CMD-SHELL', $portMap[$portProto]],
+ ]);
+ }
+ }
+
+ // Fall back to image name pattern matching
+ $imageName = strtolower($image);
+ // Strip tag
+ if (($pos = strpos($imageName, ':')) !== false) {
+ $imageName = substr($imageName, 0, $pos);
+ }
+ // Strip registry prefix (e.g. "linuxserver/mariadb" → check against "mariadb")
+ $parts = explode('/', $imageName);
+ $shortName = end($parts);
+
+ $patterns = getImageHealthcheckPatterns();
+ foreach ($patterns as $pattern => $hc) {
+ if (str_contains($shortName, $pattern)) {
+ return array_merge($defaults, $hc);
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Convert Docker nanoseconds to compose duration string (e.g. "30s", "1m30s").
+ */
+function nanosToComposeDuration(int $ns): string
+{
+ $seconds = (int)($ns / 1_000_000_000);
+ if ($seconds <= 0) {
+ return '0s';
+ }
+ if ($seconds >= 60 && $seconds % 60 === 0) {
+ return ($seconds / 60) . 'm';
+ }
+ if ($seconds >= 60) {
+ $m = intdiv($seconds, 60);
+ $s = $seconds % 60;
+ return "{$m}m{$s}s";
+ }
+ return "{$seconds}s";
+}
+
+/**
+ * Parse a port mapping string into structured data.
+ *
+ * @param string $portStr Port string like "8080:80/tcp", "192.168.1.1:8080:80/tcp", "80/tcp"
+ * @return array{hostIp: string, hostPort: string, containerPort: string, protocol: string}
+ */
+function parsePortMapping(string $portStr): array
+{
+ $protocol = 'tcp';
+ if (($slashPos = strrpos($portStr, '/')) !== false) {
+ $protocol = substr($portStr, $slashPos + 1);
+ $portStr = substr($portStr, 0, $slashPos);
+ }
+
+ // Handle bracketed IPv6 host IP: [::1]:8080:80
+ if (str_starts_with($portStr, '[')) {
+ $bracketEnd = strpos($portStr, ']');
+ if ($bracketEnd !== false) {
+ $hostIp = substr($portStr, 1, $bracketEnd - 1);
+ $rest = substr($portStr, $bracketEnd + 1); // e.g. ":8080:80"
+ $rest = ltrim($rest, ':');
+ $parts = explode(':', $rest);
+ if (count($parts) === 2) {
+ return ['hostIp' => $hostIp, 'hostPort' => $parts[0], 'containerPort' => $parts[1], 'protocol' => $protocol];
+ }
+ if (count($parts) === 1 && $parts[0] !== '') {
+ return ['hostIp' => $hostIp, 'hostPort' => '', 'containerPort' => $parts[0], 'protocol' => $protocol];
+ }
+ }
+ }
+
+ $parts = explode(':', $portStr);
+ $count = count($parts);
+
+ if ($count === 3) {
+ return ['hostIp' => $parts[0], 'hostPort' => $parts[1], 'containerPort' => $parts[2], 'protocol' => $protocol];
+ }
+ if ($count === 2) {
+ return ['hostIp' => '', 'hostPort' => $parts[0], 'containerPort' => $parts[1], 'protocol' => $protocol];
+ }
+ // Expose-only (no host binding)
+ return ['hostIp' => '', 'hostPort' => '', 'containerPort' => $parts[0], 'protocol' => $protocol];
+}
+
+/**
+ * Detect host port conflicts across a set of services.
+ *
+ * @param array $services Associative array of serviceName => service definition (with 'ports' key)
+ * @return array Array of conflicts: [{hostPort, protocol, hostIp, services: [name1, name2, ...]}]
+ */
+function detectPortConflicts(array $services): array
+{
+ // Maps grouping key => ['ip' => ..., 'port' => ..., 'proto' => ..., 'services' => [...]]
+ $portMap = [];
+
+ foreach ($services as $serviceName => $service) {
+ if (empty($service['ports']) || !is_array($service['ports'])) {
+ continue;
+ }
+ foreach ($service['ports'] as $portStr) {
+ $parsed = parsePortMapping($portStr);
+ if ($parsed['hostPort'] === '') {
+ continue; // expose-only, no host binding
+ }
+ // Normalize: treat empty and 0.0.0.0 as equivalent
+ $ip = ($parsed['hostIp'] === '' || $parsed['hostIp'] === '0.0.0.0') ? '0.0.0.0' : $parsed['hostIp'];
+ $key = "{$ip}:{$parsed['hostPort']}/{$parsed['protocol']}";
+ if (!isset($portMap[$key])) {
+ $portMap[$key] = [
+ 'hostIp' => $ip,
+ 'hostPort' => $parsed['hostPort'],
+ 'protocol' => $parsed['protocol'],
+ 'services' => [],
+ ];
+ }
+ $portMap[$key]['services'][] = $serviceName;
+ }
+ }
+
+ $conflicts = [];
+ foreach ($portMap as $entry) {
+ if (count($entry['services']) > 1) {
+ $conflicts[] = [
+ 'hostIp' => $entry['hostIp'],
+ 'hostPort' => $entry['hostPort'],
+ 'protocol' => $entry['protocol'],
+ 'services' => array_values(array_unique($entry['services'])),
+ ];
+ }
+ }
+ return $conflicts;
+}
+
+/**
+ * Get available Docker networks from the host.
+ *
+ * @return array Array of ['name' => string, 'driver' => string]
+ */
+function getDockerNetworks(): array
+{
+ $networks = [];
+ exec("docker network ls --format '{{.Name}}\t{{.Driver}}' 2>/dev/null", $outputLines, $exitCode);
+ if ($exitCode !== 0) {
+ clientDebug('Failed to list Docker networks (exit code ' . $exitCode . ')', null, 'daemon', 'warning');
+ return [];
+ }
+ foreach ($outputLines as $line) {
+ $parts = explode("\t", $line);
+ if (count($parts) === 2) {
+ $networks[] = ['name' => $parts[0], 'driver' => $parts[1]];
+ }
+ }
+ return $networks;
+}
+
+function dockerContainerToComposeService(array $info): array
+{
+ $service = [];
+ $originalName = ltrim(trim($info['Name'] ?? $info['Id'] ?? ''), '/');
+ $serviceName = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+ if ($serviceName === '') {
+ $serviceName = 'container_' . substr($info['Id'] ?? 'unknown', 0, 12);
+ }
+
+ // Store container_name explicitly so each container gets a deterministic name
+ $service['container_name'] = $serviceName;
+
+ $config = $info['Config'] ?? [];
+ $hostConfig = $info['HostConfig'] ?? [];
+
+ $service['image'] = $config['Image'] ?? '';
+
+ // Do not include explicit command/entrypoint by default in generated compose
+ // because Compose can use defaults from the image; this avoids embedding Unraid startup semantics.
+
+ if (!empty($config['Env']) && is_array($config['Env'])) {
+ $env = [];
+ $blacklist = ['TERM', 'LANG', 'HOME', 'PATH', 'HOST_OS', 'HOST_HOSTNAME', 'HOST_CONTAINERNAME'];
+ foreach ($config['Env'] as $entry) {
+ if (!str_contains($entry, '=')) {
+ continue;
+ }
+ [$key, $value] = explode('=', $entry, 2);
+ if (in_array($key, $blacklist, true)) {
+ continue;
+ }
+ $env[$key] = $value;
+ }
+ if (!empty($env)) {
+ // Replace values with env variable expansion in compose file, values go in .env file.
+ $service['environment'] = array_map(function ($key) {
+ return sprintf('%s=${%s}', $key, $key);
+ }, array_keys($env));
+ // We store the resolved env set as a reserved field to generate .env file later.
+ $service['__resolved_env'] = $env;
+ }
+ }
+
+ if (!empty($hostConfig['RestartPolicy']['Name'])) {
+ $policy = $hostConfig['RestartPolicy']['Name'];
+ if ($policy !== 'no') {
+ $service['restart'] = $policy;
+ }
+ }
+
+ $ports = [];
+ if (!empty($hostConfig['PortBindings']) && is_array($hostConfig['PortBindings'])) {
+ foreach ($hostConfig['PortBindings'] as $port => $bindings) {
+ [$privatePort, $proto] = array_pad(explode('/', $port), 2, 'tcp');
+ foreach ((array)$bindings as $bind) {
+ if (!is_array($bind)) continue;
+ $hostIp = $bind['HostIp'] ?? '';
+ $hostPort = $bind['HostPort'] ?? '';
+ if ($hostIp !== '' && $hostIp !== '0.0.0.0') {
+ // Bracket IPv6 addresses for valid Compose port syntax
+ $formattedIp = str_contains($hostIp, ':') ? "[{$hostIp}]" : $hostIp;
+ $ports[] = "{$formattedIp}:{$hostPort}:{$privatePort}/{$proto}";
+ } elseif ($hostPort !== '') {
+ $ports[] = "{$hostPort}:{$privatePort}/{$proto}";
+ } else {
+ $ports[] = "{$privatePort}/{$proto}";
+ }
+ }
+ }
+ }
+ if (!empty($ports)) {
+ $service['ports'] = $ports;
+ }
+
+ $volumes = [];
+ if (!empty($info['Mounts']) && is_array($info['Mounts'])) {
+ foreach ($info['Mounts'] as $mount) {
+ $source = $mount['Source'] ?? '';
+ $target = $mount['Destination'] ?? '';
+ if ($source !== '' && $target !== '') {
+ $mode = '';
+ if (array_key_exists('RW', $mount) && $mount['RW'] === false) {
+ $mode = ':ro';
+ }
+ $volumes[] = "{$source}:{$target}{$mode}";
+ }
+ }
+ }
+ if (!empty($volumes)) {
+ $service['volumes'] = $volumes;
+ }
+
+ $labels = [];
+ if (!empty($config['Labels']) && is_array($config['Labels'])) {
+ foreach ($config['Labels'] as $key => $val) {
+ // Skip internal Compose Manager markers and helper labels
+ if (in_array($key, ['net.unraid.docker.managed', 'net.unraid.docker.icon', 'net.unraid.docker.webui'], true)) {
+ continue;
+ }
+
+ // Skip uncaught LinuxServer/OCI metadata that is not needed in compose services
+ if ($key === 'build_version' || $key === 'maintainer') {
+ continue;
+ }
+ $excludePrefixes = [
+ 'org.opencontainers.image.',
+ 'org.label-schema.',
+ 'com.docker.compose.',
+ ];
+ $skip = false;
+ foreach ($excludePrefixes as $prefix) {
+ if (str_starts_with($key, $prefix)) {
+ $skip = true;
+ break;
+ }
+ }
+ if ($skip) {
+ continue;
+ }
+
+ $labels[$key] = $val;
+ }
+ }
+ if (!empty($labels)) {
+ $service['labels'] = $labels;
+ }
+
+ if (!empty($hostConfig['NetworkMode'])) {
+ $networkMode = $hostConfig['NetworkMode'];
+ if ($networkMode !== 'default') {
+ if (in_array($networkMode, ['host', 'bridge', 'none'])) {
+ $service['network_mode'] = $networkMode;
+ } elseif (str_starts_with($networkMode, 'container:')) {
+ $service['network_mode'] = $networkMode;
+ }
+ }
+ }
+
+ // Extract named networks from NetworkSettings (including static IPs)
+ $networks = $info['NetworkSettings']['Networks'] ?? [];
+ if (is_array($networks) && !isset($service['network_mode'])) {
+ $namedNetworks = [];
+ $networkIPs = [];
+ foreach ($networks as $netName => $netInfo) {
+ if (!in_array($netName, ['bridge', 'host', 'none'], true)) {
+ $namedNetworks[] = $netName;
+ // Preserve static IP from IPAMConfig (user-assigned, not DHCP)
+ $staticIP = $netInfo['IPAMConfig']['IPv4Address'] ?? '';
+ if ($staticIP !== '') {
+ $networkIPs[$netName] = $staticIP;
+ }
+ }
+ }
+ if (!empty($namedNetworks)) {
+ $service['networks'] = $namedNetworks;
+ }
+ if (!empty($networkIPs)) {
+ $service['__network_ips'] = $networkIPs;
+ }
+ }
+
+ // Extract exposed ports (for healthcheck guessing)
+ $exposedPorts = $config['ExposedPorts'] ?? [];
+ if (is_array($exposedPorts)) {
+ $service['__exposed_ports'] = $exposedPorts;
+ }
+
+ // Extract existing healthcheck from image, or guess one
+ $existingHC = $config['Healthcheck'] ?? null;
+ $guessed = guessHealthcheck($service['image'], $exposedPorts, $existingHC);
+ if ($existingHC && !empty($existingHC['Test'])) {
+ // Use the image-defined healthcheck (already converted by guessHealthcheck)
+ if ($guessed) {
+ $service['healthcheck'] = $guessed;
+ $service['__healthcheck_source'] = 'image';
+ }
+ } elseif ($guessed) {
+ // Store as a guess that the user can accept or reject
+ $service['__guessed_healthcheck'] = $guessed;
+ $service['__healthcheck_source'] = 'auto';
+ }
+
+ return ['name' => $serviceName, 'originalName' => $originalName, 'service' => $service];
+}
+
+/**
+ * Fetch, convert, and deduplicate Docker Manager containers into compose services.
+ *
+ * Shared by generateImportData and finalizeImportCompose to avoid duplicating
+ * the container-fetch → convert → label-extract → dedup loop.
+ *
+ * @param string[] $containerIds Docker container IDs to process
+ * @return array{services: array, inspectData: array} services keyed by deduped name,
+ * inspectData keyed by same name → raw docker inspect array
+ */
+function buildImportServicesFromIds(array $containerIds): array
+{
+ $services = [];
+ $inspectData = [];
+ foreach ($containerIds as $id) {
+ $id = trim($id);
+ if ($id === '') {
+ continue;
+ }
+ $info = getDockerManagerContainerInfo($id);
+ if (empty($info)) {
+ continue;
+ }
+ $converted = dockerContainerToComposeService($info);
+ $name = $converted['name'];
+ $service = $converted['service'];
+
+ $serviceIcon = $info['Config']['Labels']['net.unraid.docker.icon'] ?? '';
+ $serviceWebui = $info['Config']['Labels']['net.unraid.docker.webui'] ?? '';
+ if ($serviceIcon) {
+ $service['icon'] = $serviceIcon;
+ }
+ if ($serviceWebui) {
+ $service['webui'] = $serviceWebui;
+ }
+
+ // Deduplicate service keys
+ $baseName = $name;
+ $append = 1;
+ while (isset($services[$name])) {
+ $name = $baseName . '_' . $append;
+ $append++;
+ }
+ $services[$name] = $service;
+ $inspectData[$name] = $info;
+ }
+ return ['services' => $services, 'inspectData' => $inspectData];
+}
+
+/**
+ * Quote a YAML scalar value if it contains special characters.
+ *
+ * Preserves native int/float types so that fields like healthcheck.retries
+ * are emitted as bare numbers (3) rather than quoted strings ("3").
+ */
+function yamlQuoteValue($value): string
+{
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+ // Preserve actual numeric types as bare YAML scalars
+ if (is_int($value) || is_float($value)) {
+ return (string)$value;
+ }
+ $str = (string)$value;
+ if ($str === '') {
+ return '""';
+ }
+ // Quote if value contains YAML-special characters or could be misinterpreted
+ if (preg_match('/[:\#{}\[\],&*?|>!\'"%@`]/', $str)
+ || preg_match('/^[\s-]/', $str)
+ || preg_match('/\s$/', $str)
+ || in_array(strtolower($str), ['true', 'false', 'null', 'yes', 'no', 'on', 'off'], true)
+ || is_numeric($str)
+ ) {
+ return '"' . str_replace(['\\', '"', "\n", "\t"], ['\\\\', '\\"', '\\n', '\\t'], $str) . '"';
+ }
+ return $str;
+}
+
+function dockerServicesToComposeYml(array $services, array $wizardConfig = []): string
+{
+ $yaml = "services:\n";
+ $allNetworks = [];
+ $externalNetworks = []; // networks declared as external: true
+ $stackNetworks = []; // networks created by the stack (driver: bridge)
+
+ $containerNames = $wizardConfig['containerNames'] ?? [];
+ $netCfg = $wizardConfig['networkConfig'] ?? [];
+ $healthchecks = $wizardConfig['healthchecks'] ?? [];
+ $dependencies = $wizardConfig['dependencies'] ?? [];
+
+ $stackNetEnabled = !empty($netCfg['stackNetwork']['enabled']);
+ $stackNetName = $netCfg['stackNetwork']['name'] ?? '';
+ $externalNetList = $netCfg['externalNetworks'] ?? [];
+ $perServiceNet = $netCfg['perService'] ?? [];
+
+ // If wizard config provides a stack network, track it
+ if ($stackNetEnabled && $stackNetName !== '') {
+ $stackNetworks[$stackNetName] = true;
+ }
+ // Track external networks from wizard
+ foreach ($externalNetList as $extNet) {
+ if (is_string($extNet) && $extNet !== '') {
+ $externalNetworks[$extNet] = true;
+ }
+ }
+
+ // Internal fields to skip when emitting YAML
+ $internalKeys = [
+ '__resolved_env', '__exposed_ports', '__guessed_healthcheck',
+ '__healthcheck_source', '__network_ips', 'icon', 'webui',
+ ];
+
+ foreach ($services as $name => $service) {
+ $yaml .= " " . $name . ":\n";
+
+ // Apply container name override from wizard
+ if (isset($containerNames[$name]) && $containerNames[$name] !== '') {
+ $service['container_name'] = $containerNames[$name];
+ }
+
+ // Apply network configuration from wizard (if provided)
+ if (!empty($wizardConfig) && !empty($perServiceNet)) {
+ $svcNetCfg = $perServiceNet[$name] ?? [];
+ $svcNetMode = $svcNetCfg['networkMode'] ?? null;
+
+ if ($svcNetMode && $svcNetMode !== 'default') {
+ // Explicit network_mode overrides any networks setting
+ $service['network_mode'] = $svcNetMode;
+ unset($service['networks']);
+ } else {
+ // Remove any pre-existing network_mode if wizard says "default"
+ unset($service['network_mode']);
+
+ $serviceNetworks = [];
+ $svcIPv4 = $svcNetCfg['ipv4Addresses'] ?? [];
+ // Attach to stack network if enabled
+ if ($stackNetEnabled && $stackNetName !== '' && !empty($svcNetCfg['attachStackNet'])) {
+ $serviceNetworks[] = $stackNetName;
+ }
+ // Attach to selected external networks
+ $svcExternalNets = $svcNetCfg['externalNets'] ?? [];
+ foreach ($svcExternalNets as $extNet) {
+ if (is_string($extNet) && $extNet !== '' && !in_array($extNet, $serviceNetworks, true)) {
+ $serviceNetworks[] = $extNet;
+ $externalNetworks[$extNet] = true;
+ }
+ }
+ if (!empty($serviceNetworks)) {
+ // Use mapping format if any network has a static IP
+ $hasIPs = false;
+ foreach ($serviceNetworks as $netName) {
+ if (!empty($svcIPv4[$netName])) {
+ $hasIPs = true;
+ break;
+ }
+ }
+ if ($hasIPs) {
+ $netMap = [];
+ foreach ($serviceNetworks as $netName) {
+ $ip = $svcIPv4[$netName] ?? '';
+ $netMap[$netName] = $ip !== '' ? ['ipv4_address' => $ip] : null;
+ }
+ $service['networks'] = $netMap;
+ } else {
+ $service['networks'] = $serviceNetworks;
+ }
+ } else {
+ unset($service['networks']);
+ }
+ }
+ }
+
+ // Apply healthcheck from wizard
+ if (!empty($wizardConfig) && array_key_exists($name, $healthchecks)) {
+ $hc = $healthchecks[$name];
+ if ($hc === null || $hc === false) {
+ // User explicitly removed the healthcheck
+ unset($service['healthcheck']);
+ unset($service['__guessed_healthcheck']);
+ } else {
+ $service['healthcheck'] = $hc;
+ unset($service['__guessed_healthcheck']);
+ }
+ } elseif (empty($wizardConfig)) {
+ // No wizard config (legacy path) — don't auto-apply guessed healthchecks
+ unset($service['__guessed_healthcheck']);
+ }
+
+ // Apply depends_on from wizard
+ if (!empty($dependencies[$name]) && is_array($dependencies[$name])) {
+ $dependsOn = [];
+ foreach ($dependencies[$name] as $dep) {
+ $depService = $dep['service'] ?? '';
+ $depCondition = $dep['condition'] ?? 'service_started';
+ if ($depService !== '' && isset($services[$depService])) {
+ $dependsOn[$depService] = ['condition' => $depCondition];
+ }
+ }
+ if (!empty($dependsOn)) {
+ $service['depends_on'] = $dependsOn;
+ }
+ }
+
+ // Collect all referenced networks for the top-level block
+ if (!empty($service['networks']) && is_array($service['networks'])) {
+ if (array_is_list($service['networks'])) {
+ foreach ($service['networks'] as $networkName) {
+ if (!in_array($networkName, $allNetworks, true)) {
+ $allNetworks[] = $networkName;
+ }
+ }
+ } else {
+ // Mapping format (network => config or null)
+ foreach (array_keys($service['networks']) as $networkName) {
+ if (!in_array($networkName, $allNetworks, true)) {
+ $allNetworks[] = $networkName;
+ }
+ }
+ }
+ }
+
+ // Emit service fields
+ foreach ($service as $key => $value) {
+ if (in_array($key, $internalKeys, true)) {
+ continue;
+ }
+ if ($key === 'depends_on' && is_array($value) && !array_is_list($value)) {
+ // depends_on with conditions: nested map format
+ $yaml .= " depends_on:\n";
+ foreach ($value as $depName => $depConfig) {
+ $yaml .= " " . $depName . ":\n";
+ if (is_array($depConfig)) {
+ foreach ($depConfig as $dk => $dv) {
+ $yaml .= " " . $dk . ": " . yamlQuoteValue($dv) . "\n";
+ }
+ }
+ }
+ continue;
+ }
+ if ($key === 'healthcheck' && is_array($value)) {
+ $yaml .= " healthcheck:\n";
+ foreach ($value as $hcKey => $hcVal) {
+ // Skip private metadata keys (e.g. __originalTestType)
+ if (is_string($hcKey) && str_starts_with($hcKey, '__')) {
+ continue;
+ }
+ if ($hcKey === 'test' && is_array($hcVal)) {
+ $yaml .= " test:\n";
+ foreach ($hcVal as $testItem) {
+ $yaml .= " - " . yamlQuoteValue($testItem) . "\n";
+ }
+ } elseif (is_string($hcVal) || is_numeric($hcVal)) {
+ $yaml .= " $hcKey: " . yamlQuoteValue($hcVal) . "\n";
+ }
+ }
+ continue;
+ }
+ if ($key === 'networks' && is_array($value) && !array_is_list($value)) {
+ // Networks mapping format with per-network config (e.g. ipv4_address)
+ $yaml .= " networks:\n";
+ foreach ($value as $netName => $netConfig) {
+ if (is_array($netConfig) && !empty($netConfig)) {
+ $yaml .= " " . $netName . ":\n";
+ foreach ($netConfig as $nk => $nv) {
+ $yaml .= " " . $nk . ": " . yamlQuoteValue($nv) . "\n";
+ }
+ } else {
+ // No config for this network — emit as simple entry
+ $yaml .= " " . $netName . ":\n";
+ }
+ }
+ continue;
+ }
+ if (is_string($value) || is_numeric($value) || is_bool($value)) {
+ $yaml .= " $key: " . yamlQuoteValue($value) . "\n";
+ } elseif (is_array($value)) {
+ $yaml .= " $key:\n";
+ if (array_is_list($value)) {
+ foreach ($value as $item) {
+ $yaml .= " - " . yamlQuoteValue($item) . "\n";
+ }
+ } else {
+ foreach ($value as $subKey => $subVal) {
+ $yaml .= " " . $subKey . ": " . yamlQuoteValue($subVal) . "\n";
+ }
+ }
+ }
+ }
+ }
+
+ // Emit top-level networks block
+ $hasNetworks = !empty($allNetworks) || !empty($stackNetworks) || !empty($externalNetworks);
+ if ($hasNetworks) {
+ $yaml .= "\nnetworks:\n";
+ // Stack-created networks (bridge driver)
+ foreach ($stackNetworks as $netName => $_) {
+ $yaml .= " " . $netName . ":\n";
+ $yaml .= " driver: bridge\n";
+ }
+ // External networks
+ foreach ($allNetworks as $networkName) {
+ if (isset($stackNetworks[$networkName])) {
+ continue; // Already emitted as stack network
+ }
+ $yaml .= " " . $networkName . ":\n";
+ $yaml .= " external: true\n";
+ }
+ // Any external networks from wizard not yet emitted
+ foreach ($externalNetworks as $netName => $_) {
+ if (isset($stackNetworks[$netName]) || in_array($netName, $allNetworks, true)) {
+ continue;
+ }
+ $yaml .= " " . $netName . ":\n";
+ $yaml .= " external: true\n";
+ }
+ }
+
+ return $yaml;
+}
+
+function dockerResolveEnvAndCompose(array &$services): string
+{
+ $globalEnv = [];
+
+ // Track environment values by canonical variable to avoid duplicates for same values across multiple services
+ $valueKeyMap = [];
+
+ foreach ($services as $serviceName => &$service) {
+ $serviceEnvItems = [];
+
+ if (!empty($service['__resolved_env']) && is_array($service['__resolved_env'])) {
+ foreach ($service['__resolved_env'] as $k => $v) {
+ if ($k === '') {
+ continue;
+ }
+
+ // If single value reused exactly, reuse same variable name (dedupe name and value)
+ if (isset($valueKeyMap[$k][$v])) {
+ $resolvedKey = $valueKeyMap[$k][$v];
+ } else {
+ if (!array_key_exists($k, $globalEnv)) {
+ $globalEnv[$k] = $v;
+ $resolvedKey = $k;
+ } elseif ($globalEnv[$k] === $v) {
+ $resolvedKey = $k;
+ } else {
+ $suffix = 1;
+ $resolvedKey = $k . '_' . $suffix;
+ while (array_key_exists($resolvedKey, $globalEnv)) {
+ if ($globalEnv[$resolvedKey] === $v) {
+ break;
+ }
+ $suffix++;
+ $resolvedKey = $k . '_' . $suffix;
+ }
+ if (!array_key_exists($resolvedKey, $globalEnv)) {
+ $globalEnv[$resolvedKey] = $v;
+ }
+ }
+ $valueKeyMap[$k][$v] = $resolvedKey;
+ }
+
+ $serviceEnvItems[] = sprintf('%s=${%s}', $k, $resolvedKey);
+ }
+ }
+
+ if (!empty($serviceEnvItems)) {
+ $service['environment'] = $serviceEnvItems;
+ }
+
+ // Cleanup internal field before rendering
+ unset($service['__resolved_env']);
+ }
+ unset($service);
+
+ // Build per-service lookup: envKey → [serviceName, ...]
+ $keyServices = [];
+ foreach ($services as $svcName => $svc) {
+ if (!empty($svc['environment']) && is_array($svc['environment'])) {
+ foreach ($svc['environment'] as $entry) {
+ if (preg_match('/^[^=]+=\$\{(.+)\}$/', $entry, $m)) {
+ $keyServices[$m[1]][] = $svcName;
+ }
+ }
+ }
+ }
+
+ $lines = [];
+ $lastServices = null;
+ foreach ($globalEnv as $k => $v) {
+ // Add service-group comment when the owning service(s) change
+ $owners = $keyServices[$k] ?? [];
+ sort($owners);
+ if ($owners !== $lastServices) {
+ if (!empty($lines)) {
+ $lines[] = '';
+ }
+ $lines[] = '# ' . implode(', ', $owners);
+ $lastServices = $owners;
+ }
+ // Quote values that contain special characters to prevent .env parsing issues
+ if (preg_match('/[\s#$"\\\n]/', $v) || $v === '') {
+ $escaped = str_replace(['\\', '"', '$', "\n"], ['\\\\', '\\"', '\\$', '\\n'], $v);
+ $lines[] = "$k=\"$escaped\"";
+ } else {
+ $lines[] = "$k=$v";
+ }
+ }
+ return implode("\n", $lines) . (empty($lines) ? '' : "\n");
+}
+
+function dockerAddOverrideIcons(array $importedServices): string
+{
+ $yaml = "services:\n";
+ foreach ($importedServices as $serviceName => $service) {
+ $labels = ['net.unraid.docker.managed' => 'composeman'];
+ $icon = $service['icon'] ?? null;
+ $webui = $service['webui'] ?? null;
+ if ($icon) {
+ $labels['net.unraid.docker.icon'] = $icon;
+ }
+ if ($webui) {
+ $labels['net.unraid.docker.webui'] = $webui;
+ }
+
+ $yaml .= " " . $serviceName . ":\n";
+ $yaml .= " labels:\n";
+ foreach ($labels as $key => $val) {
+ $yaml .= " " . $key . ": " . yamlQuoteValue($val) . "\n";
+ }
+ }
+ return $yaml;
+}
function pruneOverrideContentServices(string $overrideContent, array $validServices): array
{
diff --git a/source/compose.manager/sheets/ComboButton.css b/source/compose.manager/sheets/ComboButton.css
index 32fe4c3..aca23b0 100644
--- a/source/compose.manager/sheets/ComboButton.css
+++ b/source/compose.manager/sheets/ComboButton.css
@@ -312,6 +312,599 @@
width: 8%;
}
+ /* ============================================
+ Import Wizard — 5-Stage Stepper (Basic / Advanced)
+ ============================================ */
+
+ .import-wizard-stepper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px 24px;
+ gap: 0;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--alt-background-color);
+ flex-wrap: wrap;
+ }
+
+ .import-wizard-step {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.85rem;
+ color: var(--alt-text-color);
+ white-space: nowrap;
+ opacity: 0.5;
+ transition: opacity 0.2s ease, color 0.2s ease;
+ }
+
+ .import-wizard-step.active {
+ opacity: 1;
+ color: var(--brand-orange);
+ font-weight: 600;
+ }
+
+ .import-wizard-step.completed {
+ opacity: 0.8;
+ color: var(--status-success);
+ }
+
+ .import-wizard-step-number {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ font-size: 0.75rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ }
+
+ .import-wizard-step.completed .import-wizard-step-number {
+ background: var(--status-success);
+ color: var(--button-text-color, #fff);
+ border-color: var(--status-success);
+ }
+
+ .import-wizard-step.active .import-wizard-step-number {
+ background: var(--brand-orange);
+ color: var(--button-text-color, #fff);
+ border-color: var(--brand-orange);
+ }
+
+ .import-wizard-step-connector {
+ width: 24px;
+ height: 2px;
+ background: var(--border-color);
+ margin: 0 6px;
+ flex-shrink: 0;
+ }
+
+ .import-wizard-step.completed + .import-wizard-step-connector {
+ background: var(--status-success);
+ }
+
+ /* ============================================
+ Import Wizard — Service Configuration Cards
+ ============================================ */
+
+ .import-service-card {
+ background-color: var(--dynamix-tablesorter-tbody-row-alt-bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ margin-bottom: 16px;
+ overflow: hidden;
+ }
+
+ .import-service-card-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 18px;
+ background: var(--alt-background-color);
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .import-service-card-header:hover {
+ background: var(--dynamix-tablesorter-tbody-row-alt-bg-color);
+ }
+
+ .import-service-card-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+ object-fit: contain;
+ background: var(--background-color);
+ padding: 2px;
+ flex-shrink: 0;
+ }
+
+ .import-service-card-title {
+ font-weight: 600;
+ font-size: 1.05rem;
+ color: var(--text-color);
+ flex: 1;
+ }
+
+ .import-service-card-image {
+ font-size: 0.85rem;
+ color: var(--alt-text-color);
+ font-family: var(--font-bitstream);
+ }
+
+ .import-service-card-toggle {
+ color: var(--alt-text-color);
+ font-size: 0.85rem;
+ transition: transform 0.2s ease;
+ }
+
+ .import-service-card.expanded .import-service-card-toggle {
+ transform: rotate(90deg);
+ }
+
+ .import-service-card-body {
+ padding: 18px;
+ display: none;
+ }
+
+ .import-service-card.expanded .import-service-card-body {
+ display: block;
+ }
+
+ .import-field-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 14px;
+ flex-wrap: wrap;
+ }
+
+ .import-field-row:last-child {
+ margin-bottom: 0;
+ }
+
+ .import-field {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 180px;
+ }
+
+ .import-field label {
+ font-size: 0.9rem;
+ color: var(--alt-text-color);
+ margin-bottom: 6px;
+ font-weight: 500;
+ }
+
+ .import-field input[type="text"],
+ .import-field select {
+ padding: 8px 12px;
+ background-color: var(--input-background-color);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-color);
+ font-size: 0.95rem;
+ transition: border-color 0.2s ease;
+ }
+
+ .import-field input:focus,
+ .import-field select:focus {
+ outline: none;
+ border-color: var(--brand-orange);
+ box-shadow: 0 0 0 2px rgba(227, 120, 33, 0.15);
+ }
+
+ .import-field input.import-name-error {
+ border-color: var(--status-error);
+ box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.15);
+ }
+
+ .import-field .import-field-error {
+ color: var(--status-error);
+ font-size: 0.8rem;
+ margin-top: 4px;
+ }
+
+ /* Port conflict badge */
+ .import-conflict-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ background: rgba(255, 152, 0, 0.15);
+ color: var(--status-warning, #ff9800);
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ }
+
+ .import-conflict-banner {
+ padding: 10px 16px;
+ margin-bottom: 16px;
+ background: rgba(255, 152, 0, 0.1);
+ border: 1px solid rgba(255, 152, 0, 0.3);
+ border-radius: 6px;
+ color: var(--status-warning, #ff9800);
+ font-size: 0.9rem;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .import-conflict-banner i {
+ margin-top: 2px;
+ flex-shrink: 0;
+ }
+
+ /* Healthcheck collapsible section */
+ .import-healthcheck-section {
+ margin-top: 12px;
+ padding: 12px;
+ background: var(--background-color);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ }
+
+ .import-healthcheck-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .import-healthcheck-header span {
+ font-weight: 500;
+ font-size: 0.9rem;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .import-healthcheck-source {
+ font-size: 0.75rem;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ }
+
+ .import-healthcheck-source.from-image {
+ background: rgba(76, 175, 80, 0.15);
+ color: var(--status-success);
+ }
+
+ .import-healthcheck-source.auto-detected {
+ background: rgba(33, 150, 243, 0.15);
+ color: var(--status-info, #2196f3);
+ }
+
+ .import-healthcheck-source.none {
+ background: var(--dynamix-tablesorter-tbody-row-alt-bg-color);
+ color: var(--alt-text-color);
+ }
+
+ .import-healthcheck-fields {
+ display: none;
+ margin-top: 10px;
+ gap: 10px;
+ }
+
+ .import-healthcheck-section.expanded .import-healthcheck-fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ }
+
+ .import-healthcheck-fields .import-field {
+ min-width: 120px;
+ }
+
+ .import-healthcheck-fields .import-field.full-width {
+ grid-column: 1 / -1;
+ min-width: 100%;
+ }
+
+ /* Network attachment checkboxes */
+ .import-net-checks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 4px;
+ }
+
+ .import-net-checks label {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 0.85rem;
+ color: var(--text-color);
+ cursor: pointer;
+ }
+
+ .import-net-checks input[type="checkbox"] {
+ margin: 0;
+ }
+
+ .import-net-checks label.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ /* Ports read-only list */
+ .import-port-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 4px;
+ }
+
+ .import-port-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ background: var(--background-color);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ font-size: 0.82rem;
+ font-family: var(--font-bitstream);
+ }
+
+ .import-port-tag.conflict {
+ border-color: var(--status-warning, #ff9800);
+ background: rgba(255, 152, 0, 0.08);
+ }
+
+ /* ============================================
+ Import Wizard — Dependencies Stage
+ ============================================ */
+
+ .import-deps-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+ }
+
+ .import-deps-table th {
+ text-align: left;
+ padding: 8px 12px;
+ background: var(--alt-background-color);
+ border-bottom: 2px solid var(--border-color);
+ font-weight: 600;
+ color: var(--alt-text-color);
+ font-size: 0.85rem;
+ }
+
+ .import-deps-table td {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border-color);
+ vertical-align: top;
+ }
+
+ .import-dep-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+ }
+
+ .import-dep-row:last-child {
+ margin-bottom: 0;
+ }
+
+ .import-dep-row select {
+ padding: 6px 10px;
+ background-color: var(--input-background-color);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-color);
+ font-size: 0.85rem;
+ }
+
+ .import-dep-row select:focus {
+ outline: none;
+ border-color: var(--brand-orange);
+ }
+
+ .import-dep-add {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ font-size: 0.8rem;
+ color: var(--brand-orange);
+ background: none;
+ border: 1px dashed var(--brand-orange);
+ border-radius: 4px;
+ cursor: pointer;
+ margin-top: 4px;
+ }
+
+ .import-dep-add:hover {
+ background: rgba(227, 120, 33, 0.1);
+ }
+
+ .import-dep-remove {
+ background: none;
+ border: none;
+ color: var(--status-error);
+ cursor: pointer;
+ padding: 4px;
+ font-size: 0.9rem;
+ }
+
+ .import-dep-remove:hover {
+ color: var(--status-error-hover, #f44336);
+ }
+
+ .import-dep-cycle-error {
+ padding: 10px 16px;
+ margin-bottom: 12px;
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.3);
+ border-radius: 6px;
+ color: var(--status-error);
+ font-size: 0.9rem;
+ }
+
+ .import-startup-order {
+ padding: 10px 16px;
+ margin-top: 12px;
+ background: rgba(76, 175, 80, 0.08);
+ border: 1px solid rgba(76, 175, 80, 0.2);
+ border-radius: 6px;
+ color: var(--status-success);
+ font-size: 0.88rem;
+ }
+
+ .import-startup-order span {
+ font-weight: 600;
+ }
+
+ /* ============================================
+ Import Wizard — Validation Stage
+ ============================================ */
+
+ .import-validation-bar {
+ padding: 12px 18px;
+ margin-bottom: 16px;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 500;
+ }
+
+ .import-validation-bar.valid {
+ background: rgba(76, 175, 80, 0.1);
+ border: 1px solid rgba(76, 175, 80, 0.3);
+ color: var(--status-success);
+ }
+
+ .import-validation-bar.error {
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.3);
+ color: var(--status-error);
+ }
+
+ .import-preview-section {
+ margin-bottom: 16px;
+ }
+
+ .import-preview-section-title {
+ font-weight: 600;
+ font-size: 0.95rem;
+ margin-bottom: 8px;
+ color: var(--text-color);
+ }
+
+ .import-preview-pre {
+ max-height: 300px;
+ overflow: auto;
+ background: var(--editor-background-color, var(--background-color));
+ color: var(--editor-text-color, var(--text-color));
+ padding: 12px 16px;
+ border-radius: 6px;
+ font-size: 0.88rem;
+ font-family: var(--font-bitstream);
+ line-height: 1.5;
+ white-space: pre;
+ border: 1px solid var(--border-color);
+ }
+
+ .import-summary-panel {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ .import-summary-item {
+ padding: 10px 14px;
+ background: var(--dynamix-tablesorter-tbody-row-alt-bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ font-size: 0.88rem;
+ }
+
+ .import-summary-item strong {
+ color: var(--brand-orange);
+ display: block;
+ font-size: 0.8rem;
+ margin-bottom: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ }
+
+ /* Generating spinner */
+ .import-generating {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ gap: 16px;
+ color: var(--alt-text-color);
+ font-size: 1.1rem;
+ }
+
+ .import-generating .fa-spinner {
+ font-size: 2.5rem;
+ color: var(--brand-orange);
+ }
+
+ /* External network add custom */
+ .import-ext-net-add {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .import-ext-net-add input {
+ padding: 6px 10px;
+ background-color: var(--input-background-color);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-color);
+ font-size: 0.88rem;
+ flex: 1;
+ max-width: 250px;
+ }
+
+ .import-ext-net-add button {
+ padding: 6px 12px;
+ background: var(--brand-orange);
+ color: var(--button-text-color, #fff);
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.85rem;
+ }
+
+ .import-ext-net-add button:hover {
+ opacity: 0.85;
+ }
+
+ /* Wizard modal sizing override */
+ .compose-modal.import-wizard-modal {
+ max-width: 880px;
+ width: 95%;
+ }
+
+ .compose-modal.import-wizard-modal .compose-modal-body {
+ max-height: calc(100vh - 200px);
+ overflow-y: auto;
+ }
+
+
.compose-ct-table .ct-col-ip {
width: 8%;
}
diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php
index 473ba7e..0162df1 100644
--- a/tests/unit/ExecActionsTest.php
+++ b/tests/unit/ExecActionsTest.php
@@ -16,6 +16,7 @@
use PluginTests\TestCase;
use PluginTests\Mocks\FunctionMocks;
+use PluginTests\Mocks\DockerUtilMock;
require_once '/usr/local/emhttp/plugins/compose.manager/include/Util.php';
@@ -113,6 +114,36 @@ private function createTestStack(string $name, array $files = []): string
return $stackPath;
}
+ /**
+ * Build a minimal Docker inspect-style container array suitable for
+ * DockerUtilMock and dockerContainerToComposeService().
+ */
+ private function makeContainer(string $id, string $name, string $image, array $extra = []): array
+ {
+ return array_replace_recursive([
+ 'Id' => $id,
+ 'Name' => '/' . $name,
+ 'Image' => $image,
+ 'Manager' => 'dockerman',
+ 'Running' => true,
+ 'Status' => 'Up 2 hours',
+ 'Config' => [
+ 'Image' => $image,
+ 'Env' => [],
+ 'Labels' => [],
+ 'ExposedPorts' => [],
+ ],
+ 'HostConfig' => [
+ 'PortBindings' => [],
+ 'Binds' => [],
+ 'RestartPolicy' => ['Name' => ''],
+ 'NetworkMode' => 'bridge',
+ ],
+ 'Mounts' => [],
+ 'NetworkSettings' => ['Networks' => []],
+ ], $extra);
+ }
+
// ===========================================
// changeName Action Tests
// ===========================================
@@ -750,4 +781,204 @@ public function testCheckStackLockReturnsFalseWhenNoLock(): void
$this->assertEquals('success', $result['result']);
$this->assertFalse($result['locked']);
}
+
+ // ===========================================
+ // performImportTransfer Action Tests
+ // ===========================================
+
+ public function testPerformImportTransferMissingStackName(): void
+ {
+ $output = $this->executeAction('performImportTransfer', [
+ 'stackName' => '',
+ 'composeYml' => "services:\n web:\n image: nginx\n",
+ ]);
+
+ $result = json_decode($output, true);
+ $this->assertEquals('error', $result['result']);
+ $this->assertStringContainsString('Stack name is required', $result['message']);
+ }
+
+ public function testPerformImportTransferEmptyCompose(): void
+ {
+ $output = $this->executeAction('performImportTransfer', [
+ 'stackName' => 'import-test',
+ 'composeYml' => '',
+ ]);
+
+ $result = json_decode($output, true);
+ $this->assertEquals('error', $result['result']);
+ $this->assertStringContainsString('Compose content is required', $result['message']);
+
+ // Stack folder should NOT have been created
+ $this->assertDirectoryDoesNotExist($this->testComposeRoot . '/import-test');
+ }
+
+ public function testPerformImportTransferSuccess(): void
+ {
+ $yaml = "services:\n web:\n image: nginx\n";
+
+ $output = $this->executeAction('performImportTransfer', [
+ 'stackName' => 'import-ok',
+ 'stackDesc' => 'My import',
+ 'composeYml' => $yaml,
+ ]);
+
+ $result = json_decode($output, true);
+ $this->assertEquals('success', $result['result']);
+ $this->assertStringContainsString('import-ok', $result['projectPath']);
+
+ // compose file written (trim() in handler strips trailing newline)
+ $composePath = $result['projectPath'] . '/compose.yaml';
+ $this->assertFileExists($composePath);
+ $this->assertEquals(trim($yaml), file_get_contents($composePath));
+ }
+
+ public function testPerformImportTransferWritesEnvAndOverride(): void
+ {
+ $yaml = "services:\n web:\n image: nginx\n";
+ $env = "FOO=bar\n";
+ $override = "services:\n web:\n labels:\n - test=1\n";
+
+ $output = $this->executeAction('performImportTransfer', [
+ 'stackName' => 'import-extras',
+ 'composeYml' => $yaml,
+ 'env' => $env,
+ 'override' => $override,
+ ]);
+
+ $result = json_decode($output, true);
+ $this->assertEquals('success', $result['result']);
+
+ $basePath = $result['projectPath'];
+ $this->assertEquals(trim($env), file_get_contents($basePath . '/.env'));
+ $this->assertEquals(trim($override), file_get_contents($basePath . '/compose.override.yaml'));
+ }
+
+ // ===========================================
+ // getDockerContainersForImport Action Tests
+ // ===========================================
+
+ public function testGetDockerContainersForImportReturnsDockerManOnly(): void
+ {
+ DockerUtilMock::setContainers([
+ 'nginx' => $this->makeContainer('abc123', 'nginx', 'nginx:latest'),
+ 'compose-svc' => $this->makeContainer('def456', 'compose-svc', 'redis:7', [
+ 'Manager' => 'compose',
+ ]),
+ ]);
+
+ $output = $this->executeAction('getDockerContainersForImport');
+ $result = json_decode($output, true);
+
+ $this->assertEquals('success', $result['result']);
+ $this->assertCount(1, $result['containers']);
+ $this->assertEquals('abc123', $result['containers'][0]['Id']);
+ }
+
+ public function testGetDockerContainersForImportEmptyWhenNoCandidates(): void
+ {
+ DockerUtilMock::setContainers([]);
+
+ $output = $this->executeAction('getDockerContainersForImport');
+ $result = json_decode($output, true);
+
+ $this->assertEquals('success', $result['result']);
+ $this->assertEmpty($result['containers']);
+ }
+
+ // ===========================================
+ // generateImportData Action Tests
+ // ===========================================
+
+ public function testGenerateImportDataErrorWhenNoContainers(): void
+ {
+ $output = $this->executeAction('generateImportData', [
+ 'containerIds' => json_encode([]),
+ ]);
+ $result = json_decode($output, true);
+ $this->assertEquals('error', $result['result']);
+ }
+
+ public function testGenerateImportDataReturnsServiceMeta(): void
+ {
+ DockerUtilMock::setContainers([
+ 'web' => $this->makeContainer('aaa111', 'web', 'nginx:latest', [
+ 'Config' => [
+ 'Image' => 'nginx:latest',
+ 'Env' => ['MY_VAR=hello'],
+ 'Labels' => [
+ 'net.unraid.docker.icon' => 'https://example.com/icon.png',
+ ],
+ 'ExposedPorts' => ['80/tcp' => (object) []],
+ ],
+ 'HostConfig' => [
+ 'PortBindings' => [
+ '80/tcp' => [['HostIp' => '', 'HostPort' => '8080']],
+ ],
+ 'Binds' => [],
+ 'RestartPolicy' => ['Name' => 'unless-stopped'],
+ 'NetworkMode' => 'bridge',
+ ],
+ ]),
+ ]);
+
+ $output = $this->executeAction('generateImportData', [
+ 'containerIds' => json_encode(['aaa111']),
+ ]);
+ $result = json_decode($output, true);
+
+ $this->assertEquals('success', $result['result']);
+ $this->assertArrayHasKey('services', $result);
+ $this->assertArrayHasKey('portConflicts', $result);
+ $this->assertArrayHasKey('networks', $result);
+
+ // Should have exactly one service
+ $this->assertCount(1, $result['services']);
+ $svc = reset($result['services']);
+ $this->assertEquals('nginx:latest', $svc['image']);
+ $this->assertEquals('https://example.com/icon.png', $svc['icon']);
+ }
+
+ // ===========================================
+ // finalizeImportCompose Action Tests
+ // ===========================================
+
+ public function testFinalizeImportComposeErrorWhenNoContainers(): void
+ {
+ $output = $this->executeAction('finalizeImportCompose', [
+ 'containerIds' => json_encode([]),
+ ]);
+ $result = json_decode($output, true);
+ $this->assertEquals('error', $result['result']);
+ }
+
+ public function testFinalizeImportComposeReturnsYaml(): void
+ {
+ DockerUtilMock::setContainers([
+ 'redis' => $this->makeContainer('bbb222', 'redis', 'redis:7', [
+ 'Config' => [
+ 'Image' => 'redis:7',
+ 'Env' => [],
+ 'Labels' => [],
+ 'ExposedPorts' => [],
+ ],
+ 'HostConfig' => [
+ 'PortBindings' => [],
+ 'Binds' => [],
+ 'RestartPolicy' => ['Name' => ''],
+ 'NetworkMode' => 'bridge',
+ ],
+ ]),
+ ]);
+
+ $output = $this->executeAction('finalizeImportCompose', [
+ 'containerIds' => json_encode(['bbb222']),
+ ]);
+ $result = json_decode($output, true);
+
+ $this->assertEquals('success', $result['result']);
+ $this->assertArrayHasKey('composeYml', $result);
+ $this->assertStringContainsString('redis:7', $result['composeYml']);
+ $this->assertArrayHasKey('validation', $result);
+ }
}
diff --git a/tests/unit/ImportWizardTest.php b/tests/unit/ImportWizardTest.php
new file mode 100644
index 0000000..f023b79
--- /dev/null
+++ b/tests/unit/ImportWizardTest.php
@@ -0,0 +1,544 @@
+assertEquals('0s', nanosToComposeDuration(0));
+ }
+
+ public function testNanosToComposeDurationSeconds(): void
+ {
+ $this->assertEquals('30s', nanosToComposeDuration(30_000_000_000));
+ }
+
+ public function testNanosToComposeDurationExactMinutes(): void
+ {
+ $this->assertEquals('2m', nanosToComposeDuration(120_000_000_000));
+ }
+
+ public function testNanosToComposeDurationMinutesAndSeconds(): void
+ {
+ $this->assertEquals('1m30s', nanosToComposeDuration(90_000_000_000));
+ }
+
+ public function testNanosToComposeDurationOneSecond(): void
+ {
+ $this->assertEquals('1s', nanosToComposeDuration(1_000_000_000));
+ }
+
+ // =========================================================
+ // parsePortMapping()
+ // =========================================================
+
+ public function testParsePortMappingHostAndContainer(): void
+ {
+ $result = parsePortMapping('8080:80/tcp');
+ $this->assertEquals([
+ 'hostIp' => '',
+ 'hostPort' => '8080',
+ 'containerPort' => '80',
+ 'protocol' => 'tcp',
+ ], $result);
+ }
+
+ public function testParsePortMappingWithHostIp(): void
+ {
+ $result = parsePortMapping('192.168.1.1:8080:80/tcp');
+ $this->assertEquals([
+ 'hostIp' => '192.168.1.1',
+ 'hostPort' => '8080',
+ 'containerPort' => '80',
+ 'protocol' => 'tcp',
+ ], $result);
+ }
+
+ public function testParsePortMappingExposeOnly(): void
+ {
+ $result = parsePortMapping('80/tcp');
+ $this->assertEquals([
+ 'hostIp' => '',
+ 'hostPort' => '',
+ 'containerPort' => '80',
+ 'protocol' => 'tcp',
+ ], $result);
+ }
+
+ public function testParsePortMappingUdpProtocol(): void
+ {
+ $result = parsePortMapping('53:53/udp');
+ $this->assertEquals([
+ 'hostIp' => '',
+ 'hostPort' => '53',
+ 'containerPort' => '53',
+ 'protocol' => 'udp',
+ ], $result);
+ }
+
+ public function testParsePortMappingNoProtocol(): void
+ {
+ $result = parsePortMapping('8080:80');
+ $this->assertEquals([
+ 'hostIp' => '',
+ 'hostPort' => '8080',
+ 'containerPort' => '80',
+ 'protocol' => 'tcp',
+ ], $result);
+ }
+
+ public function testParsePortMappingIpv6Loopback(): void
+ {
+ $result = parsePortMapping('[::1]:8080:80/tcp');
+ $this->assertEquals([
+ 'hostIp' => '::1',
+ 'hostPort' => '8080',
+ 'containerPort' => '80',
+ 'protocol' => 'tcp',
+ ], $result);
+ }
+
+ public function testParsePortMappingIpv6Unspecified(): void
+ {
+ $result = parsePortMapping('[::]:8080:80/tcp');
+ $this->assertEquals([
+ 'hostIp' => '::',
+ 'hostPort' => '8080',
+ 'containerPort' => '80',
+ 'protocol' => 'tcp',
+ ], $result);
+ }
+
+ // =========================================================
+ // guessHealthcheck()
+ // =========================================================
+
+ public function testGuessHealthcheckFromExistingInspect(): void
+ {
+ $existing = [
+ 'Test' => ['CMD-SHELL', 'curl -f http://localhost/ || exit 1'],
+ 'Interval' => 30_000_000_000,
+ 'Timeout' => 10_000_000_000,
+ 'Retries' => 3,
+ 'StartPeriod' => 5_000_000_000,
+ ];
+ $result = guessHealthcheck('nginx:latest', [], $existing);
+ $this->assertNotNull($result);
+ $this->assertEquals(['CMD-SHELL', 'curl -f http://localhost/ || exit 1'], $result['test']);
+ $this->assertEquals('30s', $result['interval']);
+ $this->assertEquals('10s', $result['timeout']);
+ $this->assertEquals(3, $result['retries']);
+ $this->assertEquals('5s', $result['start_period']);
+ }
+
+ public function testGuessHealthcheckFromKnownPort80(): void
+ {
+ $result = guessHealthcheck('someimage:latest', ['80/tcp' => new \stdClass()]);
+ $this->assertNotNull($result);
+ $this->assertEquals(['CMD-SHELL', 'curl -f http://localhost:80/ || exit 1'], $result['test']);
+ $this->assertEquals('30s', $result['interval']);
+ $this->assertEquals('10s', $result['timeout']);
+ $this->assertEquals(3, $result['retries']);
+ }
+
+ public function testGuessHealthcheckFromKnownPort3306(): void
+ {
+ $result = guessHealthcheck('custom-db:v1', ['3306/tcp' => new \stdClass()]);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('mysqladmin', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckFromKnownPort6379(): void
+ {
+ $result = guessHealthcheck('my-redis:7', ['6379/tcp' => new \stdClass()]);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('redis-cli', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckFromImageNameMysql(): void
+ {
+ $result = guessHealthcheck('mysql:8.0', []);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('mysqladmin', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckFromImageNameLinuxserverMariadb(): void
+ {
+ $result = guessHealthcheck('linuxserver/mariadb:10', []);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('mysqladmin', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckFromImageNamePostgres(): void
+ {
+ $result = guessHealthcheck('postgres:15', []);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('pg_isready', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckFromImageNameRedis(): void
+ {
+ $result = guessHealthcheck('redis:7-alpine', []);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('redis-cli', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckFromImageNameNginx(): void
+ {
+ $result = guessHealthcheck('nginx:latest', []);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('curl', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckReturnsNullForUnknown(): void
+ {
+ $result = guessHealthcheck('mycustomapp:v3', []);
+ $this->assertNull($result);
+ }
+
+ public function testGuessHealthcheckPortTakesPriorityOverImageName(): void
+ {
+ // Image says "nginx" (curl localhost/) but exposed port is 5432 (pg_isready)
+ $result = guessHealthcheck('nginx:latest', ['5432/tcp' => new \stdClass()]);
+ $this->assertNotNull($result);
+ $this->assertStringContainsString('pg_isready', $result['test'][1]);
+ }
+
+ public function testGuessHealthcheckExistingTakesPriorityOverAll(): void
+ {
+ $existing = [
+ 'Test' => ['CMD', '/usr/bin/custom-check'],
+ 'Interval' => 60_000_000_000,
+ 'Timeout' => 5_000_000_000,
+ 'Retries' => 5,
+ ];
+ // Has known port AND image match, but existing should win
+ $result = guessHealthcheck('mysql:8', ['3306/tcp' => new \stdClass()], $existing);
+ $this->assertNotNull($result);
+ $this->assertEquals(['CMD', '/usr/bin/custom-check'], $result['test']);
+ $this->assertEquals('1m', $result['interval']);
+ }
+
+ // =========================================================
+ // detectPortConflicts()
+ // =========================================================
+
+ public function testDetectPortConflictsNoConflicts(): void
+ {
+ $services = [
+ 'web' => ['ports' => ['8080:80/tcp']],
+ 'api' => ['ports' => ['3000:3000/tcp']],
+ ];
+ $conflicts = detectPortConflicts($services);
+ $this->assertEmpty($conflicts);
+ }
+
+ public function testDetectPortConflictsDuplicateHostPort(): void
+ {
+ $services = [
+ 'nginx' => ['ports' => ['8080:80/tcp']],
+ 'app' => ['ports' => ['8080:3000/tcp']],
+ ];
+ $conflicts = detectPortConflicts($services);
+ $this->assertCount(1, $conflicts);
+ $this->assertEquals('8080', $conflicts[0]['hostPort']);
+ $this->assertEquals('tcp', $conflicts[0]['protocol']);
+ $this->assertContains('nginx', $conflicts[0]['services']);
+ $this->assertContains('app', $conflicts[0]['services']);
+ }
+
+ public function testDetectPortConflictsDifferentHostIpsNoConflict(): void
+ {
+ $services = [
+ 'svc1' => ['ports' => ['192.168.1.1:8080:80/tcp']],
+ 'svc2' => ['ports' => ['192.168.1.2:8080:80/tcp']],
+ ];
+ $conflicts = detectPortConflicts($services);
+ $this->assertEmpty($conflicts);
+ }
+
+ public function testDetectPortConflictsExposeOnlyIgnored(): void
+ {
+ $services = [
+ 'svc1' => ['ports' => ['80/tcp']],
+ 'svc2' => ['ports' => ['80/tcp']],
+ ];
+ $conflicts = detectPortConflicts($services);
+ $this->assertEmpty($conflicts);
+ }
+
+ public function testDetectPortConflictsNoPorts(): void
+ {
+ $services = [
+ 'svc1' => ['image' => 'nginx'],
+ 'svc2' => ['image' => 'redis'],
+ ];
+ $conflicts = detectPortConflicts($services);
+ $this->assertEmpty($conflicts);
+ }
+
+ public function testDetectPortConflictsDifferentProtocolNoConflict(): void
+ {
+ $services = [
+ 'svc1' => ['ports' => ['53:53/tcp']],
+ 'svc2' => ['ports' => ['53:53/udp']],
+ ];
+ $conflicts = detectPortConflicts($services);
+ $this->assertEmpty($conflicts);
+ }
+
+ public function testDetectPortConflictsPortRange(): void
+ {
+ $services = [
+ 'svc1' => ['ports' => ['8000-8005:8000-8005/tcp']],
+ 'svc2' => ['ports' => ['8000-8005:9000-9005/tcp']],
+ ];
+ $conflicts = detectPortConflicts($services);
+ $this->assertCount(1, $conflicts);
+ $this->assertEquals('8000-8005', $conflicts[0]['hostPort']);
+ $this->assertEquals('tcp', $conflicts[0]['protocol']);
+ $this->assertContains('svc1', $conflicts[0]['services']);
+ $this->assertContains('svc2', $conflicts[0]['services']);
+ }
+
+ // =========================================================
+ // dockerServicesToComposeYml() with wizardConfig
+ // =========================================================
+
+ public function testComposeYmlWithContainerNames(): void
+ {
+ $services = [
+ 'web' => ['image' => 'nginx:latest'],
+ 'db' => ['image' => 'mysql:8'],
+ ];
+ $config = [
+ 'containerNames' => ['web' => 'my-web', 'db' => 'my-database'],
+ 'networkConfig' => ['stackNetwork' => ['enabled' => false], 'externalNetworks' => [], 'perService' => []],
+ 'healthchecks' => [],
+ 'dependencies' => [],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+ $this->assertStringContainsString('container_name: my-web', $yaml);
+ $this->assertStringContainsString('container_name: my-database', $yaml);
+ }
+
+ public function testComposeYmlWithStackNetwork(): void
+ {
+ $services = [
+ 'web' => ['image' => 'nginx:latest'],
+ ];
+ $config = [
+ 'containerNames' => [],
+ 'networkConfig' => [
+ 'stackNetwork' => ['enabled' => true, 'name' => 'mystack_net'],
+ 'externalNetworks' => [],
+ 'perService' => [
+ 'web' => ['networkMode' => 'default', 'attachStackNet' => true, 'externalNets' => []],
+ ],
+ ],
+ 'healthchecks' => [],
+ 'dependencies' => [],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+ $this->assertStringContainsString('networks:', $yaml);
+ $this->assertStringContainsString('mystack_net', $yaml);
+ $this->assertStringContainsString('driver: bridge', $yaml);
+ }
+
+ public function testComposeYmlWithHostNetworkMode(): void
+ {
+ $services = [
+ 'web' => ['image' => 'nginx:latest', 'networks' => ['old_net']],
+ ];
+ $config = [
+ 'containerNames' => [],
+ 'networkConfig' => [
+ 'stackNetwork' => ['enabled' => true, 'name' => 'mystack_net'],
+ 'externalNetworks' => [],
+ 'perService' => [
+ 'web' => ['networkMode' => 'host', 'attachStackNet' => false, 'externalNets' => []],
+ ],
+ ],
+ 'healthchecks' => [],
+ 'dependencies' => [],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+ $this->assertStringContainsString('network_mode: host', $yaml);
+ // Should NOT contain networks for this service
+ $this->assertStringNotContainsString('old_net', $yaml);
+ }
+
+ public function testComposeYmlWithHealthcheck(): void
+ {
+ $services = [
+ 'web' => ['image' => 'nginx:latest'],
+ ];
+ $config = [
+ 'containerNames' => [],
+ 'networkConfig' => ['stackNetwork' => ['enabled' => false], 'externalNetworks' => [], 'perService' => []],
+ 'healthchecks' => [
+ 'web' => [
+ 'test' => ['CMD-SHELL', 'curl -f http://localhost/ || exit 1'],
+ 'interval' => '30s',
+ 'timeout' => '10s',
+ 'retries' => 3,
+ 'start_period' => '10s',
+ ],
+ ],
+ 'dependencies' => [],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+ $this->assertStringContainsString('healthcheck:', $yaml);
+ $this->assertStringContainsString('test:', $yaml);
+ $this->assertStringContainsString('CMD-SHELL', $yaml);
+ $this->assertStringContainsString('interval: 30s', $yaml);
+ $this->assertStringContainsString('retries: 3', $yaml);
+ // Verify retries is NOT quoted (should be bare integer, not "3")
+ $this->assertStringNotContainsString('retries: "3"', $yaml);
+ }
+
+ public function testComposeYmlWithRemovedHealthcheck(): void
+ {
+ $services = [
+ 'web' => ['image' => 'nginx:latest', 'healthcheck' => ['test' => ['CMD', 'true']]],
+ ];
+ $config = [
+ 'containerNames' => [],
+ 'networkConfig' => ['stackNetwork' => ['enabled' => false], 'externalNetworks' => [], 'perService' => []],
+ 'healthchecks' => ['web' => null],
+ 'dependencies' => [],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+ $this->assertStringNotContainsString('healthcheck:', $yaml);
+ }
+
+ public function testComposeYmlWithDependsOn(): void
+ {
+ $services = [
+ 'web' => ['image' => 'nginx:latest'],
+ 'db' => ['image' => 'mysql:8'],
+ ];
+ $config = [
+ 'containerNames' => [],
+ 'networkConfig' => ['stackNetwork' => ['enabled' => false], 'externalNetworks' => [], 'perService' => []],
+ 'healthchecks' => [],
+ 'dependencies' => [
+ 'web' => [
+ ['service' => 'db', 'condition' => 'service_healthy'],
+ ],
+ ],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+ $this->assertStringContainsString('depends_on:', $yaml);
+ $this->assertStringContainsString('db:', $yaml);
+ $this->assertStringContainsString('condition: service_healthy', $yaml);
+ }
+
+ public function testComposeYmlWithExternalNetworks(): void
+ {
+ $services = [
+ 'web' => ['image' => 'nginx:latest'],
+ ];
+ $config = [
+ 'containerNames' => [],
+ 'networkConfig' => [
+ 'stackNetwork' => ['enabled' => false],
+ 'externalNetworks' => ['proxy_net'],
+ 'perService' => [
+ 'web' => ['networkMode' => 'default', 'attachStackNet' => false, 'externalNets' => ['proxy_net']],
+ ],
+ ],
+ 'healthchecks' => [],
+ 'dependencies' => [],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+ $this->assertStringContainsString('proxy_net', $yaml);
+ $this->assertStringContainsString('external: true', $yaml);
+ }
+
+ public function testComposeYmlLegacyPathNoWizardConfig(): void
+ {
+ $services = [
+ 'web' => [
+ 'image' => 'nginx:latest',
+ 'ports' => ['80:80/tcp'],
+ '__guessed_healthcheck' => ['test' => ['CMD', 'true']],
+ '__exposed_ports' => ['80/tcp' => new \stdClass()],
+ 'icon' => '/path/to/icon.png',
+ ],
+ ];
+ $yaml = dockerServicesToComposeYml($services);
+ $this->assertStringContainsString('nginx:latest', $yaml);
+ // Internal keys should not appear
+ $this->assertStringNotContainsString('__guessed_healthcheck', $yaml);
+ $this->assertStringNotContainsString('__exposed_ports', $yaml);
+ $this->assertStringNotContainsString('icon:', $yaml);
+ }
+
+ public function testComposeYmlFullWizardConfig(): void
+ {
+ $services = [
+ 'app' => ['image' => 'myapp:latest', 'ports' => ['8080:80/tcp']],
+ 'db' => ['image' => 'postgres:15', 'ports' => ['5432:5432/tcp']],
+ ];
+ $config = [
+ 'containerNames' => ['app' => 'my-app', 'db' => 'my-postgres'],
+ 'networkConfig' => [
+ 'stackNetwork' => ['enabled' => true, 'name' => 'app_net'],
+ 'externalNetworks' => [],
+ 'perService' => [
+ 'app' => ['networkMode' => 'default', 'attachStackNet' => true, 'externalNets' => []],
+ 'db' => ['networkMode' => 'default', 'attachStackNet' => true, 'externalNets' => []],
+ ],
+ ],
+ 'healthchecks' => [
+ 'app' => ['test' => ['CMD-SHELL', 'curl -f http://localhost:80/'], 'interval' => '15s', 'timeout' => '5s', 'retries' => 3, 'start_period' => '10s'],
+ 'db' => ['test' => ['CMD-SHELL', 'pg_isready -U postgres'], 'interval' => '30s', 'timeout' => '10s', 'retries' => 5, 'start_period' => '20s'],
+ ],
+ 'dependencies' => [
+ 'app' => [['service' => 'db', 'condition' => 'service_healthy']],
+ ],
+ ];
+ $yaml = dockerServicesToComposeYml($services, $config);
+
+ // Container names
+ $this->assertStringContainsString('container_name: my-app', $yaml);
+ $this->assertStringContainsString('container_name: my-postgres', $yaml);
+
+ // Network
+ $this->assertStringContainsString('app_net', $yaml);
+ $this->assertStringContainsString('driver: bridge', $yaml);
+
+ // App healthcheck
+ $this->assertStringContainsString('curl -f http://localhost:80/', $yaml);
+
+ // DB healthcheck
+ $this->assertStringContainsString('pg_isready -U postgres', $yaml);
+
+ // Dependency
+ $this->assertStringContainsString('depends_on:', $yaml);
+ $this->assertStringContainsString('condition: service_healthy', $yaml);
+ }
+}