diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8dde83f0..dd5a8eae 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,10 +1,11 @@ -blank_issues_enabled: true +blank_issues_enabled: false issues: - feature_request.yaml + - bug_report.yaml contact_links: - name: Security policy - url: /security - about: Report security issues - - name: Support forum - url: https://forums.unraid.net - about: For usage questions + url: https://github.com/mstrhakr/compose_plugin/security/advisories/new + about: Report security issues privately via GitHub + - name: Unraid support forum for Compose Manager Plus + url: https://forums.unraid.net/topic/197334-plugin-compose-manager-plus/ + about: For usage questions and support, please visit the Unraid support forum for Compose Manager Plus. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ad27333f..0ad519e1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' extensions: json, curl, mbstring coverage: xdebug @@ -130,7 +130,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' tools: phpstan - name: Install dependencies diff --git a/build.sh b/build.sh index 790d07dd..5521e161 100644 --- a/build.sh +++ b/build.sh @@ -109,6 +109,9 @@ fi ARCHIVE_PATH="$OUTPUT_PATH" mkdir -p "$ARCHIVE_PATH" +CACHE_PATH="$ARCHIVE_PATH/.build-cache" +HOST_CACHE_PATH="$HOST_ARCHIVE_PATH/.build-cache" + # Host path for docker socket operations should be the actual unRAID path. HOST_ARCHIVE_PATH="$ARCHIVE_PATH" if [[ "$in_container" == true && -d "/code" ]]; then @@ -157,6 +160,8 @@ SOURCE_PATH="$SCRIPT_DIR/source" mkdir -p "$ARCHIVE_PATH" mkdir -p "$HOST_ARCHIVE_PATH" +mkdir -p "$CACHE_PATH" +mkdir -p "$HOST_CACHE_PATH" # CA bundle setup CONTAINER_CA_CERT="/etc/ssl/certs/ca-certificates.crt" @@ -244,11 +249,13 @@ echo "Docker will mount SOURCE_PATH=$SOURCE_PATH" build_cmd_direct=(docker run --rm --tmpfs /tmp \ -v "$HOST_ARCHIVE_PATH:/mnt/output:rw" \ + -v "$HOST_CACHE_PATH:/mnt/cache:rw" \ -v "$SOURCE_PATH:/mnt/source:ro" \ -v "$HOST_CA_CERT:$CONTAINER_CA_CERT:ro" \ -e TZ=America/New_York \ -e COMPOSE_VERSION="$COMPOSE_VERSION" \ -e OUTPUT_FOLDER=/mnt/output \ + -e DOWNLOAD_CACHE_DIR=/mnt/cache \ -e PKG_VERSION="$VERSION" \ -e PKG_BUILD="$BUILD_NUM" \ -e CA_CERT="$CONTAINER_CA_CERT" \ @@ -259,11 +266,13 @@ if "${build_cmd_direct[@]}"; then echo "Direct source mount works. Running build via direct mount..." if ! docker run --rm --tmpfs /tmp \ -v "$HOST_ARCHIVE_PATH:/mnt/output:rw" \ + -v "$HOST_CACHE_PATH:/mnt/cache:rw" \ -v "$SOURCE_PATH:/mnt/source:ro" \ -v "$HOST_CA_CERT:$CONTAINER_CA_CERT:ro" \ -e TZ=America/New_York \ -e COMPOSE_VERSION="$COMPOSE_VERSION" \ -e OUTPUT_FOLDER=/mnt/output \ + -e DOWNLOAD_CACHE_DIR=/mnt/cache \ -e PKG_VERSION="$VERSION" \ -e PKG_BUILD="$BUILD_NUM" \ -e CA_CERT="$CONTAINER_CA_CERT" \ @@ -275,10 +284,12 @@ else echo "Direct mount failed, using tar stream fallback." if ! tar -C "$SOURCE_PATH" -cf - . | docker run --rm --tmpfs /tmp -i \ -v "$HOST_ARCHIVE_PATH:/mnt/output:rw" \ + -v "$HOST_CACHE_PATH:/mnt/cache:rw" \ -v "$HOST_CA_CERT:$CONTAINER_CA_CERT:ro" \ -e TZ=America/New_York \ -e COMPOSE_VERSION="$COMPOSE_VERSION" \ -e OUTPUT_FOLDER=/mnt/output \ + -e DOWNLOAD_CACHE_DIR=/mnt/cache \ -e PKG_VERSION="$VERSION" \ -e PKG_BUILD="$BUILD_NUM" \ -e CA_CERT="$CONTAINER_CA_CERT" \ diff --git a/build_in_docker.sh b/build_in_docker.sh index eb0fa83e..a4a1baa4 100755 --- a/build_in_docker.sh +++ b/build_in_docker.sh @@ -5,13 +5,16 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" [ -z "$COMPOSE_VERSION" ] && COMPOSE_VERSION=5.1.2 [ -z "$PKG_VERSION" ] && PKG_VERSION="$(date +%Y.%m.%d)" [ -z "$PKG_BUILD" ] && PKG_BUILD="$(date +%H%M)" +mkdir -p "$PWD/archive/.build-cache" docker run --rm --tmpfs /tmp \ -v "$PWD/archive:/mnt/output:rw" \ + -v "$PWD/archive/.build-cache:/mnt/cache:rw" \ -e TZ="America/New_York" \ -e COMPOSE_VERSION="$COMPOSE_VERSION" \ -e PKG_VERSION="$PKG_VERSION" \ -e PKG_BUILD="$PKG_BUILD" \ -e OUTPUT_FOLDER="/mnt/output" \ + -e DOWNLOAD_CACHE_DIR="/mnt/cache" \ -v "$PWD/source:/mnt/source:ro" \ vbatts/slackware:latest \ /mnt/source/pkg_build.sh "$UI_VERSION_LETTER" diff --git a/compose.manager.plg b/compose.manager.plg index 5e9bbfd6..1439b824 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,15 +2,15 @@ - + - - + + - + @@ -36,87 +36,11 @@ > -###2026.04.24 -- Features: implement centralized cron management with CronManager for autoupdate and backup tasks -- Features: enhance WebUI URL validation and detection with placeholder support -- Features: add clearIconCache action to remove service icons from cache -- Features: display plugin and Docker Compose versions in the credits section -- Features (version): update default Docker Compose version to 5.1.2 across build and deploy scripts -- Features (install): add informational messages for Ace editor and patch utility installation -- Features (tests): add unit tests for top-level dev scripts -- Features (compose): add profile name persistence for stack operations -- Features (stack): centralize profile management by introducing effective profiles and persist running profiles -- Features (stack): centralize container counts and stack state management -- Features (sortable): add normalizeComposeDetailsRowOrder function to reorder detail rows -- Features (sortable): update mover icon style to enhance visibility in stack list -- Features (sortable): load sortable functions from external composeSortable.js -- Features (sortable): implement sortable stacks with lock functionality and save order feature -- Bug Fixes (ui): update placeholder for WebUI URL input for stack -- Bug Fixes (tests): correct expected log format in composeLogger test for category messages -- Bug Fixes (pkg_build): force non-interactive yes for makepkg prompts in CI/container builds -- Bug Fixes (autoupdate): update project name resolution to use sanitized lowercase projectName instead of raw projectFolder -- Bug Fixes (autoupdate): enhance logging in compose_autoupdate.sh for error and success messages -- Bug Fixes: remove redundant 'root' user specification in cron job entries; fixes #99 -- Bug Fixes: add --ignore-buildable option to pull command to skip services with build sections -- Bug Fixes (helpers): add socket override for command execution and logging to prevent lost/missing ttyd windows -- Bug Fixes (pkg_build): update unzip download mirror and validate checksum on download -- Bug Fixes (install): enhance messaging for Ace editor and patches installation based on Unraid version -- Bug Fixes (ace): update Ace editor version limits for fallback installations -- Bug Fixes (install): update variable assignment syntax for PACKAGE_TAR and PLUGIN_FILE -- Bug Fixes (tests): update docker command assertion to match image format -- Bug Fixes (tests): remove ExecHelpers.php dependency and update function calls to use ContainerInfo -- Bug Fixes: update socket handling to use COMPOSE_TTYD_SOCKET_DIR for consistency -- Bug Fixes (docs): enhance comments for LockButton chaining contract in composeSortable.js -- Bug Fixes (docs): update file and module annotations in composeStackUtils.js -- Bug Fixes (tests): update comments to clarify source command -- Bug Fixes (styles): enhance comments for ComboButton and EditorModal CSS files -- Bug Fixes (install-ace): improve error handling for directory creation and command checks -- Bug Fixes (build): sed was too broad and messed up plg when doing local dev -- Bug Fixes (plugin): update paths for plugin and emhttp locations to use variables -- Bug Fixes (patch): update patch setup in plg and remove deprecated patches -- Bug Fixes (stack): centralize container counts and optimize state retrieval -- Bug Fixes (profiles): fix profile selection logic for compose actions -- Bug Fixes (dashboard): map paused stacks into partial summary bucket -- Bug Fixes (state): count non-running unknown container states as stopped -- Bug Fixes (profiles): prefer running profiles for multi-stack up/update -- Bug Fixes (tests): centralize shell wrapper for autoupdate tests -- Bug Fixes (auto-update): revert some changes and improve test environment setup -- Bug Fixes (auto-update): centralize shell command resolution for auto-update scripts -- Bug Fixes (submodule): update tests/framework submodule branch to main -- Bug Fixes (tests): correct string escaping in checkStackUpdates onclick handler -- Bug Fixes (stack): update container ID collection to use getContainerList method -- Bug Fixes (stack): simplify stack status display logic and update prompt for checks -- Bug Fixes (stack): derive running state from DOM and remove stale isRunning flag -- Bug Fixes (stack): derive running state from DOM instead of stale server response -- Bug Fixes (tests): enhance PHPUnit and BATS configurations, improve test setup and teardown -- Bug Fixes (yaml): harden js-yaml loading and prevent tagged override data loss -- Bug Fixes (yaml): remove fallback from custom tags and surface errors to user instead -- Bug Fixes (yaml): enhance support for custom YAML tags and add corresponding unit tests -- Bug Fixes: add custom YAML tags support and improve YAML loading functionality -- Bug Fixes (icon): enhance icon URL handling to accept data:image URLs and add corresponding regression/unit tests -- Bug Fixes (exec): update Docker network handling and utilize shared helpers to fix wrong ip issues in issue 86 Fixes mstrhakr/compose_plugin#86 -- Bug Fixes (tests): correct casing in test descriptions for ComposeListHtmlTest and StackInfoSourceTest -- Bug Fixes (sortable): update LockButton assignment to prevent hoisting issues -- Bug Fixes (sortable): extract js to own file -- Refactoring (logging): enhance logging display levels in composeLogger() for improved clarity -- Refactoring (logging): replace logger calls in scripts with new composeLogger() func for consistent logging across scripts + tests -- Refactoring (logging): renamed composeClientDebug() and clientDebug() funcs to composeLogger() -- Refactoring (logging): add explicit category handling and align clientDebug facilities (user for UI/AJAX, daemon for cron) -- Refactoring (exec): move utility functions to Util.php and move image normalization method to ContainerInfo; removed ExecHelpers.php -- Refactoring (plg): extract Ace editor installation and patch utility scripts -- Refactoring (tests): update utilPath in StackInfoSourceTest to reflect folder structure changes -- Refactoring (structure): update all path references for include/ and sheets/ -- Refactoring (structure): rename php/->include/, styles/->sheets/, PascalCase files -- Tests (StackInfoTest): add tests for indirect project handling with existing compose files -- Chores: update changelog for v2026.04.20.1923 [skip ci] -- Chores: update changelog for v2026.04.20.1510 [skip ci] -- Chores: update changelog for v2026.04.16.0728 [skip ci] -- Chores: update changelog for v2026.04.15.1634 [skip ci] -- Chores: update changelog for v2026.04.13.1951 [skip ci] -- Chores: update changelog for v2026.04.13.1913 [skip ci] -- Chores (versions): update COMPOSE_VERSION to 5.1.2 -- Chores (submodule): pin tests/framework to v0.2 -- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.04.10...v2026.04.24) +###2026.05.25.0747 +- Features: implement canonical project name sanitizer and update related functions and tests +- Bug Fixes: disable blank issues and update contact links in issue template +- [PR #108](https://github.com/mstrhakr/compose_plugin/pull/108) +- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.04.24...v2026.05.25.0747) diff --git a/composer.json b/composer.json index 7633ed38..e4c4b411 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^12.5", "phpstan/phpstan": "^2.1" }, "scripts": { diff --git a/composer.lock b/composer.lock index 368d77b1..330843d0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7b5b702bd2d72de1a2ab39242aa60932", + "content-hash": "2999a4db2678530fa0e639274a85910a", "packages": [], "packages-dev": [ { @@ -298,35 +298,33 @@ }, { "name": "phpunit/php-code-coverage", - "version": "10.1.16", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", - "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=8.1", - "phpunit/php-file-iterator": "^4.1.0", - "phpunit/php-text-template": "^3.0.1", - "sebastian/code-unit-reverse-lookup": "^3.0.0", - "sebastian/complexity": "^3.2.0", - "sebastian/environment": "^6.1.0", - "sebastian/lines-of-code": "^2.0.2", - "sebastian/version": "^4.0.1", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^10.1" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -335,7 +333,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -364,40 +362,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2024-08-22T04:31:57+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "4.1.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -425,36 +435,48 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2023-08-31T06:24:48+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", - "version": "4.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -462,7 +484,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -488,7 +510,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -496,32 +519,32 @@ "type": "github" } ], - "time": "2023-02-03T06:56:09+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "3.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -548,7 +571,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -556,32 +579,32 @@ "type": "github" } ], - "time": "2023-08-31T14:07:24+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "6.0.0", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -607,7 +630,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -615,20 +639,20 @@ "type": "github" } ], - "time": "2023-02-03T06:57:52+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "10.5.63", + "version": "12.5.22", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "33198268dad71e926626b618f3ec3966661e4d90" + "reference": "e07667405f0f43317a1799d3907c43a123bc5587" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", - "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e07667405f0f43317a1799d3907c43a123bc5587", + "reference": "e07667405f0f43317a1799d3907c43a123bc5587", "shasum": "" }, "require": { @@ -641,26 +665,23 @@ "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.1", - "phpunit/php-code-coverage": "^10.1.16", - "phpunit/php-file-iterator": "^4.1.0", - "phpunit/php-invoker": "^4.0.0", - "phpunit/php-text-template": "^3.0.1", - "phpunit/php-timer": "^6.0.0", - "sebastian/cli-parser": "^2.0.1", - "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.5", - "sebastian/diff": "^5.1.1", - "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.4", - "sebastian/global-state": "^6.0.2", - "sebastian/object-enumerator": "^5.0.0", - "sebastian/recursion-context": "^5.0.1", - "sebastian/type": "^4.0.0", - "sebastian/version": "^4.0.1" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.6", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.0", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, "bin": [ "phpunit" @@ -668,7 +689,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.5-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -700,56 +721,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.22" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-01-27T05:48:37+00:00" + "time": "2026-04-17T12:51:04+00:00" }, { "name": "sebastian/cli-parser", - "version": "2.0.1", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", - "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -773,155 +778,59 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2024-03-02T07:12:49+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "2.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "phpunit/phpunit": "^10.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" - }, - "funding": [ + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2023-02-03T06:58:43+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "phpunit/phpunit": "^10.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" - }, - "funding": [ + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2023-02-03T06:59:15+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "5.0.5", + "version": "7.1.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", - "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/diff": "^5.0", - "sebastian/exporter": "^5.0" + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^12.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -961,7 +870,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" }, "funding": [ { @@ -981,33 +890,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:25:16+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", - "version": "3.2.0", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "68ff824baeae169ec9f2137158ee529584553799" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", - "reference": "68ff824baeae169ec9f2137158ee529584553799", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.2-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1031,7 +940,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { @@ -1039,33 +948,33 @@ "type": "github" } ], - "time": "2023-12-21T08:37:17+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "5.1.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "symfony/process": "^6.4" + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1098,7 +1007,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" }, "funding": [ { @@ -1106,27 +1015,27 @@ "type": "github" } ], - "time": "2024-03-02T07:15:17+00:00" + "time": "2025-02-07T04:55:46+00:00" }, { "name": "sebastian/environment", - "version": "6.1.0", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", - "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-posix": "*" @@ -1134,7 +1043,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -1162,42 +1071,54 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-03-23T08:47:14+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", - "version": "5.1.4", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "0735b90f4da94969541dac1da743446e276defa6" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", - "reference": "0735b90f4da94969541dac1da743446e276defa6", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/recursion-context": "^5.0" + "php": ">=8.3", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1240,7 +1161,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" }, "funding": [ { @@ -1260,35 +1181,35 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:09:11+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "6.0.2", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", - "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -1314,41 +1235,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T07:19:19+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", - "version": "2.0.2", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", - "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -1372,7 +1305,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" }, "funding": [ { @@ -1380,34 +1313,34 @@ "type": "github" } ], - "time": "2023-12-21T08:38:20+00:00" + "time": "2025-02-07T04:57:28+00:00" }, { "name": "sebastian/object-enumerator", - "version": "5.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1429,7 +1362,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -1437,32 +1371,32 @@ "type": "github" } ], - "time": "2023-02-03T07:08:32+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "3.0.0", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1484,7 +1418,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { @@ -1492,32 +1427,32 @@ "type": "github" } ], - "time": "2023-02-03T07:06:18+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "5.0.1", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", - "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1548,7 +1483,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { @@ -1568,32 +1503,32 @@ "type": "tidelift" } ], - "time": "2025-08-10T07:50:56+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "4.0.0", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1616,37 +1551,50 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2023-02-03T07:10:45+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { "name": "sebastian/version", - "version": "4.0.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1669,7 +1617,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -1677,27 +1626,79 @@ "type": "github" } ], - "time": "2023-02-07T11:34:05+00:00" + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" }, { "name": "theseer/tokenizer", - "version": "1.3.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -1719,7 +1720,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -1727,7 +1728,7 @@ "type": "github" } ], - "time": "2025-11-17T20:03:58+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], diff --git a/deploy.sh b/deploy.sh index 2fe67fc0..5e12a25f 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euo pipefail +trap 'echo "ERROR: command failed on line $LINENO" >&2' ERR # Default values VERSION="" @@ -132,7 +133,7 @@ else if [[ ${#files[@]} -eq 0 ]]; then echo "No package found in archive"; exit 1 fi - PACKAGE_PATH=$(printf '%s\n' "${files[@]}" | sort -r | head -n1) + PACKAGE_PATH=$(printf '%s\n' "${files[@]}" | sort -r | head -n1 || true) if [[ -z "$PACKAGE_PATH" ]]; then echo "No package found in archive"; exit 1 fi @@ -155,26 +156,34 @@ else echo "Archive directory: $ARCHIVE_DIR" echo "Build completed but package not found"; exit 1 fi - echo "Checking archive directory: $ARCHIVE_DIR" - ls -al "$ARCHIVE_DIR" - PACKAGE_PATH=$(printf '%s\n' "${files[@]}" | sort -r | head -n1) + PACKAGE_PATH=$(printf '%s\n' "${files[@]}" | sort -r | head -n1 || true) echo "Candidate package path: $PACKAGE_PATH" - if [[ -z "$PACKAGE_PATH" ]]; then - echo "Build completed but package not found" + if [[ -z "$PACKAGE_PATH" || ! -f "$PACKAGE_PATH" ]]; then + echo "Build completed but package not found or package path is invalid" echo "Archive dir content:" ls -al "$ARCHIVE_DIR" exit 1 fi + PACKAGE_PATH=$(realpath "$PACKAGE_PATH") + echo "Resolved package path: $PACKAGE_PATH" fi fi PACKAGE_NAME=$(basename "$PACKAGE_PATH") +echo "Remote hosts: ${REMOTE_HOSTS[*]:-none}" if [[ ${#REMOTE_HOSTS[@]} -eq 0 ]]; then echo "No RemoteHost specified — build only, skipping deploy. Package: $PACKAGE_PATH" exit 0 fi +for cmd in ssh scp; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Required command not found: $cmd" >&2 + exit 1 + fi + done + PLUGIN_PATH="$SCRIPT_DIR/archive/compose.manager.plg" if [[ ! -f "$PLUGIN_PATH" ]]; then PLUGIN_PATH="$SCRIPT_DIR/compose.manager.plg" diff --git a/phpstan.neon b/phpstan.neon index 7c0251eb..28fcc203 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,6 +8,9 @@ parameters: excludePaths: - */vendor/* ignoreErrors: + # Unraid template-injected globals not traceable in dev + - message: "#Undefined variable \\$compose_root#" + - message: "#Undefined variable \\$theme#" # Platform-specific paths (Unraid emhttp) - messages: - "#/usr/local/emhttp/plugins/.*.php#" diff --git a/source/compose.manager/Compose.page b/source/compose.manager/Compose.page index a9443603..a37346f8 100644 --- a/source/compose.manager/Compose.page +++ b/source/compose.manager/Compose.page @@ -15,8 +15,8 @@ Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v - + \ No newline at end of file diff --git a/source/compose.manager/README.md b/source/compose.manager/README.md index 42be301a..8baaf817 100644 --- a/source/compose.manager/README.md +++ b/source/compose.manager/README.md @@ -1,3 +1,3 @@ -**Compose Manager Plus** +**Compose Manager Plus (Beta)** A plugin for unRAID that installs Docker Compose and adds a management interface to the web UI. diff --git a/source/compose.manager/compose.manager.dashboard.page b/source/compose.manager/compose.manager.dashboard.page index 07a090f1..87a59b0a 100644 --- a/source/compose.manager/compose.manager.dashboard.page +++ b/source/compose.manager/compose.manager.dashboard.page @@ -915,4 +915,4 @@ $configScript $styles $script EOT; -?> +?> \ No newline at end of file diff --git a/source/compose.manager/compose.manager.page b/source/compose.manager/compose.manager.page index cf0ef965..63f3ae14 100644 --- a/source/compose.manager/compose.manager.page +++ b/source/compose.manager/compose.manager.page @@ -31,4 +31,4 @@ if (!empty($_SERVER['REQUEST_URI'])) { - + \ No newline at end of file diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page index c0c693e3..f8f601ed 100755 --- a/source/compose.manager/compose.manager.settings.page +++ b/source/compose.manager/compose.manager.settings.page @@ -31,7 +31,6 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' - - - - - - - - + + + + + + + @@ -6217,7 +458,7 @@ function addComposeStackContext(elementId) { + +
+
+
+
@@ -6253,9 +500,12 @@ function addComposeStackContext(elementId) {
-
+ +
-

Configure icons, WebUI links, and shell commands for each service. These labels integrate your containers with the unRAID Docker UI.

+
+

Configure icons, WebUI links, and shell commands for each service. These labels integrate your containers with the unRAID Docker UI.

+
@@ -6264,6 +514,27 @@ function addComposeStackContext(elementId) {
+ +
@@ -6333,10 +604,16 @@ function addComposeStackContext(elementId) {
+
+ + +
Path to a specific external compose file (e.g., /mnt/user/appdata/myapp/custom.compose.yml). Leave empty to use folder mode or local project files.
+
+
- -
Path to an external .env file (e.g., /mnt/user/appdata/myapp/.env). Leave empty to use the default .env file in the project folder.
+ +
Path to an external .env file (e.g., /mnt/user/appdata/myapp/.env). Leave empty to use the default .env file in the compose source folder (project folder or indirect folder).
@@ -6351,6 +628,24 @@ function addComposeStackContext(elementId) {
+ +
+ + +
Enable this for projects that rely on auto-loaded compose.override.* and/or COMPOSE_FILE defined in .env. Leave disabled to keep explicit file selection behavior.
+
+ +
+ + +
When Automatic, the plugin manages the override file in the project directory — label edits and background maintenance are applied automatically. When Manual, you control the override file directly; all automated edits are blocked and the Labels tab opens the raw override editor instead of the form view.
+
@@ -6358,170 +653,30 @@ function addComposeStackContext(elementId) { - - - + \ No newline at end of file diff --git a/source/compose.manager/include/CronManager.php b/source/compose.manager/include/CronManager.php index 2e582d89..66c60879 100644 --- a/source/compose.manager/include/CronManager.php +++ b/source/compose.manager/include/CronManager.php @@ -1,4 +1,5 @@ diff --git a/source/compose.manager/include/Defines.php b/source/compose.manager/include/Defines.php index 2bbdc899..b3e6f50e 100644 --- a/source/compose.manager/include/Defines.php +++ b/source/compose.manager/include/Defines.php @@ -1,7 +1,8 @@ \ No newline at end of file diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index cdbf8203..63bcfa9b 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -20,6 +20,49 @@ function getPostScript(): string } } +if (!function_exists('getExpectedClientPath')) { + function getExpectedClientPath(): string + { + return isset($_POST['expectedPath']) ? trim((string) $_POST['expectedPath']) : ''; + } +} + +if (!function_exists('rejectStaleClientPath')) { + function rejectStaleClientPath(?string $actualPath, string $label): bool + { + $expectedPath = getExpectedClientPath(); + if ($expectedPath === '') { + return false; + } + + $actualPath = $actualPath !== null ? trim($actualPath) : ''; + if ($actualPath !== '' && Path::refersToSamePath($expectedPath, $actualPath)) { + return false; + } + + composeLogger( + "Rejected stale {$label} write target", + [ + 'expectedPath' => $expectedPath, + 'actualPath' => $actualPath, + ], + 'user', + 'warning', + 'editor' + ); + + echo json_encode([ + 'result' => 'error', + 'errorCode' => 'stale_path', + 'message' => 'The target path changed while the editor was open. Reload the editor and try again.', + 'expectedPath' => $expectedPath, + 'actualPath' => $actualPath, + ]); + + return true; + } +} + switch ($_POST['action']) { case 'composeLogger': $message = $_POST['msg'] ?? ''; @@ -34,25 +77,53 @@ function getPostScript(): string echo json_encode(['result' => 'success', 'config' => $cfg]); break; case 'addStack': - // Validate indirect path (HTTP-boundary security check) - $indirect = isset($_POST['stackPath']) ? trim($_POST['stackPath']) : ''; - if ($indirect !== '') { - $realIndirect = realpath(dirname($indirect)); + // Validate optional indirect inputs (folder or specific compose file) + $indirectDir = isset($_POST['stackPath']) ? trim($_POST['stackPath']) : ''; + $indirectFile = isset($_POST['stackFilePath']) ? trim($_POST['stackFilePath']) : ''; + if ($indirectDir !== '' && $indirectFile !== '') { + echo json_encode(['result' => 'error', 'message' => 'Set either Indirect Path or Indirect Compose File, not both.']); + break; + } + + $indirect = ''; + if ($indirectDir !== '') { + $realIndirect = realpath($indirectDir); if ($realIndirect === false) { - composeLogger("Failed to create stack: Could not resolve indirect path: $indirect", null, 'user', 'error', 'stack'); + composeLogger("Failed to create stack: Could not resolve indirect path: $indirectDir", null, 'user', 'error', 'stack'); echo json_encode(['result' => 'error', 'message' => 'Stack path is invalid or does not exist.']); break; } - if (strpos($realIndirect, '/mnt/') !== 0 && strpos($realIndirect, '/boot/config/') !== 0) { - composeLogger("Failed to create stack: Invalid indirect path: $indirect", null, 'user', 'error', 'stack'); + if (!Path::isAllowedPath($realIndirect, ['/mnt', '/boot/config'])) { + composeLogger("Failed to create stack: Invalid indirect path: $indirectDir", null, 'user', 'error', 'stack'); echo json_encode(['result' => 'error', 'message' => 'Stack path must be under /mnt/ or /boot/config/.']); break; } - if (!is_dir($indirect)) { - composeLogger("Failed to create stack: Indirect stack path does not exist: $indirect", null, 'user', 'error', 'stack'); + if (!is_dir($realIndirect)) { + composeLogger("Failed to create stack: Indirect stack path does not exist: $indirectDir", null, 'user', 'error', 'stack'); echo json_encode(['result' => 'error', 'message' => 'Indirect stack path does not exist.']); break; } + // Preserve the user-provided path (e.g. /mnt/user/...) instead of + // persisting a resolved /mnt/diskX/... path from realpath(). + $indirect = rtrim($indirectDir, '/'); + } elseif ($indirectFile !== '') { + $realFile = realpath($indirectFile); + if ($realFile === false || !is_file($realFile)) { + composeLogger("Failed to create stack: Indirect compose file does not exist: $indirectFile", null, 'user', 'error', 'stack'); + echo json_encode(['result' => 'error', 'message' => 'Indirect compose file does not exist.']); + break; + } + if (!Path::isAllowedPath($realFile, ['/mnt', '/boot/config'])) { + composeLogger("Failed to create stack: Invalid indirect compose file path: $indirectFile", null, 'user', 'error', 'stack'); + echo json_encode(['result' => 'error', 'message' => 'Indirect compose file must be under /mnt/ or /boot/config/.']); + break; + } + if (preg_match('/\.ya?ml$/i', basename($realFile)) !== 1) { + echo json_encode(['result' => 'error', 'message' => 'Indirect compose file must be a .yml or .yaml file.']); + break; + } + // Preserve the originally-entered file path so /mnt/user is retained. + $indirect = $indirectFile; } $stackName = isset($_POST['stackName']) ? trim($_POST['stackName']) : ''; @@ -75,8 +146,57 @@ function getPostScript(): string break; } + // Apply creation-time options, falling back to global defaults. + $cfg = @parse_plugin_cfg($sName); + + $useDefaultComposeFilesRaw = isset($_POST['useDefaultComposeFiles']) + ? strtolower(trim((string) $_POST['useDefaultComposeFiles'])) + : strtolower(trim((string) ($cfg['NEW_STACK_USE_DEFAULT_COMPOSE_FILES'] ?? 'false'))); + $useDefaultComposeFiles = $useDefaultComposeFilesRaw === 'true'; + if ($indirectFile !== '') { + $useDefaultComposeFiles = false; + } + + $overrideManagementAutomaticRaw = isset($_POST['overrideManagementAutomatic']) + ? strtolower(trim((string) $_POST['overrideManagementAutomatic'])) + : strtolower(trim((string) ($cfg['NEW_STACK_OVERRIDE_MANAGEMENT_AUTOMATIC'] ?? 'true'))); + $overrideManagementAutomatic = $overrideManagementAutomaticRaw !== 'false'; + + $stackPath = $compose_root . '/' . $stack->projectFolder; + + $useDefaultComposeFilesFile = "$stackPath/use_default_compose_files"; + if ($useDefaultComposeFiles) { + file_put_contents($useDefaultComposeFilesFile, 'true'); + } elseif (is_file($useDefaultComposeFilesFile)) { + @unlink($useDefaultComposeFilesFile); + } + + $labelsViewModeFile = "$stackPath/labels_view_mode"; + if ($overrideManagementAutomatic) { + if (is_file($labelsViewModeFile)) { + @unlink($labelsViewModeFile); + } + } else { + file_put_contents($labelsViewModeFile, 'advanced'); + } + + $envPathInput = isset($_POST['envPath']) ? trim($_POST['envPath']) : ''; + $envPathFile = "$stackPath/envpath"; + if ($envPathInput !== '') { + file_put_contents($envPathFile, $envPathInput); + } elseif (is_file($envPathFile)) { + @unlink($envPathFile); + } + composeLogger("Created stack: $stackName", null, 'user', 'info', 'stack'); - echo json_encode(['result' => 'success', 'message' => '', 'project' => $stack->projectFolder, 'projectName' => $stack->getName()]); + echo json_encode([ + 'result' => 'success', + 'message' => '', + 'project' => $stack->projectFolder, + 'projectName' => $stack->getName(), + 'useDefaultComposeFiles' => $useDefaultComposeFiles, + 'overrideManagementAutomatic' => $overrideManagementAutomatic, + ]); break; case 'deleteStack': $stackName = isset($_POST['stackName']) ? basename(trim($_POST['stackName'])) : ""; @@ -90,8 +210,35 @@ function getPostScript(): string $isInvalidIndirect = !$isIndirect && is_file("$folderName/indirect.invalid"); $filesRemain = $isIndirect ? file_get_contents("$folderName/indirect") : ($isInvalidIndirect ? file_get_contents("$folderName/indirect.invalid") : ""); - composeLogger("Deleting stack: $stackName", null, 'user', 'info', 'stack'); - exec("rm -rf " . escapeshellarg($folderName)); + composeLogger("Deleting stack: $stackName", [ + 'folderName' => $folderName, + 'isIndirect' => $isIndirect, + 'isInvalidIndirect' => $isInvalidIndirect, + 'filesRemain' => $filesRemain + ], 'user', 'debug', 'stack'); + + $execOutput = []; + $execRc = 0; + exec("rm -rf " . escapeshellarg($folderName), $execOutput, $execRc); + + $folderStillExists = is_dir($folderName); + if ($execRc !== 0 || $folderStillExists) { + composeLogger("Stack folder delete failed", [ + 'stackName' => $stackName, + 'folderName' => $folderName, + 'execRc' => $execRc, + 'execOutput' => $execOutput, + 'folderStillExists' => $folderStillExists, + 'filesRemain' => $filesRemain + ], 'user', 'error', 'stack'); + $msg = "Failed to delete stack folder. " . + ($execRc !== 0 ? "rm exit code: $execRc. " : "") . + ($folderStillExists ? "Folder still exists after rm. " : "") . + (count($execOutput) ? "Output: " . implode("; ", $execOutput) : ""); + echo json_encode(['result' => 'error', 'message' => $msg]); + break; + } + if ($filesRemain == "") { composeLogger("Deleted stack: $stackName", null, 'user', 'info', 'stack'); echo json_encode(['result' => 'success', 'message' => '']); @@ -155,26 +302,127 @@ function getPostScript(): string $stackInfo = StackInfo::fromProject($compose_root, $script); $fileName = $stackInfo->getEnvFilePath() ?? ($stackInfo->composeSource . '/.env'); - $scriptContents = is_file($fileName) ? file_get_contents($fileName) : ""; - $scriptContents = str_replace("\r", "", $scriptContents); - if (!$scriptContents) { - $scriptContents = "\n"; + $envExists = is_file($fileName); + $scriptContents = $envExists ? file_get_contents($fileName) : ""; + $scriptContents = str_replace("\r", "", (string) $scriptContents); + echo json_encode([ + 'result' => 'success', + 'fileName' => $fileName, + 'content' => $scriptContents, + 'exists' => $envExists + ]); + break; + case 'createEnvTemplate': + $script = getPostScript(); + + $stackInfo = StackInfo::fromProject($compose_root, $script); + $envPath = $stackInfo->getEnvFilePath() ?? ($stackInfo->composeSource . '/.env'); + + if (rejectStaleClientPath($envPath, '.env template')) { + break; + } + + $envDir = dirname($envPath); + if (!is_dir($envDir)) { + echo json_encode(['result' => 'error', 'message' => 'Target directory does not exist.']); + break; + } + + if (is_dir($envPath)) { + echo json_encode(['result' => 'error', 'message' => 'Target .env path is a directory.']); + break; + } + + if (!is_file($envPath)) { + $written = @file_put_contents($envPath, OverrideInfo::buildEnvTemplateContent()); + if ($written === false) { + echo json_encode(['result' => 'error', 'message' => 'Failed to create .env template.']); + break; + } + } + + if (!is_file($envPath)) { + echo json_encode(['result' => 'error', 'message' => 'Failed to create .env template.']); + break; } - echo json_encode(['result' => 'success', 'fileName' => $fileName, 'content' => $scriptContents]); + + $content = file_get_contents($envPath); + $content = str_replace("\r", "", (string) $content); + + echo json_encode([ + 'result' => 'success', + 'fileName' => $envPath, + 'content' => $content, + 'exists' => is_file($envPath) + ]); break; case 'getOverride': $script = getPostScript(); - $projectPath = "$compose_root/$script"; - // Get Override file path and ensure project override exists (create blank if not) - $overridePath = StackInfo::fromProject($compose_root, $script)->getOverridePath(); + // Get Override file path - can read from indirect if present, else project + $stackInfo = StackInfo::fromProject($compose_root, $script); + $overridePath = $stackInfo->getPreferredOverridePath(); $scriptContents = is_file($overridePath) ? file_get_contents($overridePath) : ""; $scriptContents = str_replace("\r", "", $scriptContents); if (!$scriptContents) { $scriptContents = ""; } - echo json_encode(['result' => 'success', 'fileName' => $overridePath, 'content' => $scriptContents]); + echo json_encode([ + 'result' => 'success', + 'fileName' => $overridePath, + 'content' => $scriptContents, + 'exists' => is_file($overridePath) + ]); + break; + case 'createOverrideTemplate': + $script = getPostScript(); + + $stackInfo = StackInfo::fromProject($compose_root, $script); + $overridePath = $stackInfo->getPreferredOverridePath(); + + if (rejectStaleClientPath($overridePath, 'override template')) { + break; + } + + if ($overridePath === null) { + echo json_encode(['result' => 'error', 'message' => 'Unable to resolve override path']); + break; + } + + $overrideDir = dirname($overridePath); + if (!is_dir($overrideDir)) { + echo json_encode(['result' => 'error', 'message' => 'Override target directory does not exist.']); + break; + } + + if (is_dir($overridePath)) { + echo json_encode(['result' => 'error', 'message' => 'Override target path is a directory.']); + break; + } + + if (!is_file($overridePath)) { + $written = @file_put_contents($overridePath, OverrideInfo::buildTemplateContent()); + if ($written === false) { + echo json_encode(['result' => 'error', 'message' => 'Failed to create override template.']); + break; + } + } + + if (!is_file($overridePath)) { + echo json_encode(['result' => 'error', 'message' => 'Failed to create override template.']); + break; + } + + $content = file_get_contents($overridePath); + $content = str_replace("\r", "", (string) $content); + + echo json_encode([ + 'result' => 'success', + 'fileName' => $overridePath, + 'content' => $content, + 'exists' => is_file($overridePath) + ]); break; case 'saveYml': $script = getPostScript(); @@ -183,7 +431,11 @@ function getPostScript(): string $stackInfo = StackInfo::fromProject($compose_root, $script); $composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); - // Before saving, detect service renames and migrate override entries + if (rejectStaleClientPath($composeFilePath, 'compose file')) { + break; + } + + // Before saving, detect service renames and migrate override entries in the project override only if (is_file($composeFilePath)) { $oldContent = file_get_contents($composeFilePath); $stackInfo->overrideInfo->migrateOnRename($oldContent, $scriptContents); @@ -199,16 +451,33 @@ function getPostScript(): string $stackInfo = StackInfo::fromProject($compose_root, $script); $fileName = $stackInfo->getEnvFilePath() ?? ($stackInfo->composeSource . '/.env'); + if (rejectStaleClientPath($fileName, '.env file')) { + break; + } + file_put_contents($fileName, $scriptContents); echo "$fileName saved"; break; case 'saveOverride': $script = getPostScript(); $scriptContents = isset($_POST['scriptContents']) ? $_POST['scriptContents'] : ""; - $projectPath = "$compose_root/$script"; + $isManaged = isset($_POST['managed']) && $_POST['managed'] === '1'; + + $stackInfo = StackInfo::fromProject($compose_root, $script); + // managed=1 (Automatic): plugin controls override, write to project dir only + // managed=0 (Manual): user controls override, write to the preferred override target + $overridePath = $isManaged + ? $stackInfo->overrideInfo->getProjectOverridePath() + : $stackInfo->getPreferredOverridePath(); - // Get Override file path and ensure project override exists (create blank if not) - $overridePath = StackInfo::fromProject($compose_root, $script)->getOverridePath(); + if (rejectStaleClientPath($overridePath, 'override file')) { + break; + } + + if ($overridePath === null) { + echo json_encode(['result' => 'error', 'message' => 'Unable to resolve override path']); + break; + } file_put_contents($overridePath, $scriptContents); echo "$overridePath saved"; @@ -409,6 +678,13 @@ function getPostScript(): string echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } + + try { + $stackInfo = StackInfo::fromProject($compose_root, $script); + } catch (\Throwable $e) { + echo json_encode(['result' => 'error', 'message' => 'Unable to load stack settings.']); + break; + } // Get env path $envPathFile = "$compose_root/$script/envpath"; $envPath = is_file($envPathFile) ? trim(file_get_contents($envPathFile)) : ""; @@ -425,18 +701,46 @@ function getPostScript(): string $defaultProfileFile = "$compose_root/$script/default_profile"; $defaultProfile = is_file($defaultProfileFile) ? trim(file_get_contents($defaultProfileFile)) : ""; + // Get labels tab view mode (per-stack) + $labelsViewModeFile = "$compose_root/$script/labels_view_mode"; + $labelsViewMode = is_file($labelsViewModeFile) ? strtolower(trim(file_get_contents($labelsViewModeFile))) : 'basic'; + if ($labelsViewMode !== 'advanced') { + $labelsViewMode = 'basic'; + } + + // Use Docker Compose default file discovery (no explicit -f) + // Use the StackInfo method so the effective value (gated by manual overrides) is returned. + $useDefaultComposeFiles = $stackInfo->useDefaultComposeFileDiscovery(); + // Get external compose path (indirect) $indirectFile = "$compose_root/$script/indirect"; $invalidIndirectFile = "$compose_root/$script/indirect.invalid"; $externalComposePath = ""; + $externalComposeFilePath = ""; $invalidIndirectPath = ""; if (is_file($indirectFile)) { $raw = trim(file_get_contents($indirectFile)); - if ($raw !== '' && is_dir($raw)) { - $externalComposePath = $raw; - } else { - // Path is invalid or directory unavailable — non-destructive handling - $invalidIndirectPath = $raw; + if ($raw !== '') { + if ($stackInfo->indirectMode === 'file') { + if (is_file($raw)) { + $externalComposeFilePath = $raw; + } else { + $invalidIndirectPath = $raw; + } + } elseif ($stackInfo->indirectMode === 'folder') { + if (is_dir($raw)) { + $externalComposePath = $raw; + } else { + $invalidIndirectPath = $raw; + } + } elseif (is_dir($raw)) { + $externalComposePath = $raw; + } elseif (is_file($raw)) { + $externalComposeFilePath = $raw; + } else { + // Path is invalid or unavailable — non-destructive handling + $invalidIndirectPath = $raw; + } } } elseif (is_file($invalidIndirectFile)) { // Legacy fallback: older versions renamed indirect → indirect.invalid @@ -459,11 +763,42 @@ function getPostScript(): string 'iconUrl' => $iconUrl, 'webuiUrl' => $webuiUrl, 'defaultProfile' => $defaultProfile, + 'labelsViewMode' => $labelsViewMode, + 'useDefaultComposeFiles' => $useDefaultComposeFiles, + 'indirectMode' => $stackInfo->indirectMode, 'externalComposePath' => $externalComposePath, + 'externalComposeFilePath' => $externalComposeFilePath, 'invalidIndirectPath' => $invalidIndirectPath, - 'availableProfiles' => $availableProfiles + 'availableProfiles' => $availableProfiles, + 'projectPath' => "$compose_root/$script", + 'projectOverridePath' => $stackInfo->overrideInfo->getProjectOverridePath(), + 'effectiveOverridePath' => $stackInfo->getPreferredOverridePath(), ]); break; + case 'setLabelsViewMode': + $script = getPostScript(); + if (!$script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); + break; + } + + $labelsViewMode = isset($_POST['labelsViewMode']) ? strtolower(trim((string) $_POST['labelsViewMode'])) : 'basic'; + if ($labelsViewMode !== 'advanced' && $labelsViewMode !== 'basic') { + echo json_encode(['result' => 'error', 'message' => 'Invalid labels view mode.']); + break; + } + + $labelsViewModeFile = "$compose_root/$script/labels_view_mode"; + if ($labelsViewMode === 'advanced') { + file_put_contents($labelsViewModeFile, 'advanced'); + } else { + if (is_file($labelsViewModeFile)) { + @unlink($labelsViewModeFile); + } + } + + echo json_encode(['result' => 'success', 'labelsViewMode' => $labelsViewMode]); + break; case 'detectWebui': $script = getPostScript(); if (!$script) { @@ -509,12 +844,21 @@ function getPostScript(): string $envPath = isset($_POST['envPath']) ? trim($_POST['envPath']) : ""; $defaultProfile = isset($_POST['defaultProfile']) ? trim($_POST['defaultProfile']) : ""; + $useDefaultComposeFiles = isset($_POST['useDefaultComposeFiles']) + && strtolower(trim((string) $_POST['useDefaultComposeFiles'])) === 'true'; $externalComposePath = isset($_POST['externalComposePath']) ? trim($_POST['externalComposePath']) : ""; $externalComposePath = rtrim($externalComposePath, '/'); + $externalComposeFilePath = isset($_POST['externalComposeFilePath']) ? trim($_POST['externalComposeFilePath']) : ""; + + if (!empty($externalComposePath) && !empty($externalComposeFilePath)) { + echo json_encode(['result' => 'error', 'message' => 'Set either External Compose Path or External Compose File, not both.']); + break; + } + if (!empty($externalComposePath)) { $realPath = realpath($externalComposePath) ?: $externalComposePath; - if (strpos($realPath, '/mnt/') !== 0 && strpos($realPath, '/boot/config/') !== 0) { + if (!Path::isAllowedPath($realPath, ['/mnt', '/boot/config'])) { echo json_encode(['result' => 'error', 'message' => 'External compose path must be under /mnt/ or /boot/config/.']); break; } @@ -522,6 +866,34 @@ function getPostScript(): string echo json_encode(['result' => 'error', 'message' => 'External compose path directory does not exist: ' . $externalComposePath]); break; } + + $projectPath = realpath("$compose_root/$script") ?: rtrim("$compose_root/$script", '/'); + if (Path::refersToSamePath($realPath, $projectPath)) { + echo json_encode(['result' => 'error', 'message' => 'External compose path cannot be the stack project folder. Use a path under /mnt/ or /boot/config/ that is external to this stack.']); + break; + } + } + + if (!empty($externalComposeFilePath)) { + $realFile = realpath($externalComposeFilePath); + if ($realFile === false || !is_file($realFile)) { + echo json_encode(['result' => 'error', 'message' => 'External compose file does not exist: ' . $externalComposeFilePath]); + break; + } + if (!Path::isAllowedPath($realFile, ['/mnt', '/boot/config'])) { + echo json_encode(['result' => 'error', 'message' => 'External compose file must be under /mnt/ or /boot/config/.']); + break; + } + if (preg_match('/\.ya?ml$/i', basename($realFile)) !== 1) { + echo json_encode(['result' => 'error', 'message' => 'External compose file must be a .yml or .yaml file.']); + break; + } + + $projectPath = realpath("$compose_root/$script") ?: rtrim("$compose_root/$script", '/'); + if (Path::refersToSamePath(dirname($realFile), $projectPath)) { + echo json_encode(['result' => 'error', 'message' => 'External compose file cannot be inside the stack project folder. Use a path under /mnt/ or /boot/config/ that is external to this stack.']); + break; + } } // --- All validation passed, now write everything --- @@ -562,26 +934,53 @@ function getPostScript(): string file_put_contents($defaultProfileFile, $defaultProfile); } + // Set compose file discovery mode + $useDefaultComposeFilesFile = "$compose_root/$script/use_default_compose_files"; + if ($useDefaultComposeFiles) { + file_put_contents($useDefaultComposeFilesFile, 'true'); + } else { + if (is_file($useDefaultComposeFilesFile)) { + @unlink($useDefaultComposeFilesFile); + } + } + // Set external compose path (indirect) $indirectFile = "$compose_root/$script/indirect"; $invalidIndirectFile = "$compose_root/$script/indirect.invalid"; - if (empty($externalComposePath)) { + $indirectModeFile = "$compose_root/$script/indirect_mode"; + $indirectTarget = ''; + if (!empty($externalComposeFilePath)) { + $indirectTarget = $externalComposeFilePath; + } elseif (!empty($externalComposePath)) { + $indirectTarget = $externalComposePath; + } + + if ($indirectTarget === '') { // Removing indirect: move compose file back to project folder if it only exists externally if (is_file($indirectFile)) { $oldIndirectPath = trim(file_get_contents($indirectFile)); $localCompose = findComposeFile("$compose_root/$script"); - $externalCompose = findComposeFile($oldIndirectPath); + $externalCompose = false; + if (is_dir($oldIndirectPath)) { + $externalCompose = findComposeFile($oldIndirectPath); + } elseif (is_file($oldIndirectPath)) { + $externalCompose = $oldIndirectPath; + } if (!$localCompose && $externalCompose) { copy($externalCompose, "$compose_root/$script/" . basename($externalCompose)); } @unlink($indirectFile); } + if (is_file($indirectModeFile)) { + @unlink($indirectModeFile); + } // Also clean up any invalid indirect file when clearing the path if (is_file($invalidIndirectFile)) { @unlink($invalidIndirectFile); } } else { - file_put_contents($indirectFile, $externalComposePath); + file_put_contents($indirectFile, $indirectTarget); + file_put_contents($indirectModeFile, is_file($indirectTarget) ? 'file' : 'folder'); // Clean up the invalid file now that we have a corrected path if (is_file($invalidIndirectFile)) { @unlink($invalidIndirectFile); @@ -589,7 +988,13 @@ function getPostScript(): string // Remove local compose file if it exists since we're now using external $localCompose = findComposeFile("$compose_root/$script"); if ($localCompose) { - @unlink($localCompose); + $projectPath = realpath("$compose_root/$script") ?: rtrim("$compose_root/$script", '/'); + $resolvedExternalPath = is_dir($indirectTarget) + ? (realpath($indirectTarget) ?: rtrim($indirectTarget, '/')) + : dirname($indirectTarget); + if ($resolvedExternalPath !== $projectPath) { + @unlink($localCompose); + } } } @@ -614,6 +1019,11 @@ function getPostScript(): string break; case 'getStackContainers': $script = getPostScript(); + composeLogger('getStackContainers start', [ + 'script' => $script, + 'post' => $_POST, + 'caller' => $_SERVER['REMOTE_ADDR'] ?? 'cli', + ], 'user', 'debug', 'getStackContainers'); if (!$script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -621,10 +1031,8 @@ function getPostScript(): string // Resolve stack identity and compose CLI arguments via StackInfo $stackInfo = StackInfo::fromProject($compose_root, $script); - // Get container details in JSON format (all states) $rows = $stackInfo->getContainerList(); - // Hard dependency on Docker manager: use shared helpers directly. $networkDrivers = DockerUtil::driver(); $hostIP = trim((string) DockerUtil::host()); @@ -641,6 +1049,9 @@ function getPostScript(): string $stackState = $stackInfo->getStackState(); foreach ($rows as $rawContainer) { + composeLogger('getStackContainers found container row', [ + 'rawContainer' => $rawContainer + ], 'user', 'debug', 'getStackContainers'); // Get additional details using docker inspect $ctName = $rawContainer['Name'] ?? ''; if ($ctName) { @@ -785,7 +1196,29 @@ function getPostScript(): string $containers[] = ContainerInfo::fromDockerInspect($rawContainer)->toArray(); } + // --- Persistent container metadata cache --- + $cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json'; + $cache = is_file($cacheFile) ? json_decode(file_get_contents($cacheFile), true) : []; + $stackKey = $stackInfo->projectFolder; + if (!isset($cache[$stackKey])) $cache[$stackKey] = []; + foreach ($containers as $ct) { + $service = $ct['service'] ?? $ct['Name'] ?? ''; + if ($service) { + $cache[$stackKey][$service] = $ct; + } + } + file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + echo json_encode(['result' => 'success', 'containers' => $containers, 'stackState' => $stackState, 'projectName' => $stackInfo->projectFolder, 'startedAt' => $stackInfo->getStartedAt()]); + composeLogger('getStackContainers done', [ + 'script' => $script, + 'containersCount' => count($containers), + 'containerNames' => array_map(function ($c) { + return $c['name'] ?? ($c['Name'] ?? ''); + }, $containers), + 'stackState' => $stackState, + 'projectName' => $stackInfo->projectFolder, + ], 'user', 'debug', 'getStackContainers'); break; case 'getProfileServices': // Returns the list of services that docker compose would act on for the diff --git a/source/compose.manager/include/Helpers.php b/source/compose.manager/include/Helpers.php index 1836ffdd..254a0ea3 100644 --- a/source/compose.manager/include/Helpers.php +++ b/source/compose.manager/include/Helpers.php @@ -84,6 +84,48 @@ function getLastCmdLogFileForComposeAction($action, $path) return rtrim($path, '/') . '/last_cmd.log'; } +/** + * Append compose file discovery arguments to compose.sh command args. + * + * @param array $composeCommand + * @param array $args + */ +function appendComposeFileArgs(array &$composeCommand, array $args): void +{ + if (!empty($args['useDefaultFileDiscovery'])) { + $projectDirectory = $args['projectDirectory'] ?? ''; + if (is_string($projectDirectory) && $projectDirectory !== '') { + $composeCommand[] = "-w" . $projectDirectory; + } + return; + } + + $filePaths = $args['filePaths'] ?? []; + if (!is_array($filePaths)) { + return; + } + + foreach ($filePaths as $filePath) { + if (is_string($filePath) && $filePath !== '' && is_file($filePath)) { + $composeCommand[] = "-f" . $filePath; + } + } +} + +/** + * Append env-file argument to compose.sh command args when valid. + * + * @param array $composeCommand + * @param array $args + */ +function appendComposeEnvFileArg(array &$composeCommand, array $args): void +{ + $envFilePath = $args['envFilePath'] ?? null; + if (is_string($envFilePath) && $envFilePath !== '' && is_file($envFilePath)) { + $composeCommand[] = "-e" . $envFilePath; + } +} + /** * Build and echo a compose command for a single stack. * @@ -119,25 +161,25 @@ function echoComposeCommand($action, $recreate = false, $background = false) $composeCommand = array($plugin_root . "scripts/compose.sh"); // Resolve stack identity via StackInfo - $stackInfo = StackInfo::fromProject($compose_root, basename($path)); + try { + $stackInfo = StackInfo::fromProject($compose_root, basename($path)); + } catch (\Throwable $e) { + composeLogger("Cannot perform action: invalid stack", ['action' => $action, 'path' => $path, 'error' => $e->getMessage()], 'user', 'warning', 'compose'); + echo ''; + return; + } + $args = $stackInfo->buildComposeArgs(); $composeCommand[] = "-c" . $action; - $composeCommand[] = "-p" . $stackInfo->projectName; - - $composeFile = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); - $composeCommand[] = "-f" . $composeFile; + $composeCommand[] = "-p" . $args['projectName']; + appendComposeFileArgs($composeCommand, $args); // Prune orphaned services from override before compose up if ($action === 'up') { $stackInfo->pruneOrphanOverrideServices(); } - $composeCommand[] = "-f" . $stackInfo->getOverridePath(); - - $envFilePath = $stackInfo->getEnvFilePath(); - if ($envFilePath !== null) { - $composeCommand[] = "-e" . $envFilePath; - } + appendComposeEnvFileArg($composeCommand, $args); // Support multiple profiles (comma-separated) if ($profile) { @@ -243,27 +285,25 @@ function echoComposeCommandMultiple($action, $paths, $background = false) $project = basename($path); // Resolve stack identity via StackInfo - $stackInfo = StackInfo::fromProject($compose_root, $project); + try { + $stackInfo = StackInfo::fromProject($compose_root, $project); + } catch (\Throwable $e) { + composeLogger("Skipping invalid stack during multi-compose action", ['action' => $action, 'path' => $path, 'error' => $e->getMessage()], 'user', 'warning', 'compose-multi'); + continue; + } $stackNames[] = $stackInfo->getName(); + $args = $stackInfo->buildComposeArgs(); $composeCommand[] = "-c" . $action; - $composeCommand[] = "-p" . $stackInfo->projectName; - - $composeFile = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); - $composeCommand[] = "-f" . $composeFile; + $composeCommand[] = "-p" . $args['projectName']; + appendComposeFileArgs($composeCommand, $args); // Prune orphaned services from override before compose up if ($action === 'up') { $stackInfo->pruneOrphanOverrideServices(); } - $composeCommand[] = "-f" . $stackInfo->getOverridePath(); - - // Add env-file if available for this stack - $envFilePath = $stackInfo->getEnvFilePath(); - if ($envFilePath !== null) { - $composeCommand[] = "-e" . $envFilePath; - } + appendComposeEnvFileArg($composeCommand, $args); // Profile selection per action: // - up: use user-configured default profiles (running_profiles @@ -299,6 +339,12 @@ function echoComposeCommandMultiple($action, $paths, $background = false) $commands[] = $composeCommand; } + if (empty($commands)) { + composeLogger("Multi Compose operation aborted: no valid stacks resolved", ['action' => $action], 'user', 'warning', 'compose-multi'); + echo ''; + return; + } + // Human-readable action label for terminal headings $actionLabelByType = [ 'up' => 'Starting', diff --git a/source/compose.manager/include/Icon.php b/source/compose.manager/include/Icon.php index 9786a585..79be8d3f 100644 --- a/source/compose.manager/include/Icon.php +++ b/source/compose.manager/include/Icon.php @@ -1,4 +1,5 @@ document.getElementById('done-btn').addEventListener('click', function() { - try { top.Shadowbox.close(); } catch (e) {} - try { window.close(); } catch (e) {} + try { + top.Shadowbox.close(); + } catch (e) {} + try { + window.close(); + } catch (e) {} }); @@ -176,7 +183,9 @@ function setupFrame(frame) { setupFrame(frame); // Re-attach for any subsequent navigations within the iframe. - frame.addEventListener('load', function() { setupFrame(frame); }); + frame.addEventListener('load', function() { + setupFrame(frame); + }); }); diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index cd50ac16..170adcda 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -1,5 +1,6 @@ } migrateRenamedServices(string $oldComposeContent, string $newComposeContent) Migrate override entries when services are renamed by comparing old and new compose content @@ -615,51 +674,78 @@ public static function fromStackInfo(StackInfo $stackInfo): self } /** - * Create an OverrideInfo by resolving paths from scratch. - * - * @deprecated Use StackInfo::fromProject() which provides OverrideInfo automatically - * via fromStackInfo(), avoiding duplicate filesystem resolution. + * Build the override filename that corresponds to a compose file path. * - * @param string $composeRoot - * @param string $stack - * @return OverrideInfo + * @param string $composePath Compose file path or name + * @return string */ - public static function fromStack(string $composeRoot, string $stack): self + public static function buildComputedNameForPath(string $composePath): string { - $projectPath = rtrim($composeRoot, '/') . '/' . $stack; - $indirectPath = is_file("$projectPath/indirect") - ? trim(file_get_contents("$projectPath/indirect")) - : null; - if ($indirectPath === '') { - $indirectPath = null; + $baseName = basename($composePath); + $computedName = preg_replace('/(\.[^.]+)$/', '.override$1', $baseName); + + if (!is_string($computedName) || $computedName === '') { + return $baseName . '.override'; } - $composeSource = $indirectPath ?? $projectPath; - $foundCompose = findComposeFile($composeSource); - $composeFilePath = $foundCompose !== false ? $foundCompose : null; + return $computedName; + } + + /** + * Default template content for newly created override files. + * + * @return string + */ + public static function buildTemplateContent(): string + { + return "# Override file for UI labels (icon, webui, shell)\n" + . "# This file is managed by Compose Manager\n" + . "#\n" + . "# Manual example:\n" + . "# services:\n" + . "# my-service:\n" + . "# labels:\n" + . "# net.unraid.docker.icon: https://icon-url\n" + . "# net.unraid.docker.webui: https://service-url\n" + . "# net.unraid.docker.shell: /bin/bash\n" + . "services: {}\n"; + } - return self::resolveOverride($projectPath, $indirectPath, $composeFilePath); + /** + * Default template content for newly created .env files. + * + * @return string + */ + public static function buildEnvTemplateContent(): string + { + return "# .env file - define environment variables used by docker compose\n" + . "# Variables defined here are automatically available in compose.yaml via \${VAR_NAME}\n" + . "#\n" + . "# Example:\n" + . "# IMAGE_TAG=latest\n" + . "# DATA_DIR=/mnt/user/appdata/myapp\n" + . "# PORT=8080\n"; } - /** - * Core override resolution logic shared by both factories. - * - * Computes the override filename from the compose file, resolves project - * and indirect override paths while preserving legacy filenames as-is, - * and auto-creates a project override template if needed. - * - * @param string $projectPath Full path to the stack directory - * @param string|null $indirectPath Indirect target directory, or null if not indirect - * @param string|null $composeFilePath Resolved main compose file path, or null if none - * @return OverrideInfo - */ + /** + * Core override resolution logic shared by both factories. + * + * Computes the override filename from the compose file, resolves project + * and indirect override paths while preserving legacy filenames as-is, + * and auto-creates a project override template if needed. + * + * @param string $projectPath Full path to the stack directory + * @param string|null $indirectPath Indirect target directory, or null if not indirect + * @param string|null $composeFilePath Resolved main compose file path, or null if none + * @return OverrideInfo + */ private static function resolveOverride(string $projectPath, ?string $indirectPath, ?string $composeFilePath): self { $info = new self(); $info->composeFilePath = $composeFilePath; - $composeBaseName = $composeFilePath !== null ? basename($composeFilePath) : COMPOSE_FILE_NAMES[0]; - $info->computedName = preg_replace('/(\.[^.]+)$/', '.override$1', $composeBaseName); + $composeBaseName = $composeFilePath !== null ? $composeFilePath : COMPOSE_FILE_NAMES[0]; + $info->computedName = self::buildComputedNameForPath($composeBaseName); $computedProjectOverride = $projectPath . '/' . $info->computedName; $computedIndirectOverride = $indirectPath !== null ? ($indirectPath . '/' . $info->computedName) : null; @@ -686,14 +772,6 @@ private static function resolveOverride(string $projectPath, ?string $indirectPa $info->useIndirect = ($info->indirectOverride && is_file($info->indirectOverride)); $info->mismatchIndirectLegacy = false; - if (!is_file($info->projectOverride) && !$info->useIndirect) { - $overrideContent = "# Override file for UI labels (icon, webui, shell)\n"; - $overrideContent .= "# This file is managed by Compose Manager\n"; - $overrideContent .= "services: {}\n"; - file_put_contents($info->projectOverride, $overrideContent); - composeLogger("Created missing project override template at $info->projectOverride", null, 'user', 'info', 'override'); - } - return $info; } @@ -706,6 +784,16 @@ public function getOverridePath(): ?string return $this->useIndirect ? $this->indirectOverride : $this->projectOverride; } + /** + * Get the project-only override path (for writing only, never indirect) + * Always returns the project directory override, never the indirect path. + * @return string|null + */ + public function getProjectOverridePath(): ?string + { + return $this->projectOverride; + } + /** * Prune orphaned services from the override file. * @@ -718,7 +806,8 @@ public function getOverridePath(): ?string */ public function pruneOrphanServices(array $validServices): array { - $overridePath = $this->getOverridePath(); + // Background auto-process: only ever modify app-managed project override, never external/indirect files + $overridePath = $this->getProjectOverridePath(); if ($overridePath === null || $overridePath === '' || !is_file($overridePath)) { return ['changed' => false, 'removed' => []]; } @@ -767,7 +856,8 @@ public function migrateOnRename(string $oldComposeContent, string $newComposeCon { $result = ['migrated' => false, 'migrations' => []]; - $overridePath = $this->getOverridePath(); + // Background auto-process: only ever modify app-managed project override, never external/indirect files + $overridePath = $this->getProjectOverridePath(); if ($overridePath === null || !is_file($overridePath)) { return $result; } @@ -1209,6 +1299,7 @@ private function derivePinned(): void * @property string $displayName Display name (from ./name) * @property string $path Full path to the stack directory ($composeRoot/$project) * @property string|null $indirectPath Indirect path or null if not indirect + * @property string|null $indirectMode Indirect mode ('folder' or 'file') * @property string $composeSource Resolved compose source directory, direct or indirect * @property string|null $composeFilePath Full path to the main compose file, direct or indirect * @property bool $isIndirect Whether this stack uses an indirect compose path @@ -1246,6 +1337,8 @@ class StackInfo public bool $isIndirect; /** @var string|null Path from a renamed indirect.invalid file, if present (needs user fix) */ public ?string $invalidIndirectPath; + /** @var string|null Indirect mode ('folder' or 'file') */ + public ?string $indirectMode; /** @var OverrideInfo Resolved override info (eager) */ public OverrideInfo $overrideInfo; @@ -1303,15 +1396,30 @@ private function __construct(string $composeRoot, string $projectFolder) // Resolve indirect path and compose source (indirect if present, else direct) $this->isIndirect = $this->isIndirect(); $this->indirectPath = $this->readMetadata('indirect'); + $this->indirectMode = $this->resolveIndirectMode(); $this->invalidIndirectPath = $this->readInvalidIndirect(); - if ($this->invalidIndirectPath === null && !$this->isIndirect && $this->indirectPath !== null && $this->indirectPath !== '') { - // Invalid indirect path still present in ./indirect (non-destructive handling). + if ($this->isIndirect && $this->indirectPath !== null && $this->indirectPath !== '') { + if ($this->indirectMode === 'file') { + $this->composeSource = dirname($this->indirectPath); + $this->composeFilePath = is_file($this->indirectPath) ? $this->indirectPath : null; + } elseif (is_file($this->indirectPath)) { + $this->composeFilePath = $this->indirectPath; + $this->composeSource = dirname($this->indirectPath); + } else { + $this->composeSource = $this->indirectPath; + $this->composeFilePath = self::getComposeFilePath($this->composeSource); + } + } else { + $this->composeSource = $this->path; + $this->composeFilePath = self::getComposeFilePath($this->composeSource); + } + + if ($this->invalidIndirectPath === null && $this->indirectPath !== null && $this->indirectPath !== '' && $this->composeFilePath === null && $this->isIndirect) { + // Preserve the broken indirect target for repair flows. $this->invalidIndirectPath = $this->indirectPath; } - $this->composeSource = $this->isIndirect ? $this->indirectPath : $this->path; // Resolve compose file - $this->composeFilePath = self::getComposeFilePath($this->composeSource); if ($this->composeFilePath === null) { if ($this->invalidIndirectPath !== null) { // Stack has a broken indirect reference — allow degraded construction @@ -1372,6 +1480,10 @@ public static function clearCache(?string $key = null): void */ private static function getComposeFilePath($path): string|null { + if (is_string($path) && is_file($path)) { + return preg_match('/\.ya?ml$/i', basename($path)) === 1 ? $path : null; + } + $composeFilePath = null; foreach (COMPOSE_FILE_NAMES as $name) { if (is_file("$path/$name")) { @@ -1404,12 +1516,6 @@ private function isIndirect(): bool composeLogger("Ignoring structurally invalid indirect path at $this->path/indirect: " . sanitizeLogText($indirectPath), null, 'user', 'warning', 'stack'); return false; } - if (!is_dir($indirectPath)) { - // Directory doesn't exist — may be a temporarily unmounted share (NFS, etc.). - // Ignore it and keep stack local without mutating files. - composeLogger("Ignoring unavailable indirect path (may be temporarily unavailable): " . sanitizeLogText($indirectPath), null, 'user', 'warning', 'stack'); - return false; - } return true; } return false; @@ -1434,6 +1540,52 @@ private function readInvalidIndirect(): ?string return null; } + /** + * Resolve the indirect mode for this stack. + * + * @return string|null + */ + private function resolveIndirectMode(): ?string + { + $rawMode = $this->readMetadata('indirect_mode'); + if ($rawMode !== null) { + $rawMode = strtolower(trim($rawMode)); + if ($rawMode === 'file' || $rawMode === 'folder') { + return $rawMode; + } + } + + $indirectRaw = $this->readMetadata('indirect'); + if ($indirectRaw === null) { + return null; + } + + $indirectRaw = trim($indirectRaw); + if ($indirectRaw === '') { + return null; + } + + return self::inferIndirectMode($indirectRaw); + } + + /** + * Infer indirect mode from the stored path value. + * + * @return string + */ + private static function inferIndirectMode(string $indirectRaw): string + { + if (is_file($indirectRaw)) { + return 'file'; + } + + if (preg_match('/\.ya?ml$/i', basename($indirectRaw)) === 1) { + return 'file'; + } + + return 'folder'; + } + /** * Canonical sanitizer for Display Name to Docker Compose project name. * @@ -1444,26 +1596,11 @@ private function readInvalidIndirect(): ?string */ public static function sanitizeProjectString(string $rawProjectString): string { + $wasEmpty = false; + $sanitizedProjectString = compose_manager_sanitize_project_name($rawProjectString, $wasEmpty); - // Trim and lowercase the input to start normalization. - $sanitizedProjectString = strtolower(trim($rawProjectString)); - - // Replace unsupported characters with underscore. - $sanitizedProjectString = preg_replace('/[^a-z0-9_-]/', '_', $sanitizedProjectString) ?? ''; - - // Collapse multiple underscores into one. - $sanitizedProjectString = preg_replace('/_+/', '_', $sanitizedProjectString); - - // Collapse multiple dashes into one. - $sanitizedProjectString = preg_replace('/-+/', '-', $sanitizedProjectString); - - // Remove leading or trailing underscores or dashes. - $sanitizedProjectString = trim($sanitizedProjectString, '_-'); - - // If the result is empty, default to 'compose' to ensure a valid project name. - if ($sanitizedProjectString === '') { + if ($wasEmpty) { composeLogger("Sanitized project string is empty after processing; defaulting to 'compose'", ['input' => $rawProjectString], 'user', 'warning', 'stack'); - return 'compose'; } return $sanitizedProjectString; @@ -1518,6 +1655,286 @@ public function getEnvFilePath(): ?string return ($val !== null && $val !== '') ? $val : null; } + /** + * Resolve the effective env file path for this stack. + * + * Resolution order: + * 1) `envpath` metadata (explicit stack setting) when it points to a + * readable file + * 2) Local `.env` in compose source (direct or indirect) + * + * If `envpath` exists but is empty, it is treated as unset. + * If `envpath` exists but points to a missing file, it is treated as + * invalid and local `.env` is used as fallback when available. + * + * @return string|null + */ + public function getEffectiveEnvFilePath(): ?string + { + $explicitEnvPath = $this->getExplicitEnvFilePath(); + if ($explicitEnvPath !== null) { + return $explicitEnvPath; + } + + $defaultEnvPath = $this->composeSource . '/.env'; + return is_file($defaultEnvPath) ? $defaultEnvPath : null; + } + + /** + * Resolve a valid explicit envpath configured in stack metadata. + * + * @return string|null + */ + private function getExplicitEnvFilePath(): ?string + { + $rawEnvPath = $this->readMetadata('envpath'); + if ($rawEnvPath === null) { + return null; + } + + $rawEnvPath = trim($rawEnvPath); + if ($rawEnvPath === '') { + return null; + } + + if (is_file($rawEnvPath)) { + return realpath($rawEnvPath) ?: $rawEnvPath; + } + + composeLogger("Ignoring invalid envpath for stack $this->projectFolder: file not found", ['envpath' => $rawEnvPath], 'user', 'warning', 'stack'); + return null; + } + + /** + * Parse COMPOSE_FILE values from the configured env file. + * + * This is used to support projects that declare additional compose + * files via COMPOSE_FILE in the env file loaded with the stack. + * + * @return string[] + */ + private function getAdditionalComposeFilesFromEnv(): array + { + $envFilePath = $this->getEffectiveEnvFilePath(); + if ($envFilePath === null) { + return []; + } + if (!is_file($envFilePath)) { + return []; + } + + $content = @file_get_contents($envFilePath); + if ($content === false) { + return []; + } + + $files = []; + $envDir = dirname($envFilePath); + foreach (preg_split('/\R/', $content) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, ';')) { + continue; + } + if (!str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + if ($key !== 'COMPOSE_FILE') { + continue; + } + $value = trim($value); + if ($value === '') { + continue; + } + foreach (self::splitComposeFileValue($value) as $entry) { + $entry = Strings::stripQuotes(trim($entry)); + if ($entry === '') { + continue; + } + if (Path::isAbsolutePath($entry)) { + $files[] = $entry; + } else { + $files[] = $envDir . '/' . $entry; + } + } + break; + } + + return array_values(array_unique($files)); + } + + /** + * Normalize compose file paths for deduplication. + * + * @return string + */ + private function normalizeComposeFilePath(string $path): string + { + // Normalize equivalent paths to avoid duplicate compose file entries + // when the same file is referenced via slightly different paths. + $real = realpath($path); + if ($real !== false) { + return $real; + } + return rtrim($path, '/\\'); + } + + /** + * Split a COMPOSE_FILE env value into individual file paths. + * + * This preserves quoted segments so values like + * `"compose.debug.yaml":compose.extra.yaml` are parsed correctly. + * + * @param string $value + * @return string[] + */ + private static function splitComposeFileValue(string $value): array + { + $entries = []; + $current = ''; + $quote = null; + $length = strlen($value); + + for ($i = 0; $i < $length; $i++) { + $char = $value[$i]; + if ($quote === null && ($char === '"' || $char === "'")) { + $quote = $char; + $current .= $char; + continue; + } + if ($quote !== null && $char === $quote) { + $quote = null; + $current .= $char; + continue; + } + if ($quote === null && $char === PATH_SEPARATOR) { + $entries[] = $current; + $current = ''; + continue; + } + $current .= $char; + } + + $entries[] = $current; + return $entries; + } + + private function getComposeFilePaths(): array + { + $paths = []; + $mainComposeFile = $this->composeFilePath ?? ($this->composeSource . '/' . COMPOSE_FILE_NAMES[0]); + $paths[] = $mainComposeFile; + + $overridePath = $this->getOverridePath(); + if ($overridePath !== null) { + $paths[] = $overridePath; + } + + foreach ($this->getAdditionalComposeFilesFromEnv() as $extraFile) { + $paths[] = $extraFile; + } + + $normalized = []; + $unique = []; + foreach ($paths as $path) { + $key = $this->normalizeComposeFilePath($path); + if ($key !== '' && !isset($unique[$key])) { + $unique[$key] = true; + $normalized[] = $path; + } + } + + return $normalized; + } + + /** + * Whether this stack should rely on Docker Compose default file discovery. + * + * When enabled, commands omit explicit `-f` flags and run with the stack + * compose source as project directory so Docker Compose can discover + * `compose.yaml`/`compose.override.yaml` and honor COMPOSE_FILE from `.env`. + * + * @return bool + */ + public function useDefaultComposeFileDiscovery(): bool + { + $raw = $this->readMetadata('use_default_compose_files'); + if ($raw === null) { + return false; + } + + if (strtolower(trim($raw)) !== 'true') { + return false; + } + + // Default discovery is disabled only when explicit env or indirect-file mode is set. + return !$this->hasManualComposePathOverrides(); + } + + /** + * Determine whether this stack has manual settings that require explicit mode. + * + * Explicit env-path and indirect-file selections are treated as explicit-mode + * signals, so default file discovery is bypassed. + * + * @return bool + */ + private function hasManualComposePathOverrides(): bool + { + $configuredEnvPath = $this->readMetadata('envpath'); + if ($configuredEnvPath !== null && trim($configuredEnvPath) !== '') { + return true; + } + + return $this->indirectMode === 'file'; + } + + /** + * Build the `-f` flags for all compose file paths used by this stack. + * + * @return string + */ + private function buildComposeFileFlags(): string + { + if ($this->useDefaultComposeFileDiscovery()) { + return '--project-directory ' . escapeshellarg($this->composeSource); + } + + $flags = []; + foreach ($this->getComposeFilePaths() as $filePath) { + if (is_file($filePath)) { + $flags[] = '-f ' . escapeshellarg($filePath); + } + } + return implode(' ', $flags); + } + + /** + * Get the env-file argument for the current stack, if configured. + * + * @return string + */ + private function buildEnvFileFlag(): string + { + if ($this->useDefaultComposeFileDiscovery()) { + $explicitEnvPath = $this->getExplicitEnvFilePath(); + $defaultEnvPath = $this->composeSource . '/.env'; + + if ($explicitEnvPath !== null && !Path::refersToSamePath($explicitEnvPath, $defaultEnvPath)) { + return '--env-file ' . escapeshellarg($explicitEnvPath); + } + + return ''; + } + + $envPath = $this->getEffectiveEnvFilePath(); + if ($envPath !== null && is_file($envPath)) { + return '--env-file ' . escapeshellarg($envPath); + } + return ''; + } + /** * Get the icon URL (from `icon_url` file), validated. * @return string|null @@ -1667,7 +2084,7 @@ private function isProfilesCacheStale(): bool return true; } - $envFilePath = $this->getEnvFilePath(); + $envFilePath = $this->getEffectiveEnvFilePath(); if ($envFilePath !== null && is_file($envFilePath) && filemtime($envFilePath) > $cacheMtime) { return true; } @@ -1690,16 +2107,10 @@ private function extractProfilesFromCompose(): array return []; } - $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); - - $overridePath = $this->getOverridePath(); - if ($overridePath !== null && is_file($overridePath)) { - $cmd .= " -f " . escapeshellarg($overridePath); - } - - $envFilePath = $this->getEnvFilePath(); - if ($envFilePath !== null && is_file($envFilePath)) { - $cmd .= " --env-file " . escapeshellarg($envFilePath); + $cmd = "docker compose " . $this->buildComposeFileFlags(); + $envFlag = $this->buildEnvFileFlag(); + if ($envFlag !== '') { + $cmd .= " " . $envFlag; } $cmd .= " config --profiles 2>/dev/null"; @@ -1741,6 +2152,29 @@ public function getOverridePath(): ?string return $this->overrideInfo->getOverridePath(); } + /** + * Get the path where a new override template should be created. + * + * Uses the indirect target when the stack is indirect, otherwise the + * project override path. + * + * @return string|null + */ + public function getPreferredOverridePath(): ?string + { + if (!$this->isIndirect || $this->indirectPath === null || $this->indirectPath === '') { + return $this->overrideInfo->getProjectOverridePath(); + } + + if ($this->indirectMode === 'file') { + $targetDir = dirname($this->indirectPath); + $targetName = OverrideInfo::buildComputedNameForPath($this->indirectPath); + return rtrim($targetDir, '/') . '/' . $targetName; + } + + return rtrim($this->indirectPath, '/') . '/' . $this->overrideInfo->computedName; + } + /** * Get the list of services defined in the main compose file. * @@ -1824,16 +2258,10 @@ private function extractServicesFromCompose(): array return []; } - $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); - - $overridePath = $this->getOverridePath(); - if ($overridePath !== null && is_file($overridePath)) { - $cmd .= " -f " . escapeshellarg($overridePath); - } - - $envFilePath = $this->getEnvFilePath(); - if ($envFilePath !== null && is_file($envFilePath)) { - $cmd .= " --env-file " . escapeshellarg($envFilePath); + $cmd = "docker compose " . $this->buildComposeFileFlags(); + $envFlag = $this->buildEnvFileFlag(); + if ($envFlag !== '') { + $cmd .= " " . $envFlag; } // Include all profile-scoped services so stack totals reflect the // full compose definition, not only default-profile services. @@ -1877,9 +2305,9 @@ public function pruneOrphanOverrideServices(): array * Get the list of services defined in the main compose file only (without override). * * Used internally by pruneOrphanOverrideServices() to determine which services - * are valid. Excludes the override file so orphaned override services are not - * counted as valid, but enables all profiles so profile-tagged services remain - * valid targets for Unraid label metadata stored in the override file. + * are valid. Excludes the override file so orphaned override services are not + * counted as valid, but enables all profiles so profile-tagged services remain + * valid targets for Unraid label metadata stored in the override file. * * External callers should typically use getDefinedServices() which includes * the override file for a complete picture. @@ -1892,11 +2320,10 @@ private function getMainFileServices(): array return []; } - $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); - - $envFilePath = $this->getEnvFilePath(); - if ($envFilePath !== null && is_file($envFilePath)) { - $cmd .= " --env-file " . escapeshellarg($envFilePath); + $cmd = "docker compose " . $this->buildComposeFileFlags(); + $envFlag = $this->buildEnvFileFlag(); + if ($envFlag !== '') { + $cmd .= " " . $envFlag; } $cmd .= " --profile '*' config --services 2>/dev/null"; @@ -1913,33 +2340,70 @@ private function getMainFileServices(): array /** * Build the common compose CLI arguments for this stack. * - * Returns the project name, file flags, and env-file flag suitable - * for passing to `docker compose`. + * Returns the project name, file flags, env-file flag, and additional + * metadata needed by callers. * - * @return array{projectName: string, files: string, envFile: string} + * @return array{ + * projectName: string, + * files: string, + * envFile: string, + * projectDirectory: string, + * useDefaultFileDiscovery: bool, + * envFilePath?: string, + * filePaths: string[], + * fileList: string + * } */ public function buildComposeArgs(): array { - $composeFile = $this->composeFilePath ?? ($this->composeSource . '/' . COMPOSE_FILE_NAMES[0]); + $useDefaultDiscovery = $this->useDefaultComposeFileDiscovery(); + $files = $this->buildComposeFileFlags(); + $envFile = $this->buildEnvFileFlag(); + $envPath = $this->getEffectiveEnvFilePath(); - $files = "-f " . escapeshellarg($composeFile); - - $overridePath = $this->getOverridePath(); - if ($overridePath !== null) { - $files .= " -f " . escapeshellarg($overridePath); - } + if ($useDefaultDiscovery) { + $explicitEnvPath = $this->getExplicitEnvFilePath(); + $defaultEnvPath = $this->composeSource . '/.env'; - $envFile = ""; - $envPath = $this->getEnvFilePath(); - if ($envPath !== null && is_file($envPath)) { - $envFile = "--env-file " . escapeshellarg($envPath); + if ($explicitEnvPath === null || Path::refersToSamePath($explicitEnvPath, $defaultEnvPath)) { + $envPath = null; + } else { + $envPath = $explicitEnvPath; + } } - return [ + $result = [ 'projectName' => $this->projectName, 'files' => $files, 'envFile' => $envFile, + 'projectDirectory' => $this->composeSource, + 'useDefaultFileDiscovery' => $useDefaultDiscovery, ]; + if ($envPath !== null) { + $result['envFilePath'] = $envPath; + } + $result['filePaths'] = $useDefaultDiscovery ? [] : $this->getComposeFilePaths(); + $result['fileList'] = $this->buildComposeFileList(); + + return $result; + } + + /** + * Build a PATH_SEPARATOR-delimited list of compose file paths for shell environment use. + * + * @return string + */ + public function buildComposeFileList(): string + { + if ($this->useDefaultComposeFileDiscovery()) { + return ''; + } + + $filePaths = $this->getComposeFilePaths(); + $filePaths = array_filter($filePaths, static fn(string $value): bool => $value !== ''); + $filePaths = array_map('trim', $filePaths); + + return implode(PATH_SEPARATOR, $filePaths); } /** @@ -2018,16 +2482,10 @@ private function extractHasBuildFromCompose(): bool return false; } - $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); - - $overridePath = $this->getOverridePath(); - if ($overridePath !== null && is_file($overridePath)) { - $cmd .= " -f " . escapeshellarg($overridePath); - } - - $envFilePath = $this->getEnvFilePath(); - if ($envFilePath !== null && is_file($envFilePath)) { - $cmd .= " --env-file " . escapeshellarg($envFilePath); + $cmd = "docker compose " . $this->buildComposeFileFlags(); + $envFlag = $this->buildEnvFileFlag(); + if ($envFlag !== '') { + $cmd .= " " . $envFlag; } $cmd .= " config 2>/dev/null"; @@ -2177,6 +2635,9 @@ public static function createNew( // Create indirect file to store path to indirect project directory if ($indirectPath !== '') { file_put_contents("$path/indirect", $indirectPath); + file_put_contents("$path/indirect_mode", self::inferIndirectMode($indirectPath)); + } elseif (is_file("$path/indirect_mode")) { + @unlink("$path/indirect_mode"); } // Write metadata @@ -2185,9 +2646,12 @@ public static function createNew( file_put_contents("$path/description", $description); } - // Create default compose file at the appropriate location (indirect target or stack dir) - $composeTarget = ($indirectPath !== '') ? $indirectPath : $path; - self::writeDefaultComposeFile($composeTarget); + // Create default compose file for local stacks and indirect-directory + // stacks. For indirect-file stacks, the selected file already exists. + if ($indirectPath === '' || is_dir($indirectPath)) { + $composeTarget = ($indirectPath !== '') ? $indirectPath : $path; + self::writeDefaultComposeFile($composeTarget); + } // Build + cache the instance (resolves override, etc.) return self::fromProject($composeRoot, basename($path)); diff --git a/source/compose.manager/javascript/common.js b/source/compose.manager/javascript/common.js index db774688..e05bbf45 100644 --- a/source/compose.manager/javascript/common.js +++ b/source/compose.manager/javascript/common.js @@ -214,7 +214,7 @@ function composeTrackFileTreeForInput($input, options) { var scrollContainer = composeGetScrollContainer($input[0]); var $scrollTarget = (scrollContainer === window) ? $(window) : $(scrollContainer); - var updatePosition = function() { + var updatePosition = function () { composePositionFileTreeForInput($input, options); }; @@ -224,7 +224,7 @@ function composeTrackFileTreeForInput($input, options) { var tracker = { $input: $input, update: updatePosition, - cleanup: function() { + cleanup: function () { $scrollTarget.off('.composeFileTree'); $input.removeData('composeFileTreeTrackId'); $input.removeData('composeFileTreeTrackHandlers'); @@ -244,9 +244,9 @@ function composeBindFileTreeInputs($inputs, options) { if (!$.fn.fileTreeAttach || !$inputs || !$inputs.length) return; $inputs.fileTreeAttach(); - $inputs.off('click.composeFileTree focus.composeFileTree').on('click.composeFileTree focus.composeFileTree', function() { + $inputs.off('click.composeFileTree focus.composeFileTree').on('click.composeFileTree focus.composeFileTree', function () { var $input = $(this); - setTimeout(function() { + setTimeout(function () { composePositionFileTreeForInput($input, options); }, FILE_TREE_POSITION_DELAY_MS); }); diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js new file mode 100644 index 00000000..707e23ca --- /dev/null +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -0,0 +1,6930 @@ + +// Parse a single memory value (for example "123.4MiB" or "512MB") to bytes. +// Supports both IEC (KiB, MiB, GiB, TiB) and SI (kB, MB, GB, TB) suffixes. +function parseMemValueToBytes(memVal) { + if (!memVal) return 0; + var cleaned = String(memVal).trim(); + if (!cleaned) return 0; + var match = cleaned.match(/([\d.]+)\s*([kmgt]?i?b)?/i); + if (!match) return 0; + + var num = parseFloat(match[1]); + if (!isFinite(num)) return 0; + var unit = (match[2] || 'b').toLowerCase(); + + switch (unit) { + case 'tb': + return num * 1000000000000; + case 'tib': + return num * 1099511627776; + case 'gb': + return num * 1000000000; + case 'gib': + return num * 1073741824; + case 'mb': + return num * 1000000; + case 'mib': + return num * 1048576; + case 'kb': + return num * 1000; + case 'kib': + return num * 1024; + default: + return num; + } +} + +// Parse docker stats memory string "used / limit" into bytes. +function parseMemUsagePair(memStr) { + if (!memStr) return { + used: 0, + limit: 0 + }; + var parts = String(memStr).split('/'); + var used = parseMemValueToBytes(parts[0] || ''); + var limit = parseMemValueToBytes(parts[1] || ''); + return { + used: used, + limit: limit + }; +} + function updateAddStackDefaultComposeDiscoveryState() { + var $checkbox = $('#compose-stack-use-default-compose-files'); + var $notice = $('#compose-stack-default-compose-files-disabled-note'); + if (!$checkbox.length) return; + + var hasIndirectFile = ($('#compose-stack-indirect-file').val() || '').trim() !== ''; + var hasEnvPath = ($('#compose-stack-env-path').val() || '').trim() !== ''; + if (hasIndirectFile || hasEnvPath) { + if ($checkbox.is(':checked')) { + $checkbox.prop('checked', false); + } + $checkbox.prop('disabled', true); + if ($notice.length) { + var reason = hasIndirectFile && hasEnvPath + ? 'Default discovery disabled because Indirect Compose File and Env File Path are set.' + : (hasIndirectFile + ? 'Default discovery disabled because Indirect Compose File is set.' + : 'Default discovery disabled because Env File Path is set.'); + $notice.text(reason).show(); + } + } else { + $checkbox.prop('disabled', false); + if ($notice.length) { + $notice.hide(); + } + } + } + + function updateSettingsDefaultComposeDiscoveryState(suppressChangeTracking) { + var $checkbox = $('#settings-use-default-compose-files'); + if (!$checkbox.length) return; + + var $notice = $('#settings-default-compose-files-disabled-note'); + if (!$notice.length) { + $notice = $(''); + var $anchor = $checkbox.closest('label'); + if ($anchor.length) { + $anchor.after($notice); + } else { + $checkbox.parent().append($notice); + } + } + + var hasEnvPath = ($('#settings-env-path').val() || '').trim() !== ''; + var hasExternalComposeFilePath = ($('#settings-external-compose-file').val() || '').trim() !== ''; + + if (hasEnvPath || hasExternalComposeFilePath) { + var reason = hasEnvPath && hasExternalComposeFilePath + ? 'Default discovery disabled because Env File Path and External Compose File are set.' + : (hasEnvPath + ? 'Default discovery disabled because Env File Path is set.' + : 'Default discovery disabled because External Compose File is set.'); + + if ($checkbox.is(':checked')) { + $checkbox.prop('checked', false); + if (!suppressChangeTracking) { + $checkbox.trigger('change'); + } + } + $checkbox.prop('disabled', true); + $notice.text(reason).show(); + } else { + $checkbox.prop('disabled', false); + $notice.hide(); + } + } + + +// Backward-compatible helper used by existing code paths. +function parseMemToBytes(memStr) { + return parseMemUsagePair(memStr).used; +} + +// Format bytes to human-readable string with fixed 2 decimals. +function formatBytes(bytes) { + var val = Number(bytes) || 0; + if (val < 0) val = 0; + if (val >= 1073741824) return (val / 1073741824).toFixed(2) + 'GiB'; + if (val >= 1048576) return (val / 1048576).toFixed(2) + 'MiB'; + if (val >= 1024) return (val / 1024).toFixed(2) + 'KiB'; + return val.toFixed(2) + 'B'; +} + +function formatCpuPercent(value) { + var num = Number(value) || 0; + return num.toFixed(2) + '%'; +} + +function formatMemUsageText(usedBytes, limitBytes) { + return formatBytes(usedBytes) + ' / ' + formatBytes(limitBytes); +} + +// ═══════════════════════════════════════════════════════════════════ +// Standard factory functions for container and stack identity objects +// ═══════════════════════════════════════════════════════════════════ + +/** + * Create a normalized container info object from any raw source. + * Handles PascalCase→camelCase, resolves name from multiple field aliases, + * and derives hasUpdate/isPinned when not explicitly set. + * + * @param {Object} raw - Raw container object (from server, cache, or update response) + * @returns {Object} Normalized container info + */ +function createContainerInfo(raw) { + if (!raw) return null; + var name = raw.name || raw.Name || raw.container || raw.Service || raw.service || ''; + var service = raw.service || raw.Service || name; + var updateStatus = raw.updateStatus || raw.UpdateStatus || ''; + var hasUpdate = (raw.hasUpdate !== undefined) ? !!raw.hasUpdate : (updateStatus === 'update-available'); + + return { + name: name, + service: service, + image: raw.image || raw.Image || '', + state: raw.state || raw.State || '', + isRunning: (raw.state || raw.State || '') === 'running', + hasUpdate: hasUpdate, + updateStatus: updateStatus, + localSha: raw.localSha || raw.LocalSha || '', + remoteSha: raw.remoteSha || raw.RemoteSha || '', + isPinned: (raw.isPinned !== undefined) ? !!raw.isPinned : false, + pinnedDigest: raw.pinnedDigest || raw.PinnedDigest || '', + icon: raw.icon || raw.Icon || '', + shell: raw.shell || raw.Shell || '/bin/bash', + webUI: raw.webUI || raw.WebUI || '', + ports: raw.ports || raw.Ports || [], + networks: raw.networks || raw.Networks || [], + volumes: raw.volumes || raw.Volumes || [], + id: raw.id || raw.Id || raw.ID || '', + created: raw.created || raw.Created || '', + startedAt: raw.startedAt || raw.StartedAt || '' + }; +} + +/** + * Create a normalized stack info object. + * + * @param {string} project - The project/stack folder name + * @param {Array} containers - Array of raw container objects (will be normalized) + * @param {Object} [opts] - Optional overrides (totalServices, lastChecked, etc.) + * @returns {Object} Normalized stack info + */ +function createStackInfo(project, containers, opts) { + opts = opts || {}; + var normalized = (containers || []).map(createContainerInfo).filter(Boolean); + var isRunning = normalized.some(function(c) { + return c.isRunning; + }); + var hasUpdate = normalized.some(function(c) { + return c.hasUpdate; + }); + + return { + projectName: opts.projectName || project, + containers: normalized, + isRunning: isRunning, + hasUpdate: (opts.hasUpdate !== undefined) ? opts.hasUpdate : hasUpdate, + totalServices: opts.totalServices || normalized.length, + lastChecked: opts.lastChecked || null + }; +} + +/** + * Merge update status info from a previous stackInfo into a new one. + * Matches containers by name and copies update fields. + * + * @param {Object} stackInfo - The target stack info (mutated in place) + * @param {Object} prevStatus - Previously saved stack update status + * @returns {Object} The mutated stackInfo + */ +function mergeStackUpdateStatus(stackInfo, prevStatus) { + if (!prevStatus) return stackInfo; + + // Copy stack-level fields + ['lastChecked', 'updateAvailable', 'checking', 'checked'].forEach(function(k) { + if (typeof prevStatus[k] !== 'undefined') stackInfo[k] = prevStatus[k]; + }); + + // Merge container-level update data + if (prevStatus.containers && stackInfo.containers) { + stackInfo.containers.forEach(function(c) { + var cName = c.name; + prevStatus.containers.forEach(function(pc) { + var prev = (typeof pc.name === 'string') ? pc : createContainerInfo(pc); + if (cName === prev.name) { + if (prev.hasUpdate && !c.hasUpdate) c.hasUpdate = prev.hasUpdate; + if (prev.updateStatus && !c.updateStatus) c.updateStatus = prev.updateStatus; + if (prev.localSha && !c.localSha) c.localSha = prev.localSha; + if (prev.remoteSha && !c.remoteSha) c.remoteSha = prev.remoteSha; + if (prev.isPinned !== undefined) c.isPinned = prev.isPinned; + } + }); + }); + // Recompute stack-level hasUpdate from merged containers + stackInfo.hasUpdate = stackInfo.containers.some(function(c) { + return c.hasUpdate; + }); + } + + return stackInfo; +} + +// ═══════════════════════════════════════════════════════════════════ + +// Timers for async operations (plugin-specific to avoid collision with Unraid's global timers) +var composeTimers = {}; + +function showComposeSpinner() { + var $spinner = $('div.spinner.fixed'); + if (!$spinner.length) { + return; + } + $spinner.css({ + 'z-index': 100000 + }); + $spinner.stop(true, true).show('slow'); +} + +function hideComposeSpinner() { + var $spinner = $('div.spinner.fixed'); + if (!$spinner.length) { + return; + } + $spinner.stop(true, true).hide('slow'); +} + +// Load stack list asynchronously (namespaced to avoid conflict with Docker tab's loadlist) +function composeLoadlist() { + // Return a Promise so callers can reliably .then() / .catch() on completion + return new Promise(function(resolve, reject) { + composeTimers.load = setTimeout(function() { + showComposeSpinner('Loading stack list...'); + }, 500); + + $.get('/plugins/compose.manager/include/ComposeList.php') + .done(function(data) { + clearTimeout(composeTimers.load); + + // Insert the loaded content + $('#compose_list').html(data); + + // Signal load subscribers (e.g. dockerload cache) that the list changed + $(document).trigger('composeListRefreshed'); + + // Initialize UI components for the newly loaded content + initStackListUI(); + + // Debug: log initial per-stack rendered status icons (data-status attribute) + try { + var initialStatuses = []; + $('#compose_stacks tr.compose-sortable').each(function() { + var project = $(this).data('project'); + var icon = $(this).find('.compose-status-icon').first(); + var status = icon.attr('data-status') || icon.attr('class') || ''; + initialStatuses.push({ + project: project, + status: status + }); + }); + composeLogger('initial-stack-statuses', { + stacks: initialStatuses + }, 'user', 'debug', 'composeLoadlist'); + } catch (e) {} + + // Normalize icons based on state text to ensure server-side render and + // client-side update logic agree (workaround for caching or older server HTML) + try { + $('#compose_stacks tr.compose-sortable').each(function() { + var $row = $(this); + var stateText = $row.find('.state').text().toLowerCase(); + var $icon = $row.find('.compose-status-icon').first(); + if (!$icon.length) return; + var desiredShape = null; + var desiredColor = 'grey-text'; + if (stateText.indexOf('partial') !== -1) { + desiredShape = 'exclamation-circle'; + desiredColor = 'orange-text'; + } else if (stateText.indexOf('started') !== -1) { + desiredShape = 'play'; + desiredColor = 'green-text'; + } else if (stateText.indexOf('paused') !== -1) { + desiredShape = 'pause'; + desiredColor = 'orange-text'; + } else { + desiredShape = 'square'; + desiredColor = 'grey-text'; + } + + // If icon already matches, skip + if (($icon.hasClass('fa-' + desiredShape) && $icon.hasClass(desiredColor))) return; + + composeLogger('normalize-icon', { + project: $row.data('project'), + stateText: stateText, + desiredShape: desiredShape, + desiredColor: desiredColor, + before: $icon.attr('class') + }, 'user', 'debug', 'composeLoadlist'); + // Remove old fa-* classes, color classes and apply desired ones + $icon.removeClass(function(i, cls) { + return (cls.match(/fa-[^\s]+/g) || []).join(' '); + }); + $icon.removeClass('green-text orange-text grey-text cyan-text'); + $icon.addClass('fa fa-' + desiredShape + ' ' + desiredColor + ' compose-status-icon'); + composeLogger('normalize-icon-done', { + project: $row.data('project'), + after: $icon.attr('class') + }, 'user', 'debug', 'composeLoadlist'); + }); + } catch (e) {} + + // Cleanup any temporary per-container spinners or leftover in-progress state + try { + $('#compose_list').find('.compose-container-spinner').each(function() { + var $sp = $(this); + var $wrap = $sp.closest('.hand'); + $sp.remove(); + $wrap.find('img').css('opacity', 1); + }); + // Restore any state text preserved by setStackActionInProgress + $('#compose_stacks .state').each(function() { + var $s = $(this); + if ($s.data('orig-text')) { + $s.text($s.data('orig-text')); + $s.removeData('orig-text'); + } + }); + } catch (e) {} + + // Hide compose spinner overlay + hideComposeSpinner(); + + // Show buttons now that content is loaded + $('input[type=button]').show(); + + // Notify other features (e.g. hide-from-docker) that compose list is ready + $(document).trigger('compose-list-loaded'); + + // Resolve the promise so callers know the list has been loaded + try { + resolve(data); + } catch (e) { + resolve(); + } + }) + .fail(function(xhr, status, error) { + composeLogger('failed', { + status: status, + error: error + }, 'user', 'error', 'composeLoadlist'); + clearTimeout(composeTimers.load); + hideComposeSpinner(); + $('#compose_list').html('Failed to load stack list. Please refresh the page.'); + + // Reject the promise so callers can handle the error + try { + reject({ + xhr: xhr, + status: status, + error: error + }); + } catch (e) { + reject(error); + } + }); + }); +} + +// Sortable functions loaded from composeSortable.js + +// Initialize UI components after stack list is loaded +function initStackListUI() { + // Initialize autostart switches - scope to compose_list to avoid conflict with Docker tab + // Avoid re-initializing switchButton on elements that may be re-rendered. + $('#compose_list .auto_start').each(function() { + var $el = $(this); + if ($el.data('switchbutton-initialized')) return; + $el.switchButton({ + labels_placement: 'right', + on_label: "On", + off_label: "Off", + clear: false + }); + $el.data('switchbutton-initialized', true); + }); + // Ensure change handler is bound only once + $('#compose_list').off('change', '.auto_start').on('change', '.auto_start', function() { + var script = $(this).attr("data-scriptname"); + var auto = $(this).prop('checked'); + $.post(caURL, { + action: 'updateAutostart', + script: script, + autostart: auto + }); + }); + + // Initialize context menus for stack icons + $('[id^="stack-"][data-stackid]').each(function() { + addComposeStackContext(this.id); + }); + + // Apply readmore to descriptions - scope to compose_stacks, exclude container detail rows + var $readmoreEls = $('#compose_stacks .docker_readmore').not('.stack-details-container .docker_readmore'); + $readmoreEls.readmore('destroy'); + $readmoreEls.readmore({ + maxHeight: 32, + moreLink: "", + lessLink: "" + }); + + // Apply current view mode (advanced/basic) with centralized logic + applyListView(false); + + // Seed expandedStacks from any rows rendered expanded server-side + $('.stack-details-row:visible').each(function() { + var stackId = this.id.replace('details-row-', ''); + expandedStacks[stackId] = true; + }); + + // Load saved update status after list is loaded + loadSavedUpdateStatus(); + + syncComposeSortModeUI(); +} + +// Load external stylesheets (non-critical styles — critical ones are inline above) +(function() { + var base = composeBootstrap.comboButtonCss || ''; + var editor = composeBootstrap.editorModalCss || ''; + if (base && !$('link[href="' + base + '"]').length) + $('head').append($('').attr('href', base)); + if (editor && !$('link[href="' + editor + '"]').length) + $('head').append($('').attr('href', editor)); +})(); + +function basename(path) { + return path.replace(/\\/g, '/').replace(/.*\//, ''); +} + +function dirname(path) { + return path.replace(/\\/g, '/').replace(/\/[^\/]*$/, ''); +} + +// Safely attempt to parse a JSON string; returns null on failure +function tryParseJson(str) { + if (!str || typeof str !== 'string') return null; + try { + return JSON.parse(str); + } catch (e) { + return null; + } +} + +// Editor modal state +var editorModal = { + editors: {}, + currentTab: 'compose', + originalContent: {}, + modifiedTabs: new Set(), + currentProject: null, + currentProjectName: null, + validationTimeout: null, + // Settings state + originalSettings: {}, + modifiedSettings: new Set(), + // Labels state + originalLabels: {}, + modifiedLabels: new Set(), + labelsData: null, // Stores the parsed compose and override data + labelsViewMode: 'basic', // Applied mode: 'basic' (form UI) or 'advanced' (override editor) + pendingLabelsViewMode: 'basic', // Pending settings value; applied only after save + filePaths: { + stackMeta: '', + compose: '', + env: '', + projectOverride: '', + effectiveOverride: '' + } +}; + +// Debounce helper for validation +function debounceValidation(type, content) { + if (editorModal.validationTimeout) { + clearTimeout(editorModal.validationTimeout); + } + editorModal.validationTimeout = setTimeout(function() { + validateYaml(type, content); + }, 300); +} + +// Calculate unRAID header offset dynamically +function updateModalOffset() { + var headerOffset = 0; + var header = document.getElementById('header'); + var menu = document.getElementById('menu'); + var tabs = document.querySelector('div.tabs'); + + if (header) { + headerOffset += header.offsetHeight; + } + if (menu) { + headerOffset += menu.offsetHeight; + } + if (tabs) { + headerOffset += tabs.offsetHeight; + } + + // Add a small buffer + headerOffset += 10; + + // Set CSS custom property + document.documentElement.style.setProperty('--unraid-header-offset', headerOffset + 'px'); + var overlay = document.getElementById('editor-modal-overlay'); + if (overlay) { + overlay.style.setProperty('--unraid-header-offset', headerOffset + 'px'); + } +} + +// Shared helpers for file-tree picker positioning and scroll tracking are now in common.js +// Please reference composePositionFileTreeForInput, composeTrackFileTreeForInput, composeBindFileTreeInputs from common.js + +// Initialize editor modal +function initEditorModal() { + if (typeof ace === 'undefined') { + composeLogger('Ace editor not available. Stack editor is unavailable.', null, 'user', 'warn', 'initEditorModal'); + return; + } + // Initialize Ace editors for compose and env tabs only + ['compose', 'env'].forEach(function(type) { + var editor = ace.edit('editor-' + type); + editor.setTheme(aceTheme); + editor.setShowPrintMargin(false); + editor.setOptions({ + fontSize: '1.1rem', + tabSize: 2, + useSoftTabs: true, + wrap: true + }); + + // Disable workers to avoid loading worker-yaml.js / worker-sh.js — + // we already validate YAML client-side via js-yaml + editor.getSession().setUseWorker(false); + + // Set mode based on type + if (type === 'env') { + editor.getSession().setMode('ace/mode/sh'); + } else { + editor.getSession().setMode('ace/mode/yaml'); + } + + // Track modifications + editor.on('change', function() { + var currentContent = editor.getValue(); + var originalContent = editorModal.originalContent[type] || ''; + var tabEl = $('#editor-tab-' + type); + + if (currentContent !== originalContent) { + editorModal.modifiedTabs.add(type); + tabEl.addClass('modified'); + } else { + editorModal.modifiedTabs.delete(type); + tabEl.removeClass('modified'); + } + + updateSaveButtonState(); + updateTabModifiedState(); + debounceValidation(type, currentContent); + }); + + editorModal.editors[type] = editor; + }); + + // Initialize override editor (for labels tab advanced mode) + var overrideEditor = ace.edit('editor-override'); + overrideEditor.setTheme(aceTheme); + overrideEditor.setShowPrintMargin(false); + overrideEditor.setOptions({ + fontSize: '1.1rem', + tabSize: 2, + useSoftTabs: true, + wrap: true + }); + overrideEditor.getSession().setUseWorker(false); + overrideEditor.getSession().setMode('ace/mode/yaml'); + overrideEditor.on('change', function() { + var currentContent = overrideEditor.getValue(); + var originalContent = editorModal.originalContent['override'] || ''; + if (currentContent !== originalContent) { + editorModal.modifiedTabs.add('override'); + } else { + editorModal.modifiedTabs.delete('override'); + } + updateSaveButtonState(); + updateTabModifiedState(); + debounceValidation('override', currentContent); + }); + editorModal.editors['override'] = overrideEditor; + + // Initialize settings field change tracking + $('#settings-name, #settings-description, #settings-icon-url, #settings-webui-url, #settings-env-path, #settings-default-profile, #settings-external-compose-path, #settings-external-compose-file, #settings-use-default-compose-files').on('input change', function() { + var fieldId = this.id.replace('settings-', ''); + var isCheckbox = this.type === 'checkbox'; + var currentValue = isCheckbox ? ($(this).is(':checked') ? 'true' : 'false') : $(this).val(); + var originalValue = editorModal.originalSettings[fieldId] || ''; + + if (currentValue !== originalValue) { + editorModal.modifiedSettings.add(fieldId); + } else { + editorModal.modifiedSettings.delete(fieldId); + } + + updateSaveButtonState(); + updateTabModifiedState(); + + if (fieldId === 'env-path' || fieldId === 'external-compose-file') { + updateSettingsDefaultComposeDiscoveryState(); + } + }); + + // Override management mode is a saveable setting (deferred persist). + $('#settings-override-management').on('change', function() { + var mode = $(this).is(':checked') ? 'basic' : 'advanced'; + var originalMode = editorModal.originalSettings['labels-view-mode'] || 'basic'; + + // Defer labels tab mode switch until save; only stage pending setting locally. + editorModal.pendingLabelsViewMode = mode; + $('#settings-override-management-label').text(mode === 'advanced' ? 'Manual' : 'Automatic'); + + if (mode !== originalMode) { + editorModal.modifiedSettings.add('labels-view-mode'); + } else { + editorModal.modifiedSettings.delete('labels-view-mode'); + } + + updateSaveButtonState(); + updateTabModifiedState(); + }); + + // Icon preview update with debounce + var settingsIconDebounce = null; + $('#settings-icon-url').on('input', function() { + var $input = $(this); + clearTimeout(settingsIconDebounce); + settingsIconDebounce = setTimeout(function() { + var url = $input.val().trim(); + if (url && isValidIconSrc(url)) { + $('#settings-icon-preview-img').attr('src', url); + $('#settings-icon-preview').show(); + } else { + $('#settings-icon-preview').hide(); + } + }, 300); + }); + + // External compose path info toggle + $('#settings-external-compose-path').on('input', function() { + var path = $(this).val().trim(); + var filePath = $('#settings-external-compose-file').val().trim(); + if (path || filePath) { + $('#settings-external-compose-info').show(); + } else { + $('#settings-external-compose-info').hide(); + } + }); + $('#settings-external-compose-file').on('input', function() { + var path = $('#settings-external-compose-path').val().trim(); + var filePath = $(this).val().trim(); + if (path || filePath) { + $('#settings-external-compose-info').show(); + } else { + $('#settings-external-compose-info').hide(); + } + + // Warn if the selected file lives inside the stack project folder + var stackPath = (editorModal.filePaths.stackMeta || '').replace(/\/$/, ''); + var $warning = $('#settings-external-compose-file-warning'); + if (filePath && stackPath && filePath.startsWith(stackPath + '/')) { + if (!$warning.length) { + $warning = $('
'); + $(this).after($warning); + } + $warning.text('This file is inside the stack project folder. The path must be external to this stack.').show(); + } else if ($warning.length) { + $warning.hide(); + } + }); + + // Keyboard shortcuts - use namespaced event to avoid duplicates + $(document).off('keydown.editorModal').on('keydown.editorModal', function(e) { + if ($('#editor-modal-overlay').hasClass('active')) { + // Ctrl+S or Cmd+S to save current + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + saveCurrentTab(); + } + // Escape to close + if (e.key === 'Escape') { + e.preventDefault(); + closeEditorModal(); + } + // Arrow key navigation for tabs + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + var $activeTab = $('.editor-tab.active'); + if ($activeTab.is(':focus') || $activeTab.parent().find(':focus').length) { + e.preventDefault(); + var tabs = ['compose', 'env', 'labels', 'settings']; + var currentIdx = tabs.indexOf(editorModal.currentTab); + var newIdx; + if (e.key === 'ArrowLeft') { + newIdx = currentIdx > 0 ? currentIdx - 1 : tabs.length - 1; + } else { + newIdx = currentIdx < tabs.length - 1 ? currentIdx + 1 : 0; + } + switchTab(tabs[newIdx]); + $('#editor-tab-' + tabs[newIdx]).focus(); + } + } + // Focus trapping + if (e.key === 'Tab') { + var $modal = $('#editor-modal-overlay'); + var $focusable = $modal.find('a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])').filter(':visible:not(:disabled)'); + if ($focusable.length === 0) return; + var first = $focusable[0]; + var last = $focusable[$focusable.length - 1]; + var activeElement = document.activeElement; + + if (!$.contains($modal[0], activeElement)) { + e.preventDefault(); + first.focus(); + return; + } + + if (!e.shiftKey && activeElement === last) { + e.preventDefault(); + first.focus(); + } else if (e.shiftKey && activeElement === first) { + e.preventDefault(); + last.focus(); + } + } + } + }); + + // Close modal when clicking on the overlay background (not the inner modal content) + $('#editor-modal-overlay').off('click.editorModal').on('click.editorModal', function(e) { + if (e.target === this) { + closeEditorModal(); + } + }); +} + +// Switch between tabs (compose / env / labels / settings) +function switchTab(tabName) { + var validTabs = ['compose', 'env', 'labels', 'settings']; + if (validTabs.indexOf(tabName) === -1) { + composeLogger('Invalid tab name: ' + tabName, null, 'user', 'error', 'switchTab'); + return; + } + + if (tabName !== 'settings' && hasPathSensitiveSettingsChanges()) { + enforcePathSettingsExclusivity('opening other tabs'); + tabName = 'settings'; + } + + // Update tab buttons + $('.editor-tab').removeClass('active').attr('aria-selected', 'false'); + $('#editor-tab-' + tabName).addClass('active').attr('aria-selected', 'true'); + + // Update panels and ensure inline display is correct (inline style may be persisted from tab host) + $('.editor-panel').each(function() { + var $panel = $(this); + if ($panel.attr('id') === 'editor-panel-' + tabName) { + $panel.addClass('active').css('display', 'flex'); + } else { + $panel.removeClass('active').css('display', 'none'); + } + }); + + editorModal.currentTab = tabName; + updateEditorFileInfo(); + + // Resize and focus editor if switching to compose or env tab + if ((tabName === 'compose' || tabName === 'env') && editorModal.editors[tabName]) { + try { + editorModal.editors[tabName].resize(); + editorModal.editors[tabName].renderer.updateFull(); + editorModal.editors[tabName].focus(); + } catch (e) { + composeLogger('Editor resize failed', { + error: e && e.toString(), + tab: type + }, 'user', 'warn', 'switchTab'); + } + } + + if (tabName === 'labels') { + // Apply stack-specific mode every time labels tab opens. + var labelsAdvanced = editorModal.labelsViewMode === 'advanced'; + toggleLabelsViewMode(labelsAdvanced, true); + + // If in advanced mode, ensure override editor is populated + resized. + if (labelsAdvanced) { + if (!editorModal.labelsData) { + loadLabelsData(function() { _populateOverrideEditor(); }); + } else { + _populateOverrideEditor(); + } + } else if (!editorModal.labelsData) { + // Basic mode still needs labels service/form data. + loadLabelsData(); + } + } +} + +function refreshEditorContents(type) { + if (!editorModal.editors[type]) return; + try { + editorModal.editors[type].resize(); + editorModal.editors[type].renderer.updateFull(); + } catch (e) { + composeLogger('refreshEditorContents failed for ' + type, { + error: e && e.toString() + }, 'user', 'warn', 'refreshEditorContents'); + } +} + +function getEditorSaveTargetPath() { + switch (editorModal.currentTab) { + case 'compose': + return editorModal.filePaths.compose || editorModal.filePaths.stackMeta; + case 'env': + return editorModal.filePaths.env || editorModal.filePaths.stackMeta; + case 'labels': + if (editorModal.labelsViewMode === 'advanced') { + return editorModal.filePaths.effectiveOverride || editorModal.filePaths.projectOverride || editorModal.filePaths.stackMeta; + } + return editorModal.filePaths.projectOverride || editorModal.filePaths.stackMeta; + case 'settings': + default: + return editorModal.filePaths.stackMeta; + } +} + +function getEditorActiveFilePath() { + switch (editorModal.currentTab) { + case 'compose': + return editorModal.filePaths.compose || ''; + case 'env': + return editorModal.filePaths.env || ''; + case 'labels': + if (editorModal.labelsViewMode === 'advanced') { + return editorModal.filePaths.effectiveOverride || editorModal.filePaths.projectOverride || ''; + } + return ''; + case 'settings': + default: + return ''; + } +} + +function setEditorPathText(selector, value, emptyText) { + var text = value || emptyText; + var isEmpty = !value; + $(selector) + .text(text) + .attr('title', text) + .attr('data-empty', isEmpty ? 'true' : 'false'); +} + +function updateEditorFileInfo() { + var stackPath = editorModal.filePaths.stackMeta || ''; + var activeFilePath = getEditorActiveFilePath(); + var savePath = getEditorSaveTargetPath(); + var fallbackText = savePath && savePath !== stackPath + ? 'No raw file in current tab; changes save via ' + savePath + : 'No raw file in current tab'; + + setEditorPathText('#editor-project-dir', stackPath, 'Project directory unavailable'); + setEditorPathText('#editor-edit-file', activeFilePath, fallbackText); +} + +// Update the modified indicator on tabs +function updateTabModifiedState() { + // Compose tab + if (editorModal.modifiedTabs.has('compose')) { + $('#editor-tab-compose').addClass('modified'); + } else { + $('#editor-tab-compose').removeClass('modified'); + } + + // Env tab + if (editorModal.modifiedTabs.has('env')) { + $('#editor-tab-env').addClass('modified'); + } else { + $('#editor-tab-env').removeClass('modified'); + } + + // Labels tab — modified if labels form OR override editor has changes + if (editorModal.modifiedLabels.size > 0 || editorModal.modifiedTabs.has('override')) { + $('#editor-tab-labels').addClass('modified'); + } else { + $('#editor-tab-labels').removeClass('modified'); + } + + // Settings tab + if (editorModal.modifiedSettings.size > 0) { + $('#editor-tab-settings').addClass('modified'); + } else { + $('#editor-tab-settings').removeClass('modified'); + } +} + +// composeEscapeHtml / composeEscapeAttr are provided by common.js + +// Update status cache per stack +var stackUpdateStatus = {}; + +// Load saved update status from server (called on page load) +// If auto-check is enabled and interval has elapsed, trigger a fresh check +// Also checks for pending rechecks from recent update operations +function loadSavedUpdateStatus() { + $.post(caURL, { + action: 'getSavedUpdateStatus' + }, function(data) { + if (data) { + try { + var response = JSON.parse(data); + if (response.result === 'success' && response.stacks) { + stackUpdateStatus = response.stacks; + + // Update the UI for each stack with saved status + for (var stackName in response.stacks) { + var stackInfo = response.stacks[stackName]; + updateStackUpdateUI(stackName, stackInfo); + } + + // Enable/disable Update All button based on saved status + updateUpdateAllButton(); + + // Check for pending rechecks first (from recent updates) + // This takes priority over auto-check interval + checkPendingRechecks(function(hadPendingRechecks) { + // Only run auto-check if there were no pending rechecks + if (!hadPendingRechecks && autoCheckUpdates) { + checkAutoUpdateIfNeeded(response.stacks); + } + }); + } else { + // No saved status, check for pending rechecks or run auto-check + checkPendingRechecks(function(hadPendingRechecks) { + if (!hadPendingRechecks && autoCheckUpdates) { + checkAllUpdates(); + } + }); + } + } catch (e) { + composeLogger('Failed to load saved update status', { + error: e && e.toString() + }, 'user', 'error', 'loadSavedUpdateStatus'); + checkPendingRechecks(function(hadPendingRechecks) { + if (!hadPendingRechecks && autoCheckUpdates) { + checkAllUpdates(); + } + }); + } + } else { + // No data, check for pending rechecks or run auto-check + checkPendingRechecks(function(hadPendingRechecks) { + if (!hadPendingRechecks && autoCheckUpdates) { + checkAllUpdates(); + } + }); + } + }); +} + +// Check for pending rechecks from recent update operations +// These stacks need to be rechecked regardless of auto-check interval +function checkPendingRechecks(callback) { + $.post(caURL, { + action: 'getPendingRecheckStacks' + }, function(data) { + var hadPendingRechecks = false; + if (data) { + try { + var response = JSON.parse(data); + if (response.result === 'success' && response.pending) { + var pendingStacks = Object.keys(response.pending); + if (pendingStacks.length > 0) { + hadPendingRechecks = true; + composeLogger('checkPendingRechecks:found', { + pendingStacks: pendingStacks + }, 'user', 'info', 'update-check'); + + // Check each pending stack + pendingStacks.forEach(function(stackName) { + composeLogger('Running recheck for recently updated stack', { + stackName: stackName + }, 'user', 'info', 'update-check'); + checkStackUpdates(stackName); + }); + } + } + } catch (e) { + composeLogger('checkPendingRechecks:failed', { + error: e + }, 'user', 'error', 'update-check'); + } + } + if (callback) callback(hadPendingRechecks); + }); +} + +// Check if auto-update check is needed based on lastChecked timestamp +function checkAutoUpdateIfNeeded(stacks) { + if (!autoCheckUpdates) return; + + var now = Math.floor(Date.now() / 1000); // Current time in seconds + var intervalSeconds = autoCheckDays * 24 * 60 * 60; // Convert days to seconds + var needsCheck = true; + + // Find the most recent lastChecked timestamp across all stacks + var latestCheck = 0; + for (var stackName in stacks) { + if (stacks[stackName].lastChecked && stacks[stackName].lastChecked > latestCheck) { + latestCheck = stacks[stackName].lastChecked; + } + } + + // If we have a lastChecked time and it's within the interval, don't check + if (latestCheck > 0 && (now - latestCheck) < intervalSeconds) { + needsCheck = false; + composeLogger('Last check was ' + Math.round((now - latestCheck) / 60) + ' minutes ago, interval is ' + Math.round(intervalSeconds / 60) + ' minutes. Skipping.', null, 'user', 'info', 'update-check'); + } + + if (needsCheck) { + composeLogger('Running automatic update check...', null, 'user', 'info', 'update-check'); + checkAllUpdates(); + } +} + +// Check for updates for all stacks +function checkAllUpdates() { + $('#checkUpdatesBtn').prop('disabled', true).val('Checking...'); + $('#updateAllBtn').prop('disabled', true); + + // Show checking indicator only on running stack update columns (not stopped ones) + $('#compose_stacks tr.compose-sortable').each(function() { + var $row = $(this); + var isRunning = $row.find('.state').text().indexOf('started') !== -1 || + $row.find('.state').text().indexOf('partial') !== -1; + if (isRunning) { + var $updateCell = $row.find('.compose-updatecolumn'); + $updateCell.html(' checking...'); + } + }); + + $.post(caURL, { + action: 'checkAllStacksUpdates' + }, function(data) { + if (data) { + try { + var response = JSON.parse(data); + if (response.result === 'success' && response.stacks) { + stackUpdateStatus = response.stacks; + + // Update the UI for each stack + for (var stackName in response.stacks) { + var stackInfo = response.stacks[stackName]; + updateStackUpdateUI(stackName, stackInfo); + } + + // Enable/disable Update All button based on available updates + updateUpdateAllButton(); + } + } catch (e) { + composeLogger('Failed to parse update check response', { + error: e && e.toString() + }, 'user', 'error', 'checkAllUpdates'); + } + } + $('#checkUpdatesBtn').prop('disabled', false).val('Check for Updates'); + }).fail(function() { + $('#checkUpdatesBtn').prop('disabled', false).val('Check for Updates'); + $('#updateAllBtn').prop('disabled', true); + // Reset update columns to not checked state - scope to compose_stacks + $('#compose_stacks .compose-updatecolumn').each(function() { + var $cell = $(this); + if (!$cell.find('.grey-text').length || $cell.find('.fa-docker').length === 0) { + // Only reset running stacks (not the "stopped" ones) + $cell.html(' check failed'); + } + }); + }); +} + +// Check how many stacks have updates and enable/disable the Update All button +function updateUpdateAllButton() { + var stacksWithUpdates = 0; + for (var stackName in stackUpdateStatus) { + var stackInfo = stackUpdateStatus[stackName]; + if (!stackInfo.hasUpdate) continue; + // Derive running state from DOM — saved status may be stale + var $row = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]'); + if ($row.length === 0) continue; + var stateText = $row.find('.state').text(); + var isRunning = stateText.indexOf('started') !== -1 || stateText.indexOf('partial') !== -1; + if (isRunning) stacksWithUpdates++; + } + $('#updateAllBtn').prop('disabled', stacksWithUpdates === 0); +} + +// Update All Stacks - updates all stacks that have pending updates +function updateAllStacks() { + var autostartOnly = $('#autostartOnlyToggle').is(':checked'); + var stacks = []; + + // Collect all stacks with updates + for (var stackName in stackUpdateStatus) { + var stackInfo = stackUpdateStatus[stackName]; + if (!stackInfo.hasUpdate) continue; + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]'); + if ($stackRow.length === 0) continue; + // Derive running state from DOM — saved status may be stale + var rowStateText = $stackRow.find('.state').text(); + if (rowStateText.indexOf('started') === -1 && rowStateText.indexOf('partial') === -1) continue; + + var autostart = $stackRow.find('.auto_start').is(':checked'); + + // Skip if autostart only mode and autostart is not enabled + if (autostartOnly && !autostart) continue; + + var path = $stackRow.data('path'); + var projectName = $stackRow.data('projectname'); + + stacks.push({ + project: stackName, + projectName: projectName, + path: path + }); + } + + if (stacks.length === 0) { + swal({ + title: 'No Updates Available', + text: autostartOnly ? 'No stacks with Autostart enabled have updates available.' : 'No stacks have updates available.', + type: 'info' + }); + return; + } + + var stackNames = stacks.map(function(s) { + return composeEscapeHtml(s.projectName); + }).join('
'); + var title = autostartOnly ? 'Update Autostart Stacks?' : 'Update All Stacks?'; + var confirmText = 'Yes, update ' + stacks.length + ' stack' + (stacks.length > 1 ? 's' : ''); + + var bgCheckboxHtml = '
' + + '' + + '' + + '
'; + + getConfig().then(function(pluginCfg) { + var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; + + if (disableWarnings) { + executeUpdateAllStacks(stacks, bgDefault, bgDefault); + return; + } + + swal({ + title: title, + html: true, + text: '

The following stacks will be updated:

' + stackNames + '

This will pull new images and recreate containers.

' + bgCheckboxHtml, + type: 'warning', + showCancelButton: true, + confirmButtonText: confirmText, + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) { + var runInBackground = $('#swal-run-bg-updateall').is(':checked'); + executeUpdateAllStacks(stacks, runInBackground); + } + }); + + setTimeout(function() { + var $cb = $('#swal-run-bg-updateall'); + if ($cb.length) { + $cb.prop('checked', bgDefault); + } + }, 50); + }); +} + +function executeUpdateAllStacks(stacks, background, suppressBackgroundNotification = false) { + var height = 800; + var width = 1200; + + // Create a list of paths to update + var paths = stacks.map(function(s) { + return s.path; + }); + + // Track all stacks for update check when dialog closes + var stackNames = []; + stacks.forEach(function(s) { + var stackName = s.project; + if (pendingUpdateCheckStacks.indexOf(stackName) === -1) { + pendingUpdateCheckStacks.push(stackName); + } + stackNames.push(stackName); + }); + + // Mark stacks for recheck server-side (persists across page reload) + $.post(caURL, { + action: 'markStackForRecheck', + stacks: JSON.stringify(stackNames) + }, function() { + performComposeAction({ + actionName: 'update', + title: 'Update All Stacks', + requestUrl: compURL, + payload: { + action: 'composeUpdateMultiple', + paths: JSON.stringify(paths) + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: false, + onComplete: function(parsed, data) { + if (parsed && parsed.background) { + stacks.forEach(function(s) { + pollBackgroundCompletion(s.project); + }); + } + } + }); + }); +} + +// Update UI for a single stack's update status +function updateStackUpdateUI(stackName, stackInfo) { + // Find the stack row by project name (scoped to compose_stacks to avoid Docker tab conflicts) + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]'); + if ($stackRow.length === 0) return; + + var stackId = $stackRow.attr('id').replace('stack-row-', ''); + var $updateCell = $stackRow.find('.compose-updatecolumn'); + + // Always derive running state from the current DOM rather than the + // stackInfo payload. The saved update-status file may contain a stale + // isRunning value from when the check originally ran (e.g. the stack + // was stopped then but has since been started). + var stateText = $stackRow.find('.state').text(); + var isRunning = stateText.indexOf('started') !== -1 || stateText.indexOf('partial') !== -1; + + if (!isRunning) { + // Stack is not running - show stopped + $updateCell.html(' stopped'); + return; + } + + // Count updates and pinned containers + var updateCount = 0; + var pinnedCount = 0; + var totalContainers = stackInfo.containers ? stackInfo.containers.length : 0; + + if (stackInfo.containers) { + stackInfo.containers.forEach(function(ct) { + if (ct.hasUpdate) updateCount++; + if (ct.isPinned) pinnedCount++; + }); + } + + // Update the stack row's update column (match Docker tab style) + if (updateCount > 0) { + // Updates available - orange "update ready" style with clickable link and SHA info + var updateHtml = ''; + updateHtml += ' ' + updateCount + ' update' + (updateCount > 1 ? 's' : '') + ''; + updateHtml += ''; + + // Show first container's SHA diff if only one update, or indicate multiple + if (stackInfo.containers) { + var updatesWithSha = stackInfo.containers.filter(function(ct) { + return ct.hasUpdate && ct.localSha && ct.remoteSha; + }); + if (updatesWithSha.length === 1) { + // Single update - show the SHA diff inline + var ct = updatesWithSha[0]; + updateHtml += '
'; + updateHtml += '' + composeEscapeHtml(ct.localSha.substring(0, 8)) + ''; + updateHtml += ' '; + updateHtml += '' + composeEscapeHtml(ct.remoteSha.substring(0, 8)) + ''; + updateHtml += '
'; + } else if (updatesWithSha.length > 1) { + // Multiple updates - show expand hint + updateHtml += '
Expand for details
'; + } + } + + // Also show pinned count if any containers are pinned + if (pinnedCount > 0) { + updateHtml += '
' + pinnedCount + ' pinned
'; + } + $updateCell.html(updateHtml); + } else if (totalContainers > 0) { + // No updates - check if all are pinned or up-to-date + if (pinnedCount > 0 && pinnedCount === totalContainers) { + // All containers are pinned + var html = ' all pinned'; + $updateCell.html(html); + } else if (pinnedCount > 0) { + // Some containers pinned, rest up-to-date + var html = ' up-to-date'; + html += '
' + pinnedCount + ' pinned
'; + html += ''; + $updateCell.html(html); + } else { + // No updates, no pinned - green "up-to-date" style (like Docker tab) + // Basic view: just shows up-to-date + // Advanced view: shows force update link + var html = ' up-to-date'; + html += ''; + $updateCell.html(html); + } + } else { + // No containers found in update data — stack is running but + // hasn't been checked yet. Prompt a check rather than an + // update so the SHA metadata gets populated first. + $updateCell.html(' check for updates'); + } + + // Apply current view mode — cm-advanced elements are controlled by + // the .cm-advanced-view class on #compose_stacks (CSS-only, no need to + // show/hide individual elements here since CSS handles visibility). + + // Rebuild context menus to reflect update status (only target icon spans with data-stackid, not the row) + $('[id^="stack-"][data-stackid][data-project="' + stackName + '"]').each(function() { + addComposeStackContext(this.id); + }); + + // Also update the cached container data with update status and SHA + if (stackContainersCache[stackId] && stackInfo.containers) { + stackContainersCache[stackId].forEach(function(cached) { + stackInfo.containers.forEach(function(updated) { + if (cached.name === updated.name) { + cached.hasUpdate = updated.hasUpdate; + cached.updateStatus = updated.updateStatus; + cached.localSha = updated.localSha || ''; + cached.remoteSha = updated.remoteSha || ''; + cached.isPinned = updated.isPinned || false; + cached.pinnedDigest = updated.pinnedDigest || ''; + } + }); + }); + } + + // If details are expanded, refresh them. However, avoid immediate + // refresh if a load is already in progress or we've just rendered to + // prevent a render->update->render loop. + if (expandedStacks[stackId]) { + if (stackDetailsLoading[stackId] || stackDetailsJustRendered[stackId]) { + composeLogger('skip-refresh', { + stackId: stackId, + stackName: stackName, + loading: !!stackDetailsLoading[stackId], + justRendered: !!stackDetailsJustRendered[stackId] + }, 'user', 'info', 'update-check'); + } else { + loadStackContainerDetails(stackId, stackName); + } + } +} + +// Check updates for a single stack +function checkStackUpdates(stackName) { + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]'); + if ($stackRow.length === 0) return; + + var $updateCell = $stackRow.find('.compose-updatecolumn'); + $updateCell.html(' checking...'); + + $.post(caURL, { + action: 'checkStackUpdates', + script: stackName + }, function(data) { + if (data) { + try { + var response = JSON.parse(data); + if (response.result === 'success') { + var stackInfo = createStackInfo(stackName, response.updates, { + projectName: response.projectName + }); + stackUpdateStatus[stackName] = stackInfo; + updateStackUpdateUI(stackName, stackInfo); + // Update the Update All button state + updateUpdateAllButton(); + + // Clear the pending recheck flag for this stack (if any) + $.post(caURL, { + action: 'clearStackRecheck', + stacks: JSON.stringify([stackName]) + }); + } + } catch (e) { + composeLogger('Failed to parse update check response', { + error: e && e.toString(), + stackName: stackName + }, 'user', 'error', 'checkStackUpdates'); + } + } + }); +} + +// Validate URL scheme for WebUI links (allows [IP] and [PORT]/[PORT:xxxx] placeholders) +function isValidWebUIUrl(url) { + if (!url) return false; + // Replace placeholders with dummy values for structural validation + var normalized = url.replace(/\[IP\]/gi, 'localhost') + .replace(/\[PORT:\d+\]/gi, '8080') + .replace(/\[PORT\]/gi, '8080'); + try { + return Boolean(new URL(normalized)); + } catch (e) { + return false; + } +} + +// Validate an icon source: http(s) URL, data URI, or local server path +function isValidIconSrc(src) { + if (!src) return false; + var s = src.trim(); + return s.indexOf('http://') === 0 || s.indexOf('https://') === 0 || + s.indexOf('data:image/') === 0 || s.indexOf('/') === 0; +} + +function loadPersistentContainerCache() { + return new Promise(function(resolve) { + $.get('/plugins/compose.manager/containers.cache.json') + .done(function(data) { + try { + persistentContainerCache = JSON.parse(data) || {}; + } catch (e) { + persistentContainerCache = {}; + composeLogger('Failed to parse persistent container cache', { + error: e && e.toString() + }, 'user', 'warn', 'loadPersistentContainerCache'); + } + resolve(persistentContainerCache); + }) + .fail(function() { + persistentContainerCache = {}; + resolve(persistentContainerCache); + }); + }); +} + +function getPersistentContainerInfo(project, service) { + if (!project || !service || !persistentContainerCache[project]) return null; + return persistentContainerCache[project][service] || null; +} + +// Process WebUI URL placeholders for stack-level WebUI (where no container context exists) +// For container-level WebUI, resolution is done server-side in exec.php +function processWebUIUrl(url) { + if (!url) return url; + // Replace [IP] with the server hostname/IP (stack-level only) + url = url.replace(/\[IP\]/gi, window.location.hostname); + // Replace [PORT:xxxx] with the specified port (no container port mapping at stack level) + url = url.replace(/\[PORT:(\d+)\]/gi, '$1'); + // Replace bare [PORT] — no default port available at stack level, clean up + // This shouldn't normally be reached (save validation rejects bare [PORT]), + // but handle gracefully by removing the placeholder and any preceding colon + url = url.replace(/:?\[PORT\]/gi, ''); + return url; +} + +function isComposeAdvancedMode() { + return $.cookie('compose_listview_mode') === 'advanced'; +} + +// Apply advanced/basic view based on cookie (used after async load) +// Scoped to compose_stacks to avoid affecting Docker tab when tabs are joined. +// When animate=true (user clicked toggle), run a simple symmetric transition. +// When false (page load), instant class toggle. +function applyListView(animate) { + // Sync the dockerload WebSocket with the view mode. + if (typeof window.composeDockerLoadToggle === 'function') { + window.composeDockerLoadToggle(isComposeAdvancedMode()); + } + var advanced = isComposeAdvancedMode(); + var $table = $('#compose_stacks'); + var $advanced = $table.find('.cm-advanced'); + + var setClass = function(enabled) { + if (enabled) { + $table.addClass('cm-advanced-view'); + } else { + $table.removeClass('cm-advanced-view'); + } + }; + + if (!animate) { + setClass(advanced); + $table.css({ + height: '', + overflow: '' + }); + $advanced.css({ + opacity: '', + display: '' + }); + } else { + if (advanced) { + // basic -> advanced: enable class first, then fade in advanced cells + setClass(true); + $table.css({ + height: $table.outerHeight(), + overflow: 'hidden' + }); + $advanced.stop(true, true).css({ + opacity: 0 + }).animate({ + opacity: 1 + }, 300, function() { + $table.css({ + height: '', + overflow: '' + }); + $advanced.css({ + opacity: '', + display: '' + }); + }); + } else { + // advanced -> basic: fade out then disable class to avoid flicker + $table.css({ + height: $table.outerHeight(), + overflow: 'hidden' + }); + $advanced.stop(true, true).animate({ + opacity: 0 + }, 300, function() { + setClass(false); + $table.css({ + height: '', + overflow: '' + }); + $advanced.css({ + opacity: '', + display: '' + }); + }); + } + } + + // Apply readmore to descriptions — exclude container detail rows; destroy first to avoid nested wrappers + var $readmoreEls = $('#compose_stacks .docker_readmore').not('.stack-details-container .docker_readmore'); + $readmoreEls.readmore('destroy'); + $readmoreEls.readmore({ + maxHeight: 32, + moreLink: "", + lessLink: "" + }); +} + +$(function() { + $(".tipsterallowed").show(); + $('.ca_nameEdit').tooltipster({ + trigger: 'custom', + triggerOpen: { + click: true, + touchstart: true, + mouseenter: true + }, + triggerClose: { + click: true, + scroll: false, + mouseleave: true + }, + delay: 1000, + contentAsHTML: true, + animation: 'grow', + interactive: true, + viewportAware: true, + functionBefore: function(instance, helper) { + var origin = $(helper.origin); + var myID = origin.attr('id'); + var name = $("#" + myID).html(); + var disabled = $("#" + myID).attr('data-isup') == "1" ? "disabled" : ""; + var notdisabled = $("#" + myID).attr('data-isup') == "1" ? "" : "disabled"; + var stackName = $("#" + myID).attr("data-scriptname"); + instance.content(composeEscapeHtml(stackName) + "
\ +
\ + \ + \ + \ + \ + \ +
"); + } + }); + + // Add Advanced View toggle (like Docker tab) + // Use compose-specific class to avoid conflict with Docker tab's advancedview when tabs are joined + var toggleHtml = ''; + + // In tabbed mode we must keep the toggle inside the compose content pane + // so it does not leak into the global tab bar, and in standalone mode it + // also appears above the compose stacks table. + var $toggleContainer = $('
').html(toggleHtml); + var $tableWrapper = $('#compose_stacks').closest('.TableContainer'); + if ($tableWrapper.length) { + $tableWrapper.before($toggleContainer); + } else if ($('#compose_stacks').length) { + $('#compose_stacks').before($toggleContainer); + } else if ($('.tabs').length) { + // Fallback for unusual layout: inject into tabs as a last resort + $('.tabs').append($toggleContainer); + } else { + $('body').prepend($toggleContainer); + } + + + // Initialize the Advanced/Basic view toggle. + // labels_placement:'left' puts both labels to the left of the slider. + // The plugin shows only the active label: "Basic View" (white) when + // unchecked, "Advanced View" (blue / class 'on') when checked. + var isAdvanced = $.cookie('compose_listview_mode') === 'advanced'; + $('.compose-advancedview').switchButton({ + labels_placement: 'left', + on_label: 'Advanced View', + off_label: 'Basic View', + checked: isAdvanced + }); + // Apply the current cookie state immediately so columns match the toggle. + applyListView(); + $('.compose-advancedview').change(function() { + // Persist selection and apply view consistently via applyListView() + $.cookie('compose_listview_mode', $('.compose-advancedview').is(':checked') ? 'advanced' : 'basic', { + expires: 3650 + }); + applyListView(true); + }); + + // ebox observer removed; pending update checks are now processed from + // refreshStackRow and processPendingComposeReloads directly. + + // Gate dockerload socket start until the stack list DOM is ready. + var composeListReady = false; + + // Load the persistent container cache before the stack list. + // This ensures dialog merge logic can use last-known container metadata. + loadPersistentContainerCache().then(function() { + composeLoadlist().then(function() { + composeListReady = true; + composeLogger('composeListReady=true, rows=' + $('#compose_stacks tr.compose-sortable').length, null, 'user', 'debug', 'dockerload'); + + // Start the dockerload socket now that the DOM has rows with data-ctids. + if (typeof window.composeDockerLoadToggle === 'function') { + composeLogger('triggering composeDockerLoadToggle, advancedMode=' + isComposeAdvancedMode(), null, 'user', 'debug', 'dockerload'); + window.composeDockerLoadToggle(isComposeAdvancedMode()); + } else { + composeLogger('composeDockerLoadToggle not available yet at composeLoadlist completion', null, 'user', 'debug', 'dockerload'); + } + + getConfig().then(function(config) { + if (config['STACKS_DEFAULT_EXPANDED'] == 'true') { + // Expand all stacks if the default is set to expanded + $('#compose_stacks tr.compose-sortable').each(function() { + if ($(this).data('isup') != "1" && config['ONLY_EXPAND_RUNNING_STACKS'] == 'true') { + return; // Skip stopped stacks if ONLY_EXPAND_RUNNING_STACKS is true + } + var stackId = $(this).attr('id').replace('stack-row-', ''); + toggleStackDetails(stackId); + }); + } + }); + }); + // ── Cross-widget sync ────────────────────────────────────────── + // On the Docker page the Compose stacks list is rendered below + // the built-in Docker containers table (non-tabbed) or in a + // separate tab (tabbed mode). When Docker's loadlist() fires + // (container start/stop/restart/etc.), the compose list must + // also refresh so state stays in sync. + (function hookLoadlist() { + var composeRefreshTimer = null; + var composeDataStale = false; + + // For tabbed mode: detect if our panel is hidden so we can + // defer the refresh until the user switches to the compose tab. + var composeTable = document.getElementById('compose_stacks'); + var tabPanel = composeTable ? composeTable.closest('[role="tabpanel"]') : null; + var isTabbed = tabPanel !== null; + + function isComposePanelVisible() { + if (!isTabbed) return true; // non-tabbed: always visible + return tabPanel.style.display !== 'none'; + } + + function wrapLoadlist() { + if (typeof window.loadlist === 'function' && !window.loadlist._composeTabHooked) { + var originalLoadlist = window.loadlist; + window.loadlist = function() { + originalLoadlist.apply(this, arguments); + + // Skip compose reload if refreshStackRow is already handling it + if (pendingComposeRefreshCount > 0 || skipNextComposeLoadlist) { + composeLogger('suppressed composeLoadlist (pending=' + pendingComposeRefreshCount + ', skip=' + skipNextComposeLoadlist + ')', null, 'user', 'debug', 'hookLoadlist'); + skipNextComposeLoadlist = false; + return; + } + + if (isComposePanelVisible()) { + // Visible — refresh with debounce + clearTimeout(composeRefreshTimer); + composeRefreshTimer = setTimeout(function() { + composeLoadlist(); + }, 2000); + } else { + // Hidden tab — mark stale, refresh on tab switch + composeDataStale = true; + } + }; + window.loadlist._composeTabHooked = true; + composeLogger('hooked loadlist() for cross-widget sync, tabbed=' + isTabbed, null, 'user', 'info', 'hookLoadlist'); + return true; + } + return false; + } + + // loadlist may not exist yet — retry every 500ms + if (!wrapLoadlist()) { + var hookInterval = setInterval(function() { + if (wrapLoadlist()) clearInterval(hookInterval); + }, 500); + // Give up after 30s + setTimeout(function() { + clearInterval(hookInterval); + }, 30000); + } + + // Tabbed mode: watch for tab switches to flush stale data + if (isTabbed) { + var panelObserver = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.attributeName === 'style' && isComposePanelVisible() && composeDataStale) { + composeDataStale = false; + composeLoadlist(); + } + }); + }); + panelObserver.observe(tabPanel, { + attributes: true, + attributeFilter: ['style'] + }); + } + })(); + + // ── CPU & Memory load via dockerload Nchan channel ───────────── + // Only runs in advanced view (load column is hidden in basic view). + // composeDockerLoadToggle(true/false) is called from applyListView() + // so the socket starts/stops whenever the user switches view modes. + function initComposeDockerLoadSubscriber() { + if (typeof NchanSubscriber !== 'function') { + composeLogger('NchanSubscriber not available yet', null, 'user', 'debug', 'dockerload'); + return false; + } + + // Tear down previous subscriber if page was re-navigated (Unraid + // AJAX navigation preserves window globals but old closures/sockets + // become stale). Always create a fresh subscriber. + if (window._composeDockerLoad) { + composeLogger('tearing down previous subscriber', null, 'user', 'info', 'dockerload'); + try { + window._composeDockerLoad.stop(); + } catch (e) {} + } + if (window._composeDockerLoadStaleTimer) { + clearInterval(window._composeDockerLoadStaleTimer); + } + if (window._composeDockerLoadVisHandler) { + document.removeEventListener('visibilitychange', window._composeDockerLoadVisHandler); + } + if (window._composeDockerLoadPanelObserver) { + window._composeDockerLoadPanelObserver.disconnect(); + } + $(document).off('composeListRefreshed.dockerload'); + + composeLogger('initializing subscriber, composeListReady=' + composeListReady, null, 'user', 'info', 'dockerload'); + + var composeDockerLoad = new NchanSubscriber('/sub/dockerload', { + subscriber: 'websocket', + reconnectTimeout: 5000 + }); + window._composeDockerLoad = composeDockerLoad; + var composeDockerLoadRunning = false; + var composeDockerLoadDropped = 0; + + // Cache of { stackId, containerIds[] } built from the DOM once after + // composeLoadlist() and reused until the row count changes. + // Avoids O(stacks) DOM traversal + string splits on every stats tick. + var composeStackIndex = null; + var composeLoadById = {}; + var composeLoadStaleMs = 15000; + + function isComposeLoadVisible() { + if (!isComposeAdvancedMode()) return false; + if (document.visibilityState === 'hidden') return false; + var $table = $('#compose_stacks'); + if (!$table.length) return false; + var $tabPanel = $table.closest('[role="tabpanel"]'); + if ($tabPanel.length && $tabPanel[0].style.display === 'none') return false; + return true; + } + + function clearContainerLoad(shortId) { + $('.compose-cpu-' + shortId).addClass('compose-text-muted').text('-'); + $('#compose-cpu-' + shortId).css('width', '0'); + $('.compose-mem-' + shortId).hide(); + } + + function buildComposeStackIndex() { + composeStackIndex = []; + $('#compose_stacks tr.compose-sortable').each(function() { + var stackId = ($(this).attr('id') || '').replace('stack-row-', ''); + if (!stackId) return; + var ctidsAttr = $(this).attr('data-ctids') || ''; + composeStackIndex.push({ + stackId: stackId, + containerIds: ctidsAttr ? ctidsAttr.split(',') : [] + }); + }); + composeLogger('buildComposeStackIndex complete, stacks=' + composeStackIndex.length, null, 'user', 'debug', 'dockerload'); + } + + // Invalidate the cache when the list refreshes so that added/removed + // stacks are picked up on the next stats tick. + $(document).on('composeListRefreshed.dockerload', function() { + composeLogger('composeListRefreshed — invalidating stack index and load cache', null, 'user', 'debug', 'dockerload'); + composeStackIndex = null; + composeLoadById = {}; + }); + + window.composeDockerLoadToggle = function(enable) { + if (enable && !composeDockerLoadRunning) { + composeLogger('starting WebSocket', null, 'user', 'info', 'dockerload'); + composeDockerLoad.start(); + composeDockerLoadRunning = true; + } else if (!enable && composeDockerLoadRunning) { + composeLogger('stopping WebSocket', null, 'user', 'info', 'dockerload'); + composeDockerLoad.stop(); + composeDockerLoadRunning = false; + } + }; + + function pruneStaleLoadEntries(now) { + var staleIds = []; + for (var knownId in composeLoadById) { + if ((now - composeLoadById[knownId].ts) > composeLoadStaleMs) { + staleIds.push(knownId); + } + } + if (staleIds.length > 0) { + composeLogger('pruning ' + staleIds.length + ' stale container(s)', { + ids: staleIds + }, 'user', 'debug', 'dockerload'); + } + staleIds.forEach(function(staleId) { + delete composeLoadById[staleId]; + clearContainerLoad(staleId); + }); + return staleIds.length > 0; + } + + function renderStackAggregates() { + // Aggregate per-stack totals and update stack-level cells. + // Build (or reuse) the stack→container index. + var currentRowCount = $('#compose_stacks tr.compose-sortable').length; + if (!composeStackIndex || composeStackIndex.length !== currentRowCount) { + buildComposeStackIndex(); + } + + composeStackIndex.forEach(function(entry) { + // Primary: short IDs baked into the row by ComposeList.php + var idList = entry.containerIds.slice(); + + // Fallback: if the detail panel was expanded, stackContainersCache + // may have fresher IDs (e.g. after a compose up added a service) + if (idList.length === 0) { + var containers = stackContainersCache[entry.stackId]; + if (containers && containers.length > 0) { + containers.forEach(function(ct) { + var ctId = String(ct.id || '').substring(0, 12); + if (ctId) idList.push(ctId); + }); + } + } + if (idList.length === 0) return; + + var totalCpu = 0; + var totalMemUsedBytes = 0; + var totalMemLimitBytes = 0; + var matched = 0; + idList.forEach(function(ctId) { + if (ctId && composeLoadById[ctId]) { + totalCpu += composeLoadById[ctId].cpu; + totalMemUsedBytes += composeLoadById[ctId].memUsedBytes || 0; + totalMemLimitBytes += composeLoadById[ctId].memLimitBytes || 0; + matched++; + } + }); + + if (matched > 0) { + var aggCpu = formatCpuPercent(totalCpu); + var stackMemTotalBytes = 0; + if (totalMemLimitBytes > 0 && composeSystemMemBytes > 0) { + stackMemTotalBytes = Math.min(totalMemLimitBytes, composeSystemMemBytes); + } else if (totalMemLimitBytes > 0) { + stackMemTotalBytes = totalMemLimitBytes; + } else if (composeSystemMemBytes > 0) { + stackMemTotalBytes = composeSystemMemBytes; + } + var aggMem = formatMemUsageText(totalMemUsedBytes, stackMemTotalBytes); + $('.compose-stack-cpu-' + entry.stackId).removeClass('compose-text-muted').text(aggCpu); + $('#compose-stack-cpu-' + entry.stackId).css('width', Math.min(totalCpu, 100).toFixed(2) + '%'); + $('.compose-stack-mem-' + entry.stackId).show().text(aggMem); + } else { + $('.compose-stack-cpu-' + entry.stackId).addClass('compose-text-muted').text('-'); + $('#compose-stack-cpu-' + entry.stackId).css('width', '0'); + $('.compose-stack-mem-' + entry.stackId).hide(); + } + }); + } + + composeDockerLoad.on('message', function(msg) { + var now = Date.now(); + var data = msg.split('\n'); + var i = 0; + var row = data[i]; + while (row) { + var parts = row.split(';'); + if (parts.length >= 3) { + var cpuRaw = parseFloat(parts[1]) || 0; + var cpuNorm = Math.round(Math.min(cpuRaw / Math.max(composeCpuCount, 1), 100) * 100) / 100; + var memPair = parseMemUsagePair(parts[2]); + composeLoadById[parts[0]] = { + cpu: cpuNorm, + cpuText: formatCpuPercent(cpuNorm), + mem: formatMemUsageText(memPair.used, memPair.limit), + memUsedBytes: memPair.used, + memLimitBytes: memPair.limit, + ts: now + }; + } + i++; + row = data[i]; + } + + pruneStaleLoadEntries(now); + + // Skip DOM updates when the page isn't visible — the cache + // stays warm so we can render instantly on return. + if (!isComposeLoadVisible()) { + composeDockerLoadDropped++; + return; + } + + // Update per-container CPU & MEM elements in expanded detail tables + for (var shortId in composeLoadById) { + var info = composeLoadById[shortId]; + $('.compose-cpu-' + shortId).removeClass('compose-text-muted').text(info.cpuText); + $('.compose-mem-' + shortId).show().text(info.mem); + $('#compose-cpu-' + shortId).css('width', info.cpuText); + } + + renderStackAggregates(); + }); + + composeDockerLoad.on('error', function(code, desc) { + composeLogger('WebSocket error', { + code: code, + desc: desc + }, 'user', 'warn', 'dockerload'); + }); + + // If dockerload pauses/stalls, drop stale values on a timer so the UI + // falls back to placeholders instead of showing frozen metrics forever. + window._composeDockerLoadStaleTimer = setInterval(function() { + if (!isComposeLoadVisible()) { + return; + } + if (pruneStaleLoadEntries(Date.now())) { + renderStackAggregates(); + } + }, 3000); + + // When the browser tab becomes visible again, invalidate the + // stack index so the next WebSocket message rebuilds it from + // the current DOM. This prevents permanently stale data when + // the page loaded or sat in a background tab. + window._composeDockerLoadVisHandler = function() { + if (document.visibilityState === 'visible' && composeDockerLoadRunning) { + if (composeDockerLoadDropped > 0) { + composeLogger('browser tab became visible — skipped ' + composeDockerLoadDropped + ' messages while hidden, rendering cached data', null, 'user', 'debug', 'dockerload'); + composeDockerLoadDropped = 0; + } + composeStackIndex = null; + + // Immediately render the cached load data so the UI + // shows current metrics without waiting for the next tick. + for (var shortId in composeLoadById) { + var info = composeLoadById[shortId]; + $('.compose-cpu-' + shortId).removeClass('compose-text-muted').text(info.cpuText); + $('.compose-mem-' + shortId).show().text(info.mem); + $('#compose-cpu-' + shortId).css('width', info.cpuText); + } + renderStackAggregates(); + } + }; + document.addEventListener('visibilitychange', window._composeDockerLoadVisHandler); + + // In tabbed mode, also invalidate the cache when the compose + // panel becomes visible (user switches tabs) so stale entries + // don't linger from when the panel was hidden. + var $loadTable = $('#compose_stacks'); + var $loadTabPanel = $loadTable.length ? $loadTable.closest('[role="tabpanel"]') : $(); + if ($loadTabPanel.length) { + composeLogger('tabbed mode detected — observing panel visibility', {}, 'user', 'debug', 'dockerload'); + window._composeDockerLoadPanelObserver = new MutationObserver(function() { + if ($loadTabPanel[0].style.display !== 'none' && composeDockerLoadRunning) { + composeLogger('compose tab became visible — invalidating stack index', { + 'listReady': composeListReady, + 'advanced': isComposeAdvancedMode() + }, 'user', 'debug', 'dockerload'); + composeStackIndex = null; + } + }); + window._composeDockerLoadPanelObserver.observe($loadTabPanel[0], { + attributes: true, + attributeFilter: ['style'] + }); + } + + // Only auto-start the socket if the stack list is already + // loaded (composeListReady is true). Otherwise the + // composeLoadlist().then() callback will start it. + if (composeListReady && isComposeAdvancedMode()) { + composeLogger('auto-starting socket', { + 'listReady': composeListReady, + 'advanced': isComposeAdvancedMode() + }, 'user', 'info', 'dockerload'); + composeDockerLoad.start(); + composeDockerLoadRunning = true; + } else { + composeLogger('deferring socket start', { + 'listReady': composeListReady, + 'advanced': isComposeAdvancedMode() + }, 'user', 'debug', 'dockerload'); + } + return true; + } + + // Standalone compose mode can race script load order; retry briefly + // so delayed NchanSubscriber availability still initializes dockerload. + if (!initComposeDockerLoadSubscriber()) { + composeLogger('subscriber init deferred — will retry every 250ms', null, 'user', 'debug', 'dockerload'); + var composeDockerLoadInitAttempts = 0; + var composeDockerLoadInitTimer = setInterval(function() { + composeDockerLoadInitAttempts++; + if (initComposeDockerLoadSubscriber()) { + composeLogger('subscriber initialized on retry #' + composeDockerLoadInitAttempts, null, 'user', 'info', 'dockerload'); + clearInterval(composeDockerLoadInitTimer); + } else if (composeDockerLoadInitAttempts >= 40) { + composeLogger('subscriber init gave up after ' + composeDockerLoadInitAttempts + ' attempts', null, 'user', 'warn', 'dockerload'); + clearInterval(composeDockerLoadInitTimer); + } + }, 250); + } + }); +}); + +function addStack() { + // Show custom modal for stack creation + var modalHtml = ` +
+ +
+ `; + // Remove any existing modal + var existingOverlay = document.getElementById('compose-stack-modal-overlay'); + if (existingOverlay) { + existingOverlay.remove(); + } + // Insert modal into body + var tempDiv = document.createElement('div'); + tempDiv.innerHTML = modalHtml; + document.body.appendChild(tempDiv.firstElementChild); + + getConfig().then(function(pluginCfg) { + var defaultUseDefaultComposeFiles = pluginCfg && pluginCfg.NEW_STACK_USE_DEFAULT_COMPOSE_FILES === 'true'; + var defaultOverrideAutomatic = !(pluginCfg && pluginCfg.NEW_STACK_OVERRIDE_MANAGEMENT_AUTOMATIC === 'false'); + $('#compose-stack-use-default-compose-files').prop('checked', defaultUseDefaultComposeFiles); + $('#compose-stack-override-management-automatic').prop('checked', defaultOverrideAutomatic); + updateAddStackDefaultComposeDiscoveryState(); + }); + + // The add-stack modal is created dynamically, so attach the picker after insertion. + if ($.fn.fileTreeAttach) { + var $indirectInputs = $('#compose-stack-indirect, #compose-stack-indirect-file, #compose-stack-env-path'); + composeBindFileTreeInputs($indirectInputs, { + zIndex: 100010, + minWidth: 320, + addClass: false + }); + } + + $('#compose-stack-indirect, #compose-stack-indirect-file, #compose-stack-env-path').off('input.defaultComposeDiscovery').on('input.defaultComposeDiscovery', function() { + updateAddStackDefaultComposeDiscoveryState(); + }); + + window.closeComposeStackModal = function() { + var overlay = document.getElementById('compose-stack-modal-overlay'); + if (overlay) { + overlay.remove(); + } + }; + + window.submitComposeStackModal = function() { + var name = document.getElementById('compose-stack-name').value.trim(); + var desc = document.getElementById('compose-stack-desc').value.trim(); + var indirect = document.getElementById('compose-stack-indirect').value.trim(); + var indirectFile = document.getElementById('compose-stack-indirect-file').value.trim(); + var envPath = document.getElementById('compose-stack-env-path').value.trim(); + var useDefaultComposeFiles = document.getElementById('compose-stack-use-default-compose-files').checked ? 'true' : 'false'; + var overrideManagementAutomatic = document.getElementById('compose-stack-override-management-automatic').checked ? 'true' : 'false'; + var errorDiv = document.getElementById('compose-stack-modal-error'); + if (!name) { + errorDiv.textContent = "Please enter a stack name."; + errorDiv.style.display = "block"; + return; + } + if (indirect && indirectFile) { + errorDiv.textContent = "Set either Indirect Path or Indirect Compose File, not both."; + errorDiv.style.display = "block"; + return; + } + errorDiv.style.display = "none"; + // Disable all buttons in the modal + var modal = document.getElementById('compose-stack-modal-overlay'); + if (modal) { + var btns = modal.querySelectorAll('button'); + btns.forEach(function(btn) { + btn.disabled = true; + }); + } + $.post( + caURL, { + action: 'addStack', + stackName: name, + stackDesc: desc, + stackPath: indirect, + stackFilePath: indirectFile, + envPath: envPath, + useDefaultComposeFiles: useDefaultComposeFiles, + overrideManagementAutomatic: overrideManagementAutomatic + }, + function(data) { + window.closeComposeStackModal(); + if (data) { + var response; + try { + response = JSON.parse(data); + } catch (e) { + // Handle invalid or unexpected JSON response + composeLogger('Failed to parse addStack response', { + error: e && e.toString(), + data: data + }, 'user', 'error', 'addStack'); + swal({ + title: "Failed to create stack", + text: "Unexpected response from server", + type: "error" + }); + return; + } + if (response.result == "success") { + openEditorModalByProject(response.project, response.projectName); + composeLoadlist(); + } else { + swal({ + title: "Failed to create stack", + text: response.message || "An error occurred", + type: "error" + }); + } + } else { + swal({ + title: "Failed to create stack", + text: "No response from server", + type: "error" + }); + } + } + ).fail(function() { + window.closeComposeStackModal(); + swal({ + title: "Failed to create stack", + text: "Request failed", + type: "error" + }); + }); + }; +} + + + +function stripTags(string) { + return string.replace(/(<([^>]+)>)/ig, ""); +} + +function editName(myID) { + var currentName = $("#" + myID).attr("data-namename"); + $("#" + myID).attr("data-originalName", currentName); + var $el = $("#" + myID); + $el.empty(); + var $input = $("").attr('id', 'newName' + myID).val(currentName); + var $cancel = $("").on('click', function() { + cancelName(myID); + }); + var $apply = $("").on('click', function() { + applyName(myID); + }); + $el.append($input).append($("
")).append($cancel).append("  ").append($apply); + $el.tooltipster("close"); + $el.tooltipster("disable"); +} + +function editDesc(myID) { + var origID = myID; + $("#" + myID).tooltipster("close"); + myID = myID.replace("name", "desc"); + var currentDesc = $("#" + myID).text(); + $("#" + myID).attr("data-originaldescription", currentDesc); + var $el = $("#" + myID); + $el.empty(); + var $textarea = $("").attr('id', 'newDesc' + myID).val(currentDesc); + var $cancel = $("").on('click', function() { + cancelDesc(myID); + }); + var $apply = $("").on('click', function() { + applyDesc(myID); + }); + $el.append($textarea).append($("
")).append($cancel).append("  ").append($apply); + $("#" + origID).tooltipster("enable"); +} + +function applyName(myID) { + var newName = $("#newName" + myID).val(); + var project = $("#" + myID).attr("data-scriptname"); + $("#" + myID).text(newName); + $("#" + myID).tooltipster("enable"); + $("#" + myID).tooltipster("close"); + $.post(caURL, { + action: 'changeName', + script: project, + newName: newName + }, function(data) { + refreshStackByProject(project); + }); +} + +function cancelName(myID) { + var oldName = $("#" + myID).attr("data-originalName"); + $("#" + myID).text(oldName); + $("#" + myID).tooltipster("enable"); + $("#" + myID).tooltipster("close"); +} + +function cancelDesc(myID) { + var oldName = $("#" + myID).attr("data-originaldescription"); + $("#" + myID).text(oldName); + $("#" + myID).tooltipster("enable"); + $("#" + myID).tooltipster("close"); +} + +var composeYamlSchemaCache = null; +var composeYamlCustomTagPattern = /(^|[\s:[{,\-])!(override|reset|merge)\b/m; + +function getComposeYamlLibrary() { + if (typeof jsyaml !== 'undefined') { + return jsyaml; + } + + if (typeof window !== 'undefined' && window.jsyaml) { + return window.jsyaml; + } + + return null; +} + +function composeYamlContainsCustomTags(content) { + return composeYamlCustomTagPattern.test(content || ''); +} + +function buildComposeYamlSchema() { + var yamlLib = getComposeYamlLibrary(); + if (!yamlLib || typeof yamlLib.Type !== 'function' || !yamlLib.DEFAULT_SCHEMA || typeof yamlLib.DEFAULT_SCHEMA.extend !== 'function') { + return null; + } + + var customTags = ['!override', '!reset', '!merge']; + var kinds = ['scalar', 'sequence', 'mapping']; + var types = []; + + customTags.forEach(function(tag) { + kinds.forEach(function(kind) { + types.push(new yamlLib.Type(tag, { + kind: kind, + resolve: function() { + return true; + }, + construct: function(data) { + if (data === null || data === undefined) { + if (kind === 'sequence') return []; + if (kind === 'mapping') return {}; + return ''; + } + return data; + } + })); + }); + }); + + return yamlLib.DEFAULT_SCHEMA.extend(types); +} + +function loadComposeYaml(content) { + var input = content || ''; + var yamlLib = getComposeYamlLibrary(); + + if (!yamlLib || typeof yamlLib.load !== 'function') { + throw new Error('YAML parser is unavailable. Please reload the page and try again.'); + } + + if (!composeYamlSchemaCache) { + composeYamlSchemaCache = buildComposeYamlSchema(); + } + + if (composeYamlSchemaCache) { + return yamlLib.load(input, { + schema: composeYamlSchemaCache + }); + } + return yamlLib.load(input); +} + +function applyDesc(myID) { + var newDesc = $("#newDesc" + myID).val(); + var project = $("#" + myID).attr("data-scriptname"); + // Use .text() with CSS white-space to avoid .html() XSS risk + $("#" + myID).text(newDesc).css('white-space', 'pre-line'); + $.post(caURL, { + action: 'changeDesc', + script: project, + newDesc: newDesc + }); +} + +// Opens editor modal directly using myID element (from tooltipster) +function editStack(myID) { + $("#" + myID).tooltipster("close"); + var project = $("#" + myID).attr("data-scriptname"); + var projectName = $("#" + myID).attr("data-namename"); + openEditorModalByProject(project, projectName); +} + +function generateProfiles(myID, myProject = null) { + var project = myProject; + if (myID) { + $("#" + myID).tooltipster("close"); + project = $("#" + myID).attr("data-scriptname"); + } + + $.post(caURL, { + action: 'getYml', + script: project + }, function(rawComposefile) { + var project_profiles = new Set(); + if (rawComposefile) { + var rawComposefile = JSON.parse(rawComposefile); + + if ((rawComposefile.result == 'success')) { + var main_doc = loadComposeYaml(rawComposefile.content); + + for (var service_key in main_doc.services) { + var service = main_doc.services[service_key]; + if (service.hasOwnProperty("profiles")) { + for (const profile of service.profiles) { + project_profiles.add(profile); + } + } + } + + var rawProfiles = JSON.stringify(Array.from(project_profiles)); + $.post(caURL, { + action: "saveProfiles", + script: project, + scriptContents: rawProfiles + }, function(data) { + if (!data) { + swal({ + title: "Failed to update profiles.", + type: "error" + }); + composeLogger('Failed to update profiles', { + project: project, + rawProfiles: rawProfiles, + response: data + }, 'user', 'error', 'stack-action'); + } + }); + } + } + }); +} + +function editStackSettings(myID) { + var project = $("#" + myID).attr("data-scriptname"); + + $.post(caURL, { + action: 'getEnvPath', + script: project + }, function(rawEnvPath) { + if (rawEnvPath) { + var rawEnvPath = JSON.parse(rawEnvPath); + if (rawEnvPath.result == 'success') { + var formHtml = `
ENV File Path
`; + formHtml += `
`; + formHtml += ``; + swal({ + title: "Stack Settings", + text: formHtml, + html: true, + showCancelButton: true, + confirmButtonText: "Save", + closeOnConfirm: false + }, function(confirmed) { + if (confirmed) { + var new_env_path = document.getElementById("env_path").value; + $.post(caURL, { + action: 'setEnvPath', + envPath: new_env_path, + script: project + }, function(data) { + var title = "Failed to set stack settings."; + var message = ""; + var type = "error"; + if (data) { + try { + var response = JSON.parse(data); + if (response.result == "success") { + title = "Success"; + } + message = response.message; + type = response.result; + } catch (e) { + message = "Invalid server response."; + } + } + swal({ + title: title, + text: message, + type: type + }, function() { + refreshStackByProject(project); + }); + }); + } + }); + } + } + }); +} + +// Unified update warning dialog - called from stack row and container table +function showUpdateWarning(project, stackId) { + var path = compose_root + '/' + project; + // Use the existing UpdateStack function which already has the warning dialog + UpdateStack(path, ""); +} + +// Show a brief swal when a background command is dispatched +function notifyBackgroundStarted(label, shouldNotify = true) { + if (!shouldNotify) return; + + swal({ + title: 'Running in background', + text: label + ' has been started in the background.\nYou will receive a notification when it completes.', + type: 'info', + timer: 3000, + showConfirmButton: false + }); +} + +// Poll for background operation completion by checking if stack lock is released +// Once lock is released, refresh the stack and clear the checking state +function pollBackgroundCompletion(stackName, refreshDelayMs = 0) { + // Poll with adaptive frequency to handle both fast and slow operations: + // 0–60s: every 2s (30 checks) + // 60s–5m: every 5s (48 checks) + // 5m–30m: every 15s (100 checks) + // Total coverage: ~30 minutes before giving up + var elapsed = 0; // seconds + var timeoutHandle; + + function getInterval() { + if (elapsed < 60) return 2000; + if (elapsed < 300) return 5000; + return 15000; + } + + function check() { + $.post(caURL, { + action: 'checkStackLock', + script: stackName + }, function(response) { + try { + var parsed = JSON.parse(response); + if (parsed.result === 'success' && !parsed.locked) { + // Lock is released — clear spinner first, then fetch fresh data + // so refreshStackByProject always wins and overwrites the restored state + setStackActionInProgress(stackName, false); + setTimeout(function() { + refreshStackByProject(stackName); + processPendingUpdateChecks(); + }, refreshDelayMs); + return; // stop scheduling + } + } catch (e) { + composeLogger('Parse error', e, 'user', 'error', 'poll'); + } + + // Schedule next check if still within timeout (30 minutes) + var interval = getInterval(); + elapsed += interval / 1000; + if (elapsed < 1800) { + timeoutHandle = setTimeout(check, interval); + } else { + composeLogger('Timeout after 30m', { + stack: stackName + }, 'user', 'warn', 'poll'); + setStackActionInProgress(stackName, false); + } + }); + } + + // Start first check after 2 seconds + elapsed += 2; + timeoutHandle = setTimeout(check, 2000); +} + +function composeActionStateText(actionName) { + var map = { + up: 'starting...', + down: 'stopping...', + stop: 'stopping...', + restart: 'restarting...', + pull: 'pulling...', + update: 'updating...', + forceUpdate: 'updating...', + composeUpPullBuild: 'pulling and rebuilding...' + }; + return map[actionName] || 'checking...'; +} + +function performComposeAction(opts) { + opts = opts || {}; + var stackName = opts.stackName; + var actionName = opts.actionName || ''; + var title = opts.title || (actionName ? actionName.replace(/([A-Z])/g, ' $1').trim() : 'Compose Action'); + var requestUrl = opts.requestUrl || compURL; + var payload = opts.payload || {}; + var background = opts.background || false; + var suppressBackgroundNotification = opts.suppressBackgroundNotification || false; + var pendingReload = opts.pendingReload || false; + var actionStateText = opts.actionStateText || composeActionStateText(actionName); + var onComplete = opts.onComplete; + + if (pendingReload && stackName) { + if (pendingComposeReloadStacks.indexOf(stackName) === -1) { + pendingComposeReloadStacks.push(stackName); + schedulePendingComposeReloads(); + } + } + + if (stackName) { + setStackActionInProgress(stackName, true, actionStateText); + } + + payload.background = background ? 1 : 0; + + $.post(requestUrl, payload, function(data) { + var parsed = tryParseJson(data); + if (parsed && parsed.background) { + if (stackName && !pendingReload) { + setStackActionInProgress(stackName, true, actionStateText); + } + if (!suppressBackgroundNotification) { + notifyBackgroundStarted(title, true); + } + if (stackName) { + pollBackgroundCompletion(stackName, opts.refreshDelayMs || 0); + } + } else if (data) { + if (stackName && !pendingReload) { + setStackActionInProgress(stackName, false); + } + openBox(data, title, 800, 1200, true); + } + if (typeof onComplete === 'function') { + onComplete(parsed, data); + } + }).fail(function() { + if (stackName && !pendingReload) { + setStackActionInProgress(stackName, false); + } + if (typeof onComplete === 'function') { + onComplete(null, null); + } + }); +} + +function confirmedComposeAction(path, opts) { + opts = opts || {}; + var stackName = basename(path); + opts = $.extend(true, { + actionName: '', + titlePrefix: '', + requestUrl: compURL, + payload: { + path: path, + profile: opts.profile || '' + }, + background: false, + suppressBackgroundNotification: false, + pendingReload: true, + refreshDelayMs: 0, + actionStateText: null, + onComplete: null, + preAction: null + }, opts); + + if (opts.preAction) { + opts.preAction(function() { + var nextOpts = $.extend({}, opts); + nextOpts.preAction = null; + confirmedComposeAction(path, nextOpts); + }); + return; + } + + performComposeAction({ + stackName: stackName, + actionName: opts.actionName, + title: (opts.titlePrefix ? opts.titlePrefix + ': ' : '') + stackName, + requestUrl: opts.requestUrl, + payload: opts.payload, + background: opts.background, + suppressBackgroundNotification: opts.suppressBackgroundNotification, + pendingReload: opts.pendingReload, + refreshDelayMs: opts.refreshDelayMs, + actionStateText: opts.actionStateText, + onComplete: opts.onComplete + }); +} + +// Confirmed action handlers (no dialog, just execute) +function ComposeUpConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) { + confirmedComposeAction(path, { + actionName: 'up', + titlePrefix: 'Compose Up', + requestUrl: compURL, + payload: { + action: 'composeUp', + path: path, + profile: profile + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: true + }); +} + +// Recreate containers without pulling (for label changes) +function ComposeRecreateConfirmed(path, profile = "") { + var height = 800; + var width = 1200; + + $.post(compURL, { + action: 'composeUpRecreate', + path: path, + profile: profile + }, function(data) { + if (data) { + openBox(data, "Compose Recreate: " + basename(path), height, width, true); + } + }) +} + +function ComposeUp(path, profile = "") { + showStackActionDialog('up', path, profile); +} + +function ComposeDownConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) { + confirmedComposeAction(path, { + actionName: 'down', + titlePrefix: 'Compose Down', + requestUrl: compURL, + payload: { + action: 'composeDown', + path: path, + profile: profile + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: true + }); +} + +function ComposeDown(path, profile = "") { + showStackActionDialog('down', path, profile); +} + +// Stop stack without removing containers +function ComposeStopConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) { + confirmedComposeAction(path, { + actionName: 'stop', + titlePrefix: 'Compose Stop', + requestUrl: compURL, + payload: { + action: 'composeStop', + path: path, + profile: profile + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: true + }); +} + +function ComposeStop(path, profile = "") { + showStackActionDialog('stop', path, profile); +} + +// Restart stack (recreate containers without pulling) +function ComposeRestartConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) { + confirmedComposeAction(path, { + actionName: 'restart', + titlePrefix: 'Compose Restart', + requestUrl: compURL, + payload: { + action: 'composeUpRecreate', + path: path, + profile: profile + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: true, + refreshDelayMs: 1000 + }); +} + +function ComposeRestart(path, profile = "") { + showStackActionDialog('restart', path, profile); +} + +// Force update stack (pull and rebuild even without detected updates) +function ForceUpdateStackConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) { + var stackName = basename(path); + if (pendingUpdateCheckStacks.indexOf(stackName) === -1) { + pendingUpdateCheckStacks.push(stackName); + } + + confirmedComposeAction(path, { + preAction: function(done) { + $.post(caURL, { + action: 'markStackForRecheck', + stacks: JSON.stringify([stackName]) + }, done); + }, + actionName: 'forceUpdate', + titlePrefix: 'Force Update', + requestUrl: compURL, + payload: { + action: 'composeUpPullBuild', + path: path, + profile: profile + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: true + }); +} + +function ForceUpdateStack(path, profile = "") { + showStackActionDialog('forceUpdate', path, profile); +} + +// Prompt user to recreate containers after label changes +function promptRecreateContainers(closeAfterSave) { + if (typeof closeAfterSave === 'undefined') { + closeAfterSave = true; + } + + var project = editorModal.currentProject; + if (!project) { + swal({ + title: "Saved!", + text: "All changes have been saved.", + type: "success", + timer: 1500, + showConfirmButton: false + }); + return; + } + + // Find the stack row and check if it's running + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]'); + var isUp = $stackRow.length > 0 && $stackRow.data('isup') == "1"; + + if (!isUp) { + // Stack is not running; optionally close editor and show saved message + if (closeAfterSave) { + doCloseEditorModal(); + } + swal({ + title: "Saved!", + text: "All changes have been saved. Container labels will take effect when you start the stack.", + type: "success" + }, function() { + refreshStackByProject(project); + }); + return; + } + + if (!closeAfterSave) { + swal({ + title: "Saved!", + text: "Container labels were saved. Recreate or restart containers to apply the changes.", + type: "info", + timer: 2000, + showConfirmButton: false + }, function() { + refreshStackByProject(project); + }); + return; + } + + // Stack is running, ask if user wants to recreate + var path = compose_root + '/' + project; + swal({ + title: "Recreate Containers?", + text: '
' + + '

Container labels (icon, WebUI) have been saved.

' + + '

Containers must be recreated for these changes to take effect.

' + + '

This will briefly restart the affected containers. Your data will be preserved.

' + + '
', + html: true, + type: "warning", + showCancelButton: true, + confirmButtonText: "Recreate Now", + cancelButtonText: "Later", + closeOnConfirm: true + }, function(confirmed) { + doCloseEditorModal(); + if (confirmed) { + // Use setTimeout to ensure swal is fully closed before opening ttyd dialog + setTimeout(function() { + ComposeRecreateConfirmed(path, ""); + }, 300); + } else { + swal({ + title: "Saved!", + text: "Changes saved. Remember to restart or recreate containers to apply label changes.", + type: "info", + timer: 2000, + showConfirmButton: false + }, function() { + refreshStackByProject(project); + }); + } + }); +} + +// Track stacks that need update check after operation completes +// Using array to support Update All Stacks operation +var pendingUpdateCheckStacks = []; + +// Process the queued stacks from pending update checks. +// This is called from refreshStackRow and processPendingComposeReloads. +function processPendingUpdateChecks() { + if (!pendingUpdateCheckStacks || pendingUpdateCheckStacks.length === 0) { + return; + } + + var stacksToCheck = pendingUpdateCheckStacks.slice(); + pendingUpdateCheckStacks = []; + var deferredStacks = []; + + // Delay slightly to let the UI settle after row refresh + setTimeout(function() { + composeLogger('Processing pending update checks', { + stacks: stacksToCheck + }, 'user', 'debug', 'update-check'); + + stacksToCheck.forEach(function(stackName) { + if (composeStackActionInProgress[stackName]) { + // Update action still in progress; defer until completion. + deferredStacks.push(stackName); + composeLogger('Deferring pending update check while action is in progress', { + stack: stackName + }, 'user', 'info', 'update-check'); + } else { + checkStackUpdates(stackName); + } + }); + + if (deferredStacks.length > 0) { + pendingUpdateCheckStacks = pendingUpdateCheckStacks.concat(deferredStacks); + } + }, 1000); +} + +// >0 while refreshStackRow AJAX calls are in-flight; the loadlist +// hook skips composeLoadlist until all pending refreshes complete. +var pendingComposeRefreshCount = 0; + +// One-shot flag: consumed by the loadlist hook so the very next +// loadlist call skips composeLoadlist even if refreshStackRow +// already completed before loadlist fires. +var skipNextComposeLoadlist = false; + +// Track stacks that need a full compose list reload after start/stop operations +var pendingComposeReloadStacks = []; +// Track stacks currently in progress (e.g. background start/stop/update) +var composeStackActionInProgress = {}; +// Timer for batching compose reloads to avoid duplicate refreshes +var pendingComposeReloadTimer = null; + +// Schedule processing of pending compose reloads (debounced) +function schedulePendingComposeReloads(ms) { + ms = ms || 500; + if (pendingComposeReloadTimer) { + clearTimeout(pendingComposeReloadTimer); + } + pendingComposeReloadTimer = setTimeout(function() { + processPendingComposeReloads(); + }, ms); +} + +// Process pending compose reloads: update parent rows from cache where possible, +// otherwise fall back to a full composeLoadlist(). This centralizes reloads +// so multiple triggers collapse into a single update. + +function processPendingComposeReloads() { + if (pendingComposeReloadTimer) { + clearTimeout(pendingComposeReloadTimer); + pendingComposeReloadTimer = null; + } + if (!pendingComposeReloadStacks || pendingComposeReloadStacks.length === 0) return; + var reloadStacks = pendingComposeReloadStacks.slice(); + // Clear queue immediately to avoid re-entrancy + pendingComposeReloadStacks = []; + composeLogger('processPendingComposeReloads', { + stacks: reloadStacks.slice() + }, 'user', 'info', 'ui-render'); + + // If any of the target rows are missing from DOM, fallback to full reload + var anyMissing = reloadStacks.some(function(project) { + return $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]').length === 0; + }); + if (anyMissing) { + // Give docker a moment to settle then reload whole list + setTimeout(function() { + composeLoadlist(); + }, 400); + return; + } + + // Fetch fresh container data from server, then update each parent row. + // The cache is stale after compose up/down so we must re-fetch. + reloadStacks.forEach(function(project) { + try { + var stackId = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]').attr('id').replace('stack-row-', ''); + refreshStackRow(stackId, project); + } catch (e) { + composeLogger('update-failed', { + project: project, + err: e.toString() + }, 'user', 'error', 'ui-render'); + } + }); + + // After reload sequence, process any pending update checks. + processPendingUpdateChecks(); +} + +// Helper to refresh a single stack by project name (wrapper for refreshStackRow) +function refreshStackByProject(project) { + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]'); + if ($stackRow.length > 0) { + var stackId = $stackRow.attr('id').replace('stack-row-', ''); + refreshStackRow(stackId, project); + } +} + +// Fetch fresh container data from server and update the parent stack row. +// Unlike updateParentStackFromContainers() which uses stale cache, this +// always makes an AJAX call to get current container states. +function refreshStackRow(stackId, project) { + pendingComposeRefreshCount++; + $.post(caURL, { + action: 'getStackContainers', + script: project + }, function(data) { + if (data) { + try { + composeLogger('response', { + project: project, + data: data + }, 'user', 'info', 'refreshStackRow'); + var response = JSON.parse(data); + if (response.result === 'success') { + var containers = response.containers || []; + // Normalize all containers via factory function (PascalCase→camelCase) + containers = containers.map(createContainerInfo).filter(Boolean); + // Merge saved update status so we don't lose checked info + mergeUpdateStatus(containers, project); + // Update cache with fresh data + stackContainersCache[stackId] = containers; + if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt; + // Now update the row using the fresh cache + updateParentStackFromContainers(stackId, project); + // If details are expanded, refresh them too + // If details are expanded (by JS state or DOM visibility), refresh them + var $detailsRow = $('#details-row-' + stackId); + if (expandedStacks[stackId] || $detailsRow.is(':visible')) { + expandedStacks[stackId] = true; // re-sync JS state with DOM + renderContainerDetails(stackId, containers, project); + if (!$detailsRow.is(':visible')) { + $detailsRow.slideDown(200); + } + } + } + } catch (e) { + composeLogger('parse-error', { + project: project, + err: e.toString() + }, 'user', 'error', 'refreshStackRow'); + // Fallback: update from whatever cache we have + updateParentStackFromContainers(stackId, project); + } + } + pendingComposeRefreshCount = Math.max(0, pendingComposeRefreshCount - 1); + processPendingUpdateChecks(); + }).fail(function() { + // On network failure, fall back to cache-based update + updateParentStackFromContainers(stackId, project); + pendingComposeRefreshCount = Math.max(0, pendingComposeRefreshCount - 1); + processPendingUpdateChecks(); + }); +} + +// Toggle per-stack action-in-progress UI (replace status icon with spinner) +function setStackActionInProgress(stackName, inProgress, text) { + composeLogger('setStackActionInProgress', { + stack: stackName, + inProgress: inProgress, + text: text + }, 'user', 'info', 'stack-action'); + + if (inProgress) { + composeStackActionInProgress[stackName] = true; + } else { + delete composeStackActionInProgress[stackName]; + } + + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]'); + if ($stackRow.length === 0) return; + $stackRow.data('action-in-progress', inProgress ? true : null); + if (!inProgress) { + $stackRow.removeData('action-in-progress'); + } + var $icon = $stackRow.find('.compose-status-icon'); + var $state = $stackRow.find('.state'); + if (inProgress) { + // Save original icon classes so we can restore them later + if (!$icon.data('orig-class')) { + $icon.data('orig-class', $icon.attr('class')); + } + // Use same spinner as containers to keep UI consistent + $icon.removeClass().addClass('fa fa-refresh fa-spin compose-status-spinner compose-status-icon'); + $state.data('orig-text', $state.text()); + if (text) { + $state.text(text); + } else { + $state.text('checking...'); + } + } else { + // Restore original icon classes if we saved them + if ($icon.data('orig-class')) { + $icon.removeClass().addClass($icon.data('orig-class')); + $icon.removeData('orig-class'); + } + // Restore original state text if present + if ($state.data('orig-text')) { + $state.text($state.data('orig-text')); + $state.removeData('orig-text'); + } + } +} + +function UpdateStackConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) { + var stackName = basename(path); + if (pendingUpdateCheckStacks.indexOf(stackName) === -1) { + pendingUpdateCheckStacks.push(stackName); + } + + confirmedComposeAction(path, { + preAction: function(done) { + $.post(caURL, { + action: 'markStackForRecheck', + stacks: JSON.stringify([stackName]) + }, done); + }, + actionName: 'update', + titlePrefix: 'Update', + requestUrl: compURL, + payload: { + action: 'composeUpPullBuild', + path: path, + profile: profile + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: true + }); +} + +function UpdateStack(path, profile = "") { + showStackActionDialog('update', path, profile); +} + +// Start All Stacks function +function startAllStacks() { + var autostartOnly = $('#autostartOnlyToggle').is(':checked'); + var stacks = []; + + // Collect all stacks from the table + $('#compose_stacks tr.compose-sortable').each(function() { + var $row = $(this); + var project = $row.data('project'); + var projectName = $row.data('projectname'); + var path = $row.data('path'); + var isUp = $row.data('isup'); + var autostart = $row.find('.auto_start').is(':checked'); + + // Skip if autostart only mode and autostart is not enabled + if (autostartOnly && !autostart) return; + + // Only include stopped stacks + var $stateEl = $row.find('.state'); + var stateText = $stateEl.text(); + if (stateText === 'stopped' || !isUp) { + stacks.push({ + project: project, + projectName: projectName, + path: path + }); + } + }); + + if (stacks.length === 0) { + swal({ + title: 'No Stacks to Start', + text: autostartOnly ? 'No stopped stacks with Autostart enabled found.' : 'No stopped stacks found.', + type: 'info' + }); + return; + } + + var stackNames = stacks.map(function(s) { + return composeEscapeHtml(s.projectName); + }).join('
'); + var title = autostartOnly ? 'Start Autostart Stacks?' : 'Start All Stacks?'; + var confirmText = autostartOnly ? 'Yes, start ' + stacks.length + ' autostart stack' + (stacks.length > 1 ? 's' : '') : 'Yes, start ' + stacks.length + ' stack' + (stacks.length > 1 ? 's' : ''); + + var bgCheckboxHtml = '
' + + '' + + '' + + '
'; + + getConfig().then(function(pluginCfg) { + var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; + + if (disableWarnings) { + executeStartAllStacks(stacks, bgDefault, bgDefault); + return; + } + + swal({ + title: title, + html: true, + text: '

The following stacks will be started:

' + stackNames + '
' + bgCheckboxHtml + '
', + type: 'warning', + showCancelButton: true, + confirmButtonText: confirmText, + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) { + var runInBackground = $('#swal-run-bg-startall').is(':checked'); + executeStartAllStacks(stacks, runInBackground); + } + }); + + setTimeout(function() { + var $cb = $('#swal-run-bg-startall'); + if ($cb.length) $cb.prop('checked', bgDefault); + }, 50); + }); +} + +function executeStartAllStacks(stacks, background, suppressBackgroundNotification = false) { + var height = 800; + var width = 1200; + + // Create a list of paths to start + var paths = stacks.map(function(s) { + return s.path; + }); + + // Mark stacks for local reload and show per-row spinners + stacks.forEach(function(s) { + var stackName = s.project; + if (pendingComposeReloadStacks.indexOf(stackName) === -1) pendingComposeReloadStacks.push(stackName); + composeLogger('queued', { + stack: stackName, + pending: pendingComposeReloadStacks.slice() + }, 'user', 'info', 'startAllStacks'); + setStackActionInProgress(stackName, true); + }); + + $.post(compURL, { + action: 'composeUpMultiple', + paths: JSON.stringify(paths), + background: background ? 1 : 0 + }, function(data) { + var parsed = tryParseJson(data); + if (parsed && parsed.background) { + notifyBackgroundStarted('Start All Stacks', !suppressBackgroundNotification); + stacks.forEach(function(s) { + pollBackgroundCompletion(s.project); + }); + } else if (data) { + openBox(data, 'Start All Stacks', height, width, true); + } + }); +} + +// Stop All Stacks function +function stopAllStacks() { + var autostartOnly = $('#autostartOnlyToggle').is(':checked'); + var stacks = []; + + // Collect all stacks from the table + $('#compose_stacks tr.compose-sortable').each(function() { + var $row = $(this); + var project = $row.data('project'); + var projectName = $row.data('projectname'); + var path = $row.data('path'); + var isUp = $row.data('isup'); + var autostart = $row.find('.auto_start').is(':checked'); + + // Skip if autostart only mode and autostart is not enabled + if (autostartOnly && !autostart) return; + + // Only include running stacks + var $stateEl = $row.find('.state'); + var stateText = $stateEl.text(); + if (stateText !== 'stopped' && isUp) { + stacks.push({ + project: project, + projectName: projectName, + path: path + }); + } + }); + + if (stacks.length === 0) { + swal({ + title: 'No Stacks to Stop', + text: autostartOnly ? 'No running stacks with Autostart enabled found.' : 'No running stacks found.', + type: 'info' + }); + return; + } + + var stackNames = stacks.map(function(s) { + return composeEscapeHtml(s.projectName); + }).join('
'); + var title = autostartOnly ? 'Stop Autostart Stacks?' : 'Stop All Stacks?'; + var confirmText = autostartOnly ? 'Yes, stop ' + stacks.length + ' autostart stack' + (stacks.length > 1 ? 's' : '') : 'Yes, stop ' + stacks.length + ' stack' + (stacks.length > 1 ? 's' : ''); + + var bgCheckboxHtml = '
' + + '' + + '' + + '
'; + + getConfig().then(function(pluginCfg) { + var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; + + if (disableWarnings) { + executeStopAllStacks(stacks, bgDefault, bgDefault); + return; + } + + swal({ + title: title, + html: true, + text: '

The following stacks will be stopped:

' + stackNames + '

Containers will be stopped and removed. Data in volumes will be preserved.

' + bgCheckboxHtml + '
', + type: 'warning', + showCancelButton: true, + confirmButtonText: confirmText, + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) { + var runInBackground = $('#swal-run-bg-stopall').is(':checked'); + executeStopAllStacks(stacks, runInBackground); + } + }); + + setTimeout(function() { + var $cb = $('#swal-run-bg-stopall'); + if ($cb.length) $cb.prop('checked', bgDefault); + }, 50); + }); +} + +function executeStopAllStacks(stacks, background, suppressBackgroundNotification = false) { + var height = 800; + var width = 1200; + + // Create a list of paths to stop + var paths = stacks.map(function(s) { + return s.path; + }); + + // Mark stacks for local reload and show per-row spinners + stacks.forEach(function(s) { + var stackName = s.project; + if (pendingComposeReloadStacks.indexOf(stackName) === -1) pendingComposeReloadStacks.push(stackName); + composeLogger('queued', { + stack: stackName, + pending: pendingComposeReloadStacks.slice() + }, 'user', 'info', 'stopAllStacks'); + setStackActionInProgress(stackName, true); + }); + + $.post(compURL, { + action: 'composeDownMultiple', + paths: JSON.stringify(paths), + background: background ? 1 : 0 + }, function(data) { + var parsed = tryParseJson(data); + if (parsed && parsed.background) { + notifyBackgroundStarted('Stop All Stacks', !suppressBackgroundNotification); + stacks.forEach(function(s) { + pollBackgroundCompletion(s.project); + }); + } else if (data) { + openBox(data, 'Stop All Stacks', height, width, true); + } + }); +} + +// Helper to merge update status into containers array +// Uses createContainerInfo for consistent name resolution +function mergeUpdateStatus(containers, project) { + if (!containers || !stackUpdateStatus[project] || !stackUpdateStatus[project].containers) { + return containers; + } + containers.forEach(function(container) { + var cInfo = createContainerInfo(container); + stackUpdateStatus[project].containers.forEach(function(update) { + var uInfo = createContainerInfo(update); + if (cInfo.name === uInfo.name) { + container.hasUpdate = uInfo.hasUpdate; + container.updateStatus = uInfo.updateStatus; + container.localSha = uInfo.localSha; + container.remoteSha = uInfo.remoteSha; + } + }); + }); + return containers; +} + +// Unified stack action dialog - handles up, down, and update actions +function showStackActionDialog(action, path, profile) { + var stackName = basename(path); + var project = stackName; + + // Find the stack row (scoped to compose_stacks) + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]'); + var stackId = ''; + var displayName = stackName; // Default to folder name + var hasBuild = false; + if ($stackRow.length > 0) { + stackId = $stackRow.attr('id').replace('stack-row-', ''); + // Get display name from data attribute + displayName = $stackRow.data('projectname') || stackName; + hasBuild = $stackRow.data('hasbuild') == "1"; + } + + // Always use getProfileServices for both profile and no-profile cases + var cachedContainers = (stackId && stackContainersCache[stackId]) ? stackContainersCache[stackId] : []; + composeLogger('profile/unified AJAX start', { + action, + path, + profile, + stackId, + cachedContainers + }, 'user', 'debug', 'showStackActionDialog'); + $.post(caURL, { + action: 'getProfileServices', + script: project, + profiles: profile || '' + }, function(data) { + var profileServices = []; + try { + var response = JSON.parse(data); + if (response.result === 'success') { + profileServices = response.services || []; + } else { + composeLogger('getProfileServices result!=success', { + response, + data + }, 'user', 'error', 'showStackActionDialog'); + } + } catch (e) { + composeLogger('getProfileServices JSON parse error', { + e, + data + }, 'user', 'error', 'showStackActionDialog'); + } + + // Build a lookup of running containers by service name + var containersByService = {}; + cachedContainers.forEach(function(ct) { + var svc = (ct.service || ct.Service || '').toString(); + if (svc) containersByService[svc] = ct; + }); + + // Build the container list: prefer live/runtime container data, + // then fall back to persistent cache, then to a minimal placeholder. + var containers = []; + profileServices.forEach(function(svc) { + var container = containersByService[svc]; + if (!container) { + var persisted = getPersistentContainerInfo(project, svc); + if (persisted) { + container = createContainerInfo(persisted); + } + } + if (!container) { + container = { + service: svc, + name: svc, + state: 'stopped', + image: '' + }; + } + containers.push(container); + }); + + composeLogger('profile/unified AJAX done', { + profileServices, + containers, + cachedContainers + }, 'user', 'debug', 'showStackActionDialog'); + containers = mergeUpdateStatus(containers, project); + renderStackActionDialog(action, displayName, path, profile, containers, hasBuild); + }).fail(function(xhr, status, error) { + // Fallback: if profile resolution fails, show cached container metadata + composeLogger('getProfileServices AJAX fail', { + xhr, + status, + error, + cachedContainers + }, 'user', 'error', 'showStackActionDialog'); + var containers = []; + if (cachedContainers.length > 0) { + containers = mergeUpdateStatus(cachedContainers, project); + } else if (persistentContainerCache[project]) { + containers = Object.keys(persistentContainerCache[project]).map(function(key) { + return createContainerInfo(persistentContainerCache[project][key]); + }); + containers = mergeUpdateStatus(containers, project); + } + renderStackActionDialog(action, displayName, path, profile, containers, hasBuild); + }); + return; +} + +function renderStackActionDialog(action, displayName, path, profile, containers, hasBuild) { + hasBuild = hasBuild || false; + // Action-specific configuration + var pullLabel = hasBuild ? 'Build' : 'Pull'; + var config = { + 'up': { + title: 'Compose Up: ' + composeEscapeHtml(displayName), + description: 'This will create and start all containers in ' + composeEscapeHtml(displayName) + '.', + listTitle: 'CONTAINERS', + warning: 'Images will be pulled if not present locally.', + warningIcon: 'info-circle', + warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-ui-dropdownchecklist-color'), + confirmText: 'Compose Up', + showVersionArrow: false, + confirmedFn: ComposeUpConfirmed + }, + 'down': { + title: 'Compose Down: ' + composeEscapeHtml(displayName), + description: 'This will stop and remove all containers in ' + composeEscapeHtml(displayName) + '.', + listTitle: 'CONTAINERS', + warning: 'Containers will be removed but data in volumes is preserved.', + warningIcon: 'exclamation-triangle', + warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-sb-message-link-color'), + confirmText: 'Compose Down', + showVersionArrow: false, + confirmedFn: ComposeDownConfirmed + }, + 'stop': { + title: 'Compose Stop: ' + composeEscapeHtml(displayName), + description: 'This will stop all containers in ' + composeEscapeHtml(displayName) + ' without removing them.', + listTitle: 'CONTAINERS', + warning: 'Containers will be stopped but not removed. Use Compose Up to start them again.', + warningIcon: 'info-circle', + warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-ui-dropdownchecklist-color'), + confirmText: 'Compose Stop', + showVersionArrow: false, + confirmedFn: ComposeStopConfirmed + }, + 'restart': { + title: 'Compose Restart: ' + composeEscapeHtml(displayName), + description: 'This will restart all containers in ' + composeEscapeHtml(displayName) + '.', + listTitle: 'CONTAINERS', + warning: 'Containers will be recreated. Data in volumes is preserved.', + warningIcon: 'info-circle', + warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-ui-dropdownchecklist-color'), + confirmText: 'Compose Restart', + showVersionArrow: false, + confirmedFn: ComposeRestartConfirmed + }, + 'pull': { + title: pullLabel + ': ' + composeEscapeHtml(displayName), + description: 'This will ' + (hasBuild ? 'build images for' : 'pull the latest images for') + ' ' + composeEscapeHtml(displayName) + ' without starting containers.', + listTitle: 'CONTAINERS', + warning: hasBuild ? 'Images will be built from Dockerfile.' : 'Images will be pulled from the registry.', + warningIcon: 'info-circle', + warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-ui-dropdownchecklist-color'), + confirmText: pullLabel, + showVersionArrow: false, + confirmedFn: ComposePullConfirmed + }, + 'update': { + title: 'Update: ' + composeEscapeHtml(displayName), + description: 'This will pull the latest images and recreate containers in ' + composeEscapeHtml(displayName) + '.', + listTitle: 'CONTAINERS', + warning: 'Running containers will be recreated with the latest images.', + warningIcon: 'exclamation-triangle', + warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--brand-orange') || window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-sb-message-link-color'), + confirmText: 'Update', + showVersionArrow: true, + confirmedFn: UpdateStackConfirmed + }, + 'forceUpdate': { + title: 'Force Update: ' + composeEscapeHtml(displayName), + description: 'This will pull the latest images and rebuild containers in ' + composeEscapeHtml(displayName) + ', even if no updates are detected.', + listTitle: 'CONTAINERS', + warning: 'All containers will be recreated with freshly pulled images.', + warningIcon: 'exclamation-triangle', + warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--brand-orange') || window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-sb-message-link-color'), + confirmText: 'Force Update', + showVersionArrow: false, + confirmedFn: ForceUpdateStackConfirmed + } + }; + var cfg = config[action]; + if (!cfg) return; + + // Build HTML content for the dialog + var html = '
'; + html += '
' + cfg.description + '
'; + + // Container list with icons + if (containers && containers.length > 0) { + html += '
'; + html += '
' + cfg.listTitle + '
'; + + containers.forEach(function(container, index) { + var containerName = container.name || container.service || 'Unknown'; + var shortName = container.service || containerName.replace(/^[^-]+-/, ''); + var image = container.image || ''; + var imageParts = image.split(':'); + var imageName = imageParts[0].split('/').pop(); + var imageTag = imageParts[1] || 'latest'; + var state = container.state || 'unknown'; + var stateClass = state === 'running' ? 'compose-status-success' : (state === 'paused' ? 'compose-status-warning' : 'compose-text-muted'); + var stateIcon = state === 'running' ? 'play' : (state === 'paused' ? 'pause' : 'square'); + + // Check if this container has an update available + var hasUpdate = container.hasUpdate || false; + var updateStatus = container.updateStatus || 'unknown'; + var localSha = container.localSha || ''; + var remoteSha = container.remoteSha || ''; + + var iconSrc = (container.icon && isValidIconSrc(container.icon)) ? + composeEscapeAttr(container.icon) : + '/plugins/dynamix.docker.manager/images/question.png'; + + // Grey out containers without updates when showing update dialog + var rowOpacity = (cfg.showVersionArrow && !hasUpdate && updateStatus === 'up-to-date') ? '0.5' : '1'; + var isLast = (index === containers.length - 1); + var borderStyle = isLast ? '' : 'border-bottom:1px solid var(--dynamix-box-inner-div-border-color);'; + + html += '
'; + html += ''; + html += '
'; + html += '
' + composeEscapeHtml(shortName); + // Show update badge if update is available (for update action) + if (cfg.showVersionArrow && hasUpdate) { + html += ' UPDATE'; + } else if (cfg.showVersionArrow && updateStatus === 'up-to-date') { + html += ' '; + } + html += '
'; + html += '
'; + html += ''; + html += composeEscapeHtml(imageName) + ' : ' + composeEscapeHtml(imageTag) + ''; + + // Show SHA info for update action + if (cfg.showVersionArrow) { + if (hasUpdate && localSha && remoteSha) { + // Has update - show current SHA → new SHA + html += '
'; + html += '' + composeEscapeHtml(localSha.substring(0, 8)) + ''; + html += ' '; + html += '' + composeEscapeHtml(remoteSha.substring(0, 8)) + ''; + html += '
'; + } else if (localSha) { + // No update - just show current SHA (greyed) + html += '
' + composeEscapeHtml(localSha.substring(0, 8)) + '
'; + } + } + html += '
'; + }); + + html += '
'; + } + + // Warning/info text + html += '
' + cfg.warning + '
'; + + // Run-in-background checkbox (appended after config is fetched below) + var bgCheckboxHtml = '
' + + '' + + '' + + '
'; + html += bgCheckboxHtml; + html += '
'; + + // Fetch config to determine default checkbox state, then show swal (or skip warnings) + getConfig().then(function(pluginCfg) { + var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; + + if (disableWarnings) { + // In default background mode (warnings disabled and background enabled), don't show toast if background is used + cfg.confirmedFn(path, profile, bgDefault, bgDefault); + return; + } + + // Use native swal (SweetAlert 1.x) with callback style + swal({ + title: cfg.title, + text: html, + html: true, + type: 'warning', + showCancelButton: true, + confirmButtonText: cfg.confirmText, + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) { + // Capture checkbox state before swal destroys the DOM + var runInBackground = $('#swal-run-bg-checkbox').is(':checked'); + // when running in background, suppress the extra notifyBackgroundStarted popup + cfg.confirmedFn(path, profile, runInBackground, runInBackground); + } + }); + + // Set checkbox default state after swal renders (small delay for DOM) + setTimeout(function() { + var $cb = $('#swal-run-bg-checkbox'); + if ($cb.length) { + $cb.prop('checked', bgDefault); + } + }, 50); + }); +} + +function ViewLastCmdLog(project, displayName) { + $.post(caURL, { + action: 'getLastCmdLog', + script: project + }, function(response) { + var parsed = tryParseJson(response); + if (!parsed || parsed.result !== 'success') { + swal({ + title: 'Error', + text: 'Could not retrieve the log.', + type: 'error' + }); + return; + } + if (!parsed.log) { + swal({ + title: 'No log available', + text: 'No command log has been saved for ' + composeEscapeHtml(displayName) + ' yet.\nRun a command to generate one.', + type: 'info' + }); + return; + } + // Show log in a swal with a scrollable pre block + var logHtml = '
' + + '
' +
+            composeEscapeHtml(parsed.log) + '
'; + swal({ + title: 'Last Cmd Log: ' + composeEscapeHtml(displayName), + text: logHtml, + html: true, + type: null, + confirmButtonText: 'Close' + }); + }); +} + +function ComposePullConfirmed(path, profile = "", background = false, suppressBackgroundNotification = false) { + var stackName = basename(path); + performComposeAction({ + stackName: stackName, + actionName: 'pull', + title: 'Compose Pull: ' + stackName, + requestUrl: compURL, + payload: { + action: 'composePull', + path: path, + profile: profile + }, + background: background, + suppressBackgroundNotification: suppressBackgroundNotification, + pendingReload: true + }); +} + +function ComposePull(path, profile = "") { + showStackActionDialog('pull', path, profile); +} + +function ComposeLogs(pathOrProject, profile = "") { + var height = Math.min(screen.availHeight, 800); + var width = Math.min(screen.availWidth, 1200); + // Support both project name (legacy) and path + var path = pathOrProject.includes('/') ? pathOrProject : compose_root + "/" + pathOrProject; + $.post(compURL, { + action: 'composeLogs', + path: path, + profile: profile + }, function(data) { + if (data) { + window.open(data, 'Logs_' + basename(path), + 'height=' + height + ',width=' + width + ',resizable=yes,scrollbars=yes'); + } + }) +} + +// ============================================ +// Stack Actions Menu Functions +// ============================================ +var currentStackId = null; +var expandedStacks = {}; +var stackContainersCache = {}; +var persistentContainerCache = {}; // Persistent cache loaded from disk +var stackStartedAtCache = {}; // Cache for stack-level started_at timestamps +// Track stacks currently loading details to prevent concurrent reloads +var stackDetailsLoading = {}; +// Suppress immediate refresh after a render to avoid loops +var stackDetailsJustRendered = {}; + +function openStackActionsMenu(event, stackId) { + event.stopPropagation(); + currentStackId = stackId; + + var $row = $('#stack-row-' + stackId); + var projectName = $row.data('projectname'); + var isUp = $row.data('isup') == "1"; + + // Update modal title + $('.stack-actions-modal-title').text(projectName); + + // Show/hide certain actions based on state + // Delete is disabled when stack is running + var $deleteBtn = $('.stack-action-item:contains("Delete Stack")'); + if (isUp) { + $deleteBtn.addClass('disabled').prop('disabled', true); + } else { + $deleteBtn.removeClass('disabled').prop('disabled', false); + } + + // Position and show modal + var $modal = $('#stack-actions-modal'); + var $overlay = $('#stack-actions-overlay'); + + // Get button position for modal placement + var $btn = $('#kebab-' + stackId); + var btnOffset = $btn.offset(); + var btnHeight = $btn.outerHeight(); + + // Position modal near the button + $modal.css({ + top: btnOffset.top + btnHeight + 5, + right: $(window).width() - btnOffset.left - $btn.outerWidth() + }); + + $overlay.show(); + $modal.show(); +} + +function closeStackActionsMenu() { + $('#stack-actions-modal').hide(); + $('#stack-actions-overlay').hide(); + currentStackId = null; +} + +function executeStackAction(action) { + if (!currentStackId) return; + + var $row = $('#stack-row-' + currentStackId); + var project = $row.data('project'); + var projectName = $row.data('projectname'); + var path = $row.data('path'); + var profiles = $row.data('profiles') || []; + var isUp = $row.data('isup') == "1"; + + closeStackActionsMenu(); + + // Handle profile selection if profiles exist and action supports it + var profileSupportedActions = ['up', 'down', 'update', 'pull', 'logs']; + if (profiles.length > 0 && profileSupportedActions.includes(action)) { + showProfileSelector(action, path, profiles); + return; + } + + switch (action) { + case 'up': + ComposeUp(path); + break; + case 'down': + ComposeDown(path); + break; + case 'update': + UpdateStack(path); + break; + case 'pull': + ComposePull(path); + break; + case 'logs': + ComposeLogs(path); + break; + case 'viewCmdLog': + ViewLastCmdLog(project, projectName); + break; + case 'edit': + openEditorModalByProject(project, projectName); + break; + case 'delete': + if (!isUp) { + deleteStackByProject(project, projectName); + } + break; + } +} + +function showProfileSelector(action, path, profiles) { + var actionNames = { + 'up': 'Compose Up', + 'down': 'Compose Down', + 'stop': 'Compose Stop', + 'restart': 'Compose Restart', + 'update': 'Update', + 'forceUpdate': 'Force Update', + 'pull': 'Compose Pull', + 'logs': 'Compose Logs' + }; + + // Build profile selection UI: + // - Default services (no profile) are always included and non-toggleable. + // - "All profile-based services" enables every profile via "*". + // - Individual profiles can be multi-selected when all-profiles is off. + var profileHtml = '
'; + profileHtml += '
'; + profileHtml += ''; + profileHtml += '
'; + profileHtml += '
'; + profileHtml += ''; + profileHtml += '
'; + profileHtml += '
'; + profiles.forEach(function(profile) { + profileHtml += ''; + }); + profileHtml += '
'; + profileHtml += '
Default services are always included. Select multiple profiles to include profile-based services.
'; + profileHtml += '
'; + + swal({ + title: "Select Profiles", + text: "Choose which profiles to use for " + actionNames[action] + "

" + profileHtml, + html: true, + showCancelButton: true, + confirmButtonText: "Continue", + cancelButtonText: "Cancel" + }, function(confirmed) { + if (confirmed) { + var selectedProfiles = []; + if (!$('#profile_all_profiles').is(':checked')) { + $('.profile_checkbox:checked').each(function() { + selectedProfiles.push($(this).val()); + }); + } + // Use "*" when all profile-based services are requested. + // Empty profile string means default services only. + var profileStr = $('#profile_all_profiles').is(':checked') ? '*' : selectedProfiles.join(','); + switch (action) { + case 'up': + ComposeUp(path, profileStr); + break; + case 'down': + ComposeDown(path, profileStr); + break; + case 'stop': + ComposeStop(path, profileStr); + break; + case 'restart': + ComposeRestart(path, profileStr); + break; + case 'update': + UpdateStack(path, profileStr); + break; + case 'forceUpdate': + ForceUpdateStack(path, profileStr); + break; + case 'pull': + ComposePull(path, profileStr); + break; + case 'logs': + ComposeLogs(path, profileStr); + break; + } + } + }); +} + +// Toggle individual profile checkboxes when all-profile scope is enabled/disabled +function toggleAllProfiles(checkbox) { + var disabled = checkbox.checked; + $('.profile_checkbox').prop('disabled', disabled).prop('checked', false); +} + +function openEditorModalByProject(project, projectName, initialTab) { + if (typeof ace === 'undefined') { + swal({ + title: 'Editor Unavailable', + text: 'The Ace editor library could not be loaded. Please reload the page or verify the plugin installation.', + type: 'error' + }); + return; + } + + editorModal.currentProject = project; + editorModal.currentProjectName = projectName || project; + editorModal.modifiedTabs = new Set(); + editorModal.modifiedSettings = new Set(); + editorModal.modifiedLabels = new Set(); + editorModal.originalContent = {}; + editorModal.originalSettings = {}; + editorModal.originalLabels = {}; + editorModal.labelsData = null; + editorModal.labelsViewMode = 'basic'; + editorModal.pendingLabelsViewMode = 'basic'; + editorModal.filePaths = { + stackMeta: compose_root + '/' + project, + compose: '', + env: '', + projectOverride: compose_root + '/' + project + '/compose.override.yaml', + effectiveOverride: '' + }; + $('#settings-override-management').prop('checked', true); + $('#settings-override-management-label').text('Automatic'); + + // Reset all tabs to unmodified state + $('.editor-tab').removeClass('modified active'); + $('.editor-main-tab').removeClass('modified active'); + $('.editor-container').removeClass('active'); + $('.editor-panel').removeClass('active'); + + // Reset labels tab to basic immediately; stack-specific mode arrives from getStackSettings. + toggleLabelsViewMode(false, true); + if (editorModal.editors['override']) { + editorModal.editors['override'].setValue('', -1); + } + $('#labels-override-empty-state').hide(); + $('#labels-override-editor-wrap').show(); + $('#editor-validation-override').html(' Ready').removeClass('valid error warning'); + $('#env-empty-state').hide(); + $('#env-editor-wrap').show(); + + // Set modal title + $('#editor-modal-title').text('Editing: ' + projectName); + updateEditorFileInfo(); + + // Ensure overlay is in top-level document layer for tabbed mode + // Appending to body makes the modal full-screen and avoids nested overflow limitations. + $('#editor-modal-overlay').appendTo('body').addClass('active'); + $('#editor-validation-compose').html(' Loading files...').removeClass('valid error warning'); + + // Load all files and settings + loadEditorFiles(project); + loadSettingsData(project, projectName); + + // Switch to appropriate initial tab (default to 'compose') + var targetTab = initialTab || 'compose'; + switchTab(targetTab); + setTimeout(function() { + refreshEditorContents(targetTab); + refreshEditorContents('compose'); + refreshEditorContents('env'); + }, 100); +} + +function loadEditorFiles(project) { + var loadPromises = []; + + // Load compose file + loadPromises.push( + $.post(caURL, { + action: 'getYml', + script: project + }).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.filePaths.compose = response.fileName || ''; + editorModal.originalContent['compose'] = response.content || ''; + if (editorModal.editors['compose']) editorModal.editors['compose'].setValue(response.content || '', -1); + updateEditorFileInfo(); + } + }).fail(function() { + var errorContent = '# Error loading file'; + editorModal.originalContent['compose'] = errorContent; + if (editorModal.editors['compose']) editorModal.editors['compose'].setValue(errorContent, -1); + }) + ); + + // Load env file + loadPromises.push( + $.post(caURL, { + action: 'getEnv', + script: project + }).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.filePaths.env = response.fileName || ''; + editorModal.envExists = response.exists === true; + if (editorModal.envExists) { + $('#env-empty-state').hide(); + $('#env-editor-wrap').show(); + editorModal.originalContent['env'] = response.content || ''; + if (editorModal.editors['env']) editorModal.editors['env'].setValue(response.content || '', -1); + } else { + $('#env-empty-state').css('display', 'flex'); + $('#env-editor-wrap').hide(); + editorModal.originalContent['env'] = ''; + if (editorModal.editors['env']) editorModal.editors['env'].setValue('', -1); + } + updateEditorFileInfo(); + } + }).fail(function() { + var errorContent = '# Error loading file'; + editorModal.originalContent['env'] = errorContent; + if (editorModal.editors['env']) editorModal.editors['env'].setValue(errorContent, -1); + }) + ); + + // When all files are loaded + $.when.apply($, loadPromises).then(function() { + // Run validation on compose file + var composeContent = editorModal.editors['compose'] ? editorModal.editors['compose'].getValue() : (editorModal.originalContent['compose'] || ''); + validateYaml('compose', composeContent); + }).fail(function() { + $('#editor-validation-compose').html(' Error loading some files').removeClass('valid').addClass('error'); + }); +} + +// Load settings data into the settings panel +function loadSettingsData(project, projectName) { + // Set the name from projectName (display name) + $('#settings-name').val(projectName || ''); + editorModal.originalSettings['name'] = projectName || ''; + editorModal.currentProjectName = projectName || project; + + // Load description + $.post(caURL, { + action: 'getDescription', + script: project + }).then(function(data) { + if (data) { + try { + var response = JSON.parse(data); + var desc = (response.content || '').replace(/
/g, '\n'); + $('#settings-description').val(desc); + editorModal.originalSettings['description'] = desc; + } catch (e) { + $('#settings-description').val(''); + editorModal.originalSettings['description'] = ''; + } + } + }).fail(function() { + $('#settings-description').val(''); + editorModal.originalSettings['description'] = ''; + }); + + // Load stack settings (icon URL and env path) + $.post(caURL, { + action: 'getStackSettings', + script: project + }).then(function(data) { + if (data) { + try { + var response = JSON.parse(data); + } catch (e) { + return; + } + if (response.result === 'success') { + // Icon URL + var iconUrl = response.iconUrl || ''; + $('#settings-icon-url').val(iconUrl); + editorModal.originalSettings['icon-url'] = iconUrl; + if (iconUrl && isValidIconSrc(iconUrl)) { + $('#settings-icon-preview-img').attr('src', iconUrl); + $('#settings-icon-preview').show(); + } else { + $('#settings-icon-preview').hide(); + } + + // WebUI URL + var webuiUrl = response.webuiUrl || ''; + $('#settings-webui-url').val(webuiUrl); + editorModal.originalSettings['webui-url'] = webuiUrl; + + // Detect suggested WebUI URL + $('#settings-webui-suggestion').hide(); + $.post(caURL, { + action: 'detectWebui', + script: project + }).then(function(detectData) { + try { + var dr = JSON.parse(detectData); + } catch (e) { + return; + } + if (dr.result === 'success' && dr.detected && dr.detected.url) { + var currentVal = $('#settings-webui-url').val(); + if (currentVal === dr.detected.url) return; // already set to detected value + $('#settings-webui-detected-url').text(dr.detected.url); + $('#settings-webui-detected-source').text('(from ' + dr.detected.source + ')'); + $('#settings-webui-suggestion').show(); + $('#settings-webui-use-btn').off('click').on('click', function() { + $('#settings-webui-url').val(dr.detected.url).trigger('input'); + $('#settings-webui-suggestion').hide(); + }); + } + }); + + // ENV path + var envPath = response.envPath || ''; + $('#settings-env-path').val(envPath); + editorModal.originalSettings['env-path'] = envPath; + + // External compose path + var externalComposePath = response.externalComposePath || ''; + var externalComposeFilePath = response.externalComposeFilePath || ''; + var invalidIndirectPath = response.invalidIndirectPath || ''; + var indirectMode = response.indirectMode || ''; + if (!externalComposePath && !externalComposeFilePath && invalidIndirectPath) { + // Pre-populate with the broken path so the user can fix it + if (indirectMode === 'file') { + $('#settings-external-compose-path').val(''); + $('#settings-external-compose-file').val(invalidIndirectPath); + editorModal.originalSettings['external-compose-path'] = ''; + editorModal.originalSettings['external-compose-file'] = ''; + editorModal.modifiedSettings.add('external-compose-file'); + } else { + $('#settings-external-compose-path').val(invalidIndirectPath); + $('#settings-external-compose-file').val(''); + editorModal.originalSettings['external-compose-path'] = ''; + editorModal.originalSettings['external-compose-file'] = ''; + editorModal.modifiedSettings.add('external-compose-path'); + } + $('#settings-invalid-indirect-warning').show(); + $('#settings-external-compose-info').hide(); + } else { + $('#settings-external-compose-path').val(externalComposePath); + $('#settings-external-compose-file').val(externalComposeFilePath); + editorModal.originalSettings['external-compose-path'] = externalComposePath; + editorModal.originalSettings['external-compose-file'] = externalComposeFilePath; + $('#settings-invalid-indirect-warning').hide(); + if (externalComposePath || externalComposeFilePath) { + $('#settings-external-compose-info').show(); + } else { + $('#settings-external-compose-info').hide(); + } + } + + // Default profile + var defaultProfile = response.defaultProfile || ''; + $('#settings-default-profile').val(defaultProfile); + editorModal.originalSettings['default-profile'] = defaultProfile; + + // Compose file discovery mode + var useDefaultComposeFiles = response.useDefaultComposeFiles === true; + $('#settings-use-default-compose-files').prop('checked', useDefaultComposeFiles); + editorModal.originalSettings['use-default-compose-files'] = useDefaultComposeFiles ? 'true' : 'false'; + updateSettingsDefaultComposeDiscoveryState(true); + + editorModal.filePaths.stackMeta = response.projectPath || (compose_root + '/' + project); + editorModal.filePaths.projectOverride = response.projectOverridePath || (compose_root + '/' + project + '/compose.override.yaml'); + editorModal.filePaths.effectiveOverride = response.effectiveOverridePath || editorModal.filePaths.projectOverride; + updateEditorFileInfo(); + + // Labels editor mode (per-stack) + var labelsViewMode = response.labelsViewMode === 'advanced' ? 'advanced' : 'basic'; + editorModal.labelsViewMode = labelsViewMode; + editorModal.pendingLabelsViewMode = labelsViewMode; + editorModal.originalSettings['labels-view-mode'] = labelsViewMode; + $('#editor-tab-labels-text').text(labelsViewMode === 'advanced' ? 'Override' : 'Labels'); + + // Always sync settings control state, even when Labels tab is not active. + $('#settings-override-management').prop('checked', labelsViewMode !== 'advanced'); + $('#settings-override-management-label').text(labelsViewMode === 'advanced' ? 'Manual' : 'Automatic'); + + if (editorModal.currentProject === project && editorModal.currentTab === 'labels') { + toggleLabelsViewMode(labelsViewMode === 'advanced', true); + } + + // Available profiles (from the profiles file) + var availableProfiles = response.availableProfiles || []; + if (availableProfiles.length > 0) { + $('#settings-profiles-list').text(availableProfiles.join(', ')); + $('#settings-available-profiles').show(); + } else { + $('#settings-available-profiles').hide(); + } + } + } + }).fail(function() { + $('#settings-icon-url').val(''); + $('#settings-webui-url').val(''); + $('#settings-env-path').val(''); + $('#settings-default-profile').val(''); + $('#settings-external-compose-path').val(''); + $('#settings-external-compose-file').val(''); + $('#settings-use-default-compose-files').prop('checked', false); + updateSettingsDefaultComposeDiscoveryState(true); + editorModal.originalSettings['icon-url'] = ''; + editorModal.originalSettings['webui-url'] = ''; + editorModal.originalSettings['env-path'] = ''; + editorModal.originalSettings['default-profile'] = ''; + editorModal.originalSettings['external-compose-path'] = ''; + editorModal.originalSettings['external-compose-file'] = ''; + editorModal.originalSettings['use-default-compose-files'] = 'false'; + editorModal.originalSettings['labels-view-mode'] = 'basic'; + editorModal.labelsViewMode = 'basic'; + editorModal.pendingLabelsViewMode = 'basic'; + editorModal.filePaths.stackMeta = compose_root + '/' + project; + editorModal.filePaths.projectOverride = compose_root + '/' + project + '/compose.override.yaml'; + editorModal.filePaths.effectiveOverride = editorModal.filePaths.projectOverride; + $('#editor-tab-labels-text').text('Labels'); + $('#settings-override-management').prop('checked', true); + $('#settings-override-management-label').text('Automatic'); + $('#settings-icon-preview').hide(); + $('#settings-available-profiles').hide(); + updateEditorFileInfo(); + $('#settings-external-compose-info').hide(); + $('#settings-invalid-indirect-warning').hide(); + }); +} + +// Toggle labels tab between basic (form) and advanced (override editor) modes +function toggleLabelsViewMode(isAdvanced, skipPersist) { + editorModal.labelsViewMode = isAdvanced ? 'advanced' : 'basic'; + updateEditorFileInfo(); + + if (isAdvanced) { + $('#labels-basic-view').hide(); + $('#labels-advanced-view').show(); + $('#editor-tab-labels-text').text('Override'); + + // Pre-populate override editor if labelsData is available (loaded via loadLabelsData) + // If not yet loaded, load it now + if (!editorModal.labelsData) { + loadLabelsData(function() { + _populateOverrideEditor(); + }); + } else { + _populateOverrideEditor(); + } + } else { + $('#labels-advanced-view').hide(); + $('#labels-basic-view').show(); + $('#editor-tab-labels-text').text('Labels'); + + // If the override editor has unsaved changes, warn before switching back + // (already tracked in modifiedTabs so save button will prompt) + } +} + +// Populate the override Ace editor with the current override file content +function _populateOverrideEditor() { + if (!editorModal.editors['override']) return; + var hasOverride = !!(editorModal.labelsData && editorModal.labelsData.overrideExists); + var content = (editorModal.labelsData && editorModal.labelsData.overrideContent) || ''; + + if (!hasOverride) { + $('#labels-override-empty-state').css('display', 'flex'); + $('#labels-override-editor-wrap').hide(); + if (!editorModal.modifiedTabs.has('override')) { + editorModal.originalContent['override'] = ''; + editorModal.editors['override'].setValue('', -1); + } + return; + } + + $('#labels-override-empty-state').hide(); + $('#labels-override-editor-wrap').show(); + + // Hydrate editor from override file unless there are unsaved override edits. + if (!editorModal.modifiedTabs.has('override')) { + editorModal.originalContent['override'] = content; + editorModal.editors['override'].setValue(content, -1); + } + setTimeout(function() { + try { + editorModal.editors['override'].resize(); + editorModal.editors['override'].renderer.updateFull(); + editorModal.editors['override'].focus(); + } catch(e) {} + }, 50); +} + +// Load labels data for the WebUI Labels panel +function loadLabelsData(callback) { + var project = editorModal.currentProject; + if (!project) return; + + $('#labels-services-container').html('
Loading services...
'); + + // Load both compose file and override file to build the labels UI + var composePromise = $.post(caURL, { + action: 'getYml', + script: project + }); + var overridePromise = $.post(caURL, { + action: 'getOverride', + script: project + }); + + $.when(composePromise, overridePromise).then(function(composeResult, overrideResult) { + try { + var composeData = JSON.parse(composeResult[0]); + var overrideData = JSON.parse(overrideResult[0]); + + if (composeData.result !== 'success') { + throw new Error('Failed to load compose file'); + } + + var mainDoc = loadComposeYaml(composeData.content) || { + services: {} + }; + var overrideDoc = loadComposeYaml(overrideData.content || '') || { + services: {} + }; + + // Ensure override has services object + if (!overrideDoc.services) { + overrideDoc.services = {}; + } + + editorModal.labelsData = { + mainDoc: mainDoc, + overrideDoc: overrideDoc, + overrideContent: overrideData.content || '', + overrideHasCustomTags: composeYamlContainsCustomTags(overrideData.content || ''), + overrideExists: overrideData.exists === true + }; + editorModal.filePaths.compose = composeData.fileName || editorModal.filePaths.compose; + editorModal.filePaths.effectiveOverride = overrideData.fileName || editorModal.filePaths.effectiveOverride; + updateEditorFileInfo(); + + // Pre-cache override content for the override editor (only if not yet set for this project) + if (editorModal.originalContent['override'] === undefined) { + editorModal.originalContent['override'] = overrideData.content || ''; + } + + renderLabelsUI(mainDoc, overrideDoc); + + if (typeof callback === 'function') { + callback(); + } + + } catch (e) { + composeLogger('Failed to parse compose files for labels', { + error: e && e.toString() + }, 'user', 'error', 'loadLabelsData'); + $('#labels-services-container').html('
Error loading services: ' + composeEscapeHtml(e.message) + '
'); + } + }).fail(function() { + $('#labels-services-container').html('
Failed to load compose files
'); + }); +} + +function createOverrideTemplate() { + var project = editorModal.currentProject; + if (!project) return; + + if (enforcePathSettingsExclusivity('creating override templates')) { + return; + } + + $('#labels-override-empty-state button').prop('disabled', true); + + $.post(caURL, { + action: 'createOverrideTemplate', + script: project, + expectedPath: editorModal.filePaths.effectiveOverride || editorModal.filePaths.projectOverride || '' + }).done(function(data) { + try { + if (!data) { + throw new Error('Empty server response'); + } + + var response = JSON.parse(data); + if (response.result !== 'success') { + if (handleStaleEditorResponse(response, 'Override file location changed while this dialog was open. Reloading the editor.')) { + return; + } + throw new Error(response.message || 'Failed to create override template.'); + } + + editorModal.labelsData = editorModal.labelsData || {}; + editorModal.labelsData.overrideExists = true; + editorModal.labelsData.overrideContent = response.content || ''; + editorModal.filePaths.effectiveOverride = response.fileName || editorModal.filePaths.effectiveOverride; + editorModal.originalContent['override'] = response.content || ''; + editorModal.modifiedTabs.delete('override'); + + if (editorModal.editors['override']) { + editorModal.editors['override'].setValue(response.content || '', -1); + } + + _populateOverrideEditor(); + updateEditorFileInfo(); + validateYaml('override', response.content || ''); + updateTabModifiedState(); + updateSaveButtonState(); + } catch (error) { + swal({ + title: 'Create Failed', + text: error && error.message ? error.message : 'Failed to create override template.', + type: 'error' + }); + } + }).fail(function() { + swal({ + title: 'Create Failed', + text: 'Failed to create override template.', + type: 'error' + }); + }).always(function() { + $('#labels-override-empty-state button').prop('disabled', false); + }); +} + +function createEnvTemplate() { + var project = editorModal.currentProject; + if (!project) return; + + if (enforcePathSettingsExclusivity('creating .env templates')) { + return; + } + + $('#env-empty-state button').prop('disabled', true); + + $.post(caURL, { + action: 'createEnvTemplate', + script: project, + expectedPath: editorModal.filePaths.env || '' + }).done(function(data) { + try { + if (!data) throw new Error('Empty server response'); + var response = JSON.parse(data); + if (response.result !== 'success') { + if (handleStaleEditorResponse(response, '.env file location changed while this dialog was open. Reloading the editor.')) { + return; + } + throw new Error(response.message || 'Failed to create .env template.'); + } + + editorModal.envExists = true; + editorModal.filePaths.env = response.fileName || editorModal.filePaths.env; + editorModal.originalContent['env'] = response.content || ''; + editorModal.modifiedTabs.delete('env'); + + if (editorModal.editors['env']) { + editorModal.editors['env'].setValue(response.content || '', -1); + } + + $('#env-empty-state').hide(); + $('#env-editor-wrap').show(); + updateEditorFileInfo(); + updateTabModifiedState(); + updateSaveButtonState(); + } catch (error) { + swal({ + title: 'Create Failed', + text: error && error.message ? error.message : 'Failed to create .env template.', + type: 'error' + }); + } + }).fail(function() { + swal({ + title: 'Create Failed', + text: 'Failed to create .env template.', + type: 'error' + }); + }).always(function() { + $('#env-empty-state button').prop('disabled', false); + }); +} + + +// Render the WebUI Labels UI +function renderLabelsUI(mainDoc, overrideDoc) { + var html = ''; + var deletedHtml = ''; + var hasServices = false; + var hasDeletedServices = false; + + // Process services from main compose file + for (var serviceKey in mainDoc.services) { + hasServices = true; + var service = mainDoc.services[serviceKey]; + var overrideService = overrideDoc.services[serviceKey] || { + labels: {} + }; + + // Ensure override service has proper structure + if (!overrideService.labels) { + overrideDoc.services[serviceKey] = overrideDoc.services[serviceKey] || {}; + overrideDoc.services[serviceKey].labels = overrideDoc.services[serviceKey].labels || {}; + overrideDoc.services[serviceKey].labels[managed_label] = managed_label_name; + overrideService = overrideDoc.services[serviceKey]; + } + + var containerName = service.container_name || serviceKey; + var iconValue = findLabelValue(overrideService, service, icon_label); + var webuiValue = findLabelValue(overrideService, service, webui_label); + var shellValue = findLabelValue(overrideService, service, shell_label); + + // Store original values + editorModal.originalLabels[serviceKey + '_icon'] = iconValue; + editorModal.originalLabels[serviceKey + '_webui'] = webuiValue; + editorModal.originalLabels[serviceKey + '_shell'] = shellValue; + + var iconSrc = iconValue || '/plugins/dynamix.docker.manager/images/question.png'; + html += '
'; + html += '
'; + html += ''; + html += '' + composeEscapeHtml(containerName) + ''; + html += '
'; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += '
'; + } + + // Check for orphaned services in override that aren't in main (e.g., after rename) + for (var serviceKey in overrideDoc.services) { + if (!(serviceKey in mainDoc.services)) { + hasDeletedServices = true; + var overrideService = overrideDoc.services[serviceKey]; + var containerName = (overrideService && overrideService.container_name) || serviceKey; + var iconValue = findLabelValue(overrideService, {}, icon_label); + var webuiValue = findLabelValue(overrideService, {}, webui_label); + var shellValue = findLabelValue(overrideService, {}, shell_label); + + var deletedIconSrc = iconValue || '/plugins/dynamix.docker.manager/images/question.png'; + deletedHtml += '
'; + deletedHtml += '
'; + deletedHtml += ''; + deletedHtml += '' + composeEscapeHtml(containerName) + ' (will be removed on save)'; + deletedHtml += '
'; + deletedHtml += '
'; + deletedHtml += '
'; + deletedHtml += '
'; + deletedHtml += '
'; + deletedHtml += '
'; + deletedHtml += '
'; + } + } + + if (!hasServices) { + html = '
No services defined in compose file
'; + } + + if (hasDeletedServices) { + html += '
'; + html += '
Orphaned Services (copy values before saving)
'; + html += '
' + deletedHtml + '
'; + html += '
'; + } + + $('#labels-services-container').html(html); + + // Attach file tree picker to container icon inputs + if ($.fn.fileTreeAttach && typeof composeBindFileTreeInputs === 'function') { + var $iconInputs = $('#labels-services-container').find('input[data-pickroot]'); + composeBindFileTreeInputs($iconInputs, { + zIndex: 100010, + minWidth: 320, + addClass: true + }); + } + + // Attach change handlers to label inputs + $('#labels-services-container').find('input[data-service]').on('input', function() { + var service = $(this).data('service'); + var field = $(this).data('field'); + var key = service + '_' + field; + var currentValue = $(this).val(); + var originalValue = editorModal.originalLabels[key] || ''; + + if (currentValue !== originalValue) { + editorModal.modifiedLabels.add(key); + } else { + editorModal.modifiedLabels.delete(key); + } + + // Live icon preview with debounce + if (field === 'icon') { + var $input = $(this); + clearTimeout($input.data('iconDebounce')); + $input.data('iconDebounce', setTimeout(function() { + var iconUrl = $input.val().trim(); + var $preview = $('#label-icon-preview-' + service); + if (iconUrl && isValidIconSrc(iconUrl)) { + $preview.attr('src', iconUrl); + } else { + $preview.attr('src', '/plugins/dynamix.docker.manager/images/question.png'); + } + }, 300)); + } + + updateSaveButtonState(); + updateTabModifiedState(); + }); +} + +// Helper to find label value from override or main service +function findLabelValue(overrideService, mainService, labelKey) { + if (overrideService && overrideService.labels && overrideService.labels[labelKey]) { + return overrideService.labels[labelKey]; + } + if (mainService && mainService.labels && mainService.labels[labelKey]) { + return mainService.labels[labelKey]; + } + return ''; +} + +// Toggle deleted services visibility +function toggleDeletedServices(el) { + var $title = $(el); + var $services = $title.next('.labels-deleted-services'); + $title.toggleClass('expanded'); + $services.toggleClass('visible'); +} + +function validateYaml(type, content) { + if (type === 'env') { + // Basic validation for env files + updateValidation(type, content); + return; + } + + try { + if (content.trim()) { + loadComposeYaml(content); + } + updateValidation(type, content, true); + } catch (e) { + updateValidation(type, content, false, e.message); + } +} + +function updateValidation(type, content, isValid, errorMsg) { + var validationEl = $('#editor-validation-' + type); + + // Handle env files separately (no YAML validation needed) + if (type === 'env') { + var lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')); + validationEl.html(' ' + lines.length + ' environment variable(s)'); + validationEl.removeClass('error warning').addClass('valid'); + return; + } + + // If isValid is undefined, run actual YAML validation + if (isValid === undefined) { + validateYaml(type, content); + return; + } + + if (isValid) { + validationEl.html(' YAML syntax is valid'); + validationEl.removeClass('error warning').addClass('valid'); + } else { + // Truncate error message to first line for cleaner display + var shortError = errorMsg.split('\n')[0].substring(0, 100); + if (errorMsg.length > 100) shortError += '...'; + // Use text node to prevent XSS from malicious YAML content + validationEl.empty() + .append(' YAML Error: ') + .append(document.createTextNode(shortError)); + validationEl.removeClass('valid warning').addClass('error'); + } +} + +function updateSaveButtonState() { + var totalChanges = editorModal.modifiedTabs.size + editorModal.modifiedSettings.size + editorModal.modifiedLabels.size; + var hasChanges = totalChanges > 0; + $('#editor-btn-apply').prop('disabled', !hasChanges); + $('#editor-change-count').text(totalChanges + (totalChanges === 1 ? ' change' : ' changes')); +} + +function hasPathSensitiveSettingsChanges() { + return editorModal.modifiedSettings.has('env-path') || + editorModal.modifiedSettings.has('external-compose-path') || + editorModal.modifiedSettings.has('external-compose-file') || + editorModal.modifiedSettings.has('use-default-compose-files'); +} + +function hasPathDependentEdits() { + return editorModal.modifiedTabs.has('compose') || + editorModal.modifiedTabs.has('env') || + editorModal.modifiedTabs.has('override') || + editorModal.modifiedLabels.size > 0; +} + +function enforcePathSettingsExclusivity(actionLabel) { + if (!hasPathSensitiveSettingsChanges()) { + return false; + } + + swal({ + title: 'Save Settings First', + text: 'Path/discovery settings changed. Save settings and reload the editor before ' + actionLabel + '.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Save Settings & Reload', + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) { + saveSettingsWithOptionalReload(false); + } + }); + + return true; +} + +function getExpectedSavePathForTab(tabName) { + switch (tabName) { + case 'compose': + return editorModal.filePaths.compose || ''; + case 'env': + return editorModal.filePaths.env || ''; + case 'override': + if (editorModal.labelsViewMode === 'basic') { + return editorModal.filePaths.projectOverride || ''; + } + return editorModal.filePaths.effectiveOverride || editorModal.filePaths.projectOverride || ''; + default: + return ''; + } +} + +function reloadEditorModal(targetTab, message) { + var project = editorModal.currentProject; + var projectName = editorModal.currentProjectName || $('#settings-name').val().trim() || project; + var nextTab = targetTab || editorModal.currentTab || 'compose'; + + if (!project) { + return; + } + + doCloseEditorModal(); + openEditorModalByProject(project, projectName, nextTab); + + if (message) { + swal({ + title: 'Editor Reloaded', + text: message, + type: 'info', + timer: 1800, + showConfirmButton: false + }); + } +} + +function handleStaleEditorResponse(response, message) { + if (!response || response.errorCode !== 'stale_path') { + return false; + } + + reloadEditorModal(editorModal.currentTab, message || response.message || 'Stack paths changed while this dialog was open.'); + return true; +} + +function saveSettingsWithOptionalReload(closeAfterSave) { + var saveErrors = []; + var project = editorModal.currentProject; + var reloadTab = editorModal.currentTab || 'settings'; + + return saveSettings(saveErrors).then(function(result) { + if (result === true) { + if (closeAfterSave) { + doCloseEditorModal(); + } else { + reloadEditorModal(reloadTab, 'Stack paths changed. Files were reloaded from the current target.'); + } + if (project) { + setTimeout(function() { + refreshStackByProject(project); + }, 100); + } + return true; + } + + var errorText = saveErrors.length > 0 ? saveErrors.join('\n') : 'Failed to save stack settings.'; + swal({ + title: 'Save Failed', + text: errorText, + type: 'error' + }); + return false; + }).fail(function() { + swal({ + title: 'Save Failed', + text: 'Failed to save stack settings.', + type: 'error' + }); + return false; + }); +} + +function handleOkayAction() { + var totalChanges = editorModal.modifiedTabs.size + editorModal.modifiedSettings.size + editorModal.modifiedLabels.size; + if (totalChanges > 0) { + saveAllChanges(true); + } else { + doCloseEditorModal(); + } +} + +function saveCurrentTab() { + var currentTab = editorModal.currentTab; + if (!currentTab) return; + var saveErrors = []; + + if (currentTab !== 'settings' && enforcePathSettingsExclusivity('saving files')) { + return; + } + + // Handle override editor save (labels tab in advanced mode) + if (currentTab === 'labels' && editorModal.labelsViewMode === 'advanced') { + if (!editorModal.modifiedTabs.has('override')) return; + saveTab('override', saveErrors).then(function(result) { + if (result === true) { + $('#editor-validation-override').html(' Saved!').removeClass('error warning').addClass('valid'); + setTimeout(function() { + if (editorModal.editors['override']) validateYaml('override', editorModal.editors['override'].getValue()); + }, 1500); + } else if (saveErrors.indexOf('__STALE_PATH__') !== -1) { + return; + } else { + $('#editor-validation-override').html(' Save failed').removeClass('valid warning').addClass('error'); + } + }).catch(function() { + if (saveErrors.indexOf('__STALE_PATH__') !== -1) { + return; + } + $('#editor-validation-override').html(' Save failed').removeClass('valid warning').addClass('error'); + }); + return; + } + + // Only save editor tabs + if (currentTab !== 'compose' && currentTab !== 'env') return; + if (!editorModal.modifiedTabs.has(currentTab)) return; + + saveTab(currentTab, saveErrors).then(function(result) { + if (result === true) { + // Brief feedback in validation panel + $('#editor-validation-' + currentTab).html(' Saved!').removeClass('error warning').addClass('valid'); + setTimeout(function() { + if (editorModal.editors[currentTab]) validateYaml(currentTab, editorModal.editors[currentTab].getValue()); + }, 1500); + } else if (saveErrors.indexOf('__STALE_PATH__') !== -1) { + return; + } else { + $('#editor-validation-' + currentTab).html(' Save failed').removeClass('valid warning').addClass('error'); + } + }).catch(function() { + if (saveErrors.indexOf('__STALE_PATH__') !== -1) { + return; + } + $('#editor-validation-' + currentTab).html(' Save failed').removeClass('valid warning').addClass('error'); + }); +} + +function parseSaveResponse(data, saveErrors, saveTarget) { + if (!data) { + if (saveErrors) saveErrors.push('Failed to save ' + saveTarget + '. Empty server response.'); + return false; + } + + var response = data; + if (typeof response === 'string') { + var trimmed = response.trim(); + if (trimmed.charAt(0) === '{') { + try { + response = JSON.parse(trimmed); + } catch (e) { + if (saveErrors) saveErrors.push('Failed to save ' + saveTarget + '. Invalid server response.'); + return false; + } + } else { + return true; + } + } + + if (typeof response === 'object' && response !== null && response.result) { + if (response.result === 'success') { + return true; + } + if (handleStaleEditorResponse(response, 'The save target changed while this dialog was open. Reloading the editor.')) { + if (saveErrors) saveErrors.push('__STALE_PATH__'); + return false; + } + if (saveErrors) saveErrors.push(response.message || ('Failed to save ' + saveTarget + '.')); + return false; + } + + return true; +} + +function saveTab(tabName, saveErrors) { + if (!editorModal.editors[tabName]) return Promise.reject('Editor not available'); + var content = editorModal.editors[tabName].getValue(); + var project = editorModal.currentProject; + var actionStr = null; + + switch (tabName) { + case 'compose': + actionStr = 'saveYml'; + break; + case 'env': + actionStr = 'saveEnv'; + break; + case 'override': + actionStr = 'saveOverride'; + break; + default: + return Promise.reject('Unknown tab'); + } + + var savePayload = { + action: actionStr, + script: project, + scriptContents: content, + expectedPath: getExpectedSavePathForTab(tabName) + }; + if (tabName === 'override') { + savePayload.managed = editorModal.labelsViewMode === 'basic' ? 1 : 0; + } + + return $.post(caURL, savePayload).then(function(data) { + var saveTarget = tabName === 'override' ? 'override file' : (tabName + ' file'); + if (!parseSaveResponse(data, saveErrors, saveTarget)) { + return false; + } + + editorModal.originalContent[tabName] = content; + editorModal.modifiedTabs.delete(tabName); + // For override, clear 'modified' on the labels tab indicator + if (tabName === 'override') { + updateTabModifiedState(); + } else { + $('#editor-tab-' + tabName).removeClass('modified'); + } + updateSaveButtonState(); + updateTabModifiedState(); + + // Regenerate profiles if compose file was saved + if (tabName === 'compose') { + generateProfiles(null, project); + } + + return true; + }).fail(function() { + if (saveErrors) saveErrors.push('Failed to save ' + tabName + ' file.'); + return false; + }); +} + +// Save all modified changes (files, settings, and labels) +function saveAllChanges(closeAfterSave) { + if (typeof closeAfterSave === 'undefined') { + closeAfterSave = false; + } + + var savePromises = []; + var saveErrors = []; + var skippedManualLabels = false; + var totalChanges = editorModal.modifiedTabs.size + editorModal.modifiedSettings.size + editorModal.modifiedLabels.size; + var pathSensitiveSettingsChanged = hasPathSensitiveSettingsChanges(); + var pathDependentEdits = hasPathDependentEdits(); + + if (totalChanges === 0) { + return; + } + + if (pathSensitiveSettingsChanged && pathDependentEdits) { + swal({ + title: 'Reload Required', + text: 'Compose path or discovery settings changed. Save settings and reload the editor before saving compose, .env, override, or label edits.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Save Settings Only', + cancelButtonText: 'Cancel' + }, function(confirmed) { + if (confirmed) { + saveSettingsWithOptionalReload(closeAfterSave); + } + }); + return; + } + + if (pathSensitiveSettingsChanged) { + saveSettingsWithOptionalReload(closeAfterSave); + return; + } + + // Track if labels are being modified in Automatic mode (need to offer recreate) + var labelsWereModified = editorModal.labelsViewMode === 'basic' && editorModal.modifiedLabels.size > 0; + + // Save modified file tabs (compose, env, and override editor) + editorModal.modifiedTabs.forEach(function(tabName) { + savePromises.push(saveTab(tabName, saveErrors)); + }); + + // Save settings if modified + if (editorModal.modifiedSettings.size > 0) { + savePromises.push(saveSettings(saveErrors)); + } + + // Save labels only in Automatic mode. + if (editorModal.modifiedLabels.size > 0) { + if (editorModal.labelsViewMode === 'basic') { + savePromises.push(saveLabels(saveErrors)); + } else { + skippedManualLabels = true; + } + } + + $.when.apply($, savePromises).then(function() { + var results = Array.prototype.slice.call(arguments); + var allSucceeded = results.every(function(result) { + return result === true; + }); + + if (allSucceeded) { + if (skippedManualLabels) { + swal({ + title: "Partially Saved", + text: "Non-label changes were saved. WebUI label form edits were not saved because Override File Management is set to Manual. Switch to Automatic to save Labels form changes.", + type: "warning" + }); + updateTabModifiedState(); + updateSaveButtonState(); + return; + } + + // Check if we should offer to recreate containers + if (labelsWereModified) { + promptRecreateContainers(closeAfterSave); + } else { + var project = editorModal.currentProject; + if (closeAfterSave) { + doCloseEditorModal(); + } + swal({ + title: "Saved!", + text: "All changes have been saved.", + type: "success", + timer: 1500, + showConfirmButton: false + }); + setTimeout(function() { + if (project) { + refreshStackByProject(project); + } + }, 1600); + } + } else { + var filteredErrors = saveErrors.filter(function(message) { + return message !== '__STALE_PATH__'; + }); + if (filteredErrors.length === 0 && saveErrors.indexOf('__STALE_PATH__') !== -1) { + return; + } + var errorText = filteredErrors.length > 0 ? + filteredErrors.join('\n') : + 'Some items could not be saved. Please try again.'; + swal({ + title: "Save Failed", + text: errorText, + type: "error" + }); + } + }).fail(function() { + swal({ + title: "Save Failed", + text: "An error occurred while saving. Please try again.", + type: "error" + }); + }); +} + +// Save settings +function saveSettings(saveErrors) { + var project = editorModal.currentProject; + var savePromises = []; + + // Save name if modified + if (editorModal.modifiedSettings.has('name')) { + var newName = $('#settings-name').val(); + savePromises.push( + $.post(caURL, { + action: 'changeName', + script: project, + newName: newName + }).then(function() { + editorModal.currentProjectName = newName || project; + editorModal.originalSettings['name'] = newName; + editorModal.modifiedSettings.delete('name'); + return true; + }).fail(function() { + if (saveErrors) saveErrors.push('Failed to save stack name.'); + return false; + }) + ); + } + + // Save description if modified + if (editorModal.modifiedSettings.has('description')) { + var newDesc = $('#settings-description').val().replace(/\n/g, '
'); + savePromises.push( + $.post(caURL, { + action: 'changeDesc', + script: project, + newDesc: newDesc + }).then(function() { + editorModal.originalSettings['description'] = $('#settings-description').val(); + editorModal.modifiedSettings.delete('description'); + return true; + }).fail(function() { + if (saveErrors) saveErrors.push('Failed to save description.'); + return false; + }) + ); + } + + // Save icon URL, webui URL, env path, default profile, and external compose settings if any are modified + if (editorModal.modifiedSettings.has('icon-url') || editorModal.modifiedSettings.has('webui-url') || editorModal.modifiedSettings.has('env-path') || editorModal.modifiedSettings.has('default-profile') || editorModal.modifiedSettings.has('external-compose-path') || editorModal.modifiedSettings.has('external-compose-file') || editorModal.modifiedSettings.has('use-default-compose-files')) { + var iconUrl = $('#settings-icon-url').val(); + var webuiUrl = $('#settings-webui-url').val(); + if (webuiUrl && !isValidWebUIUrl(webuiUrl)) { + swal({ + type: 'error', + title: 'Save Failed', + text: 'Invalid WebUI URL. Must be http:// or https:// (supports [IP] and [PORT:xxxx] placeholders).' + }); + return $.Deferred().resolve(false).promise(); + } + if (webuiUrl && /\[PORT\]/i.test(webuiUrl)) { + swal({ + type: 'error', + title: 'Save Failed', + text: 'Bare [PORT] placeholder is not supported at stack level. Use [PORT:xxxx] with a default port instead (e.g. [PORT:8080]).' + }); + return $.Deferred().resolve(false).promise(); + } + var envPath = $('#settings-env-path').val(); + var defaultProfile = $('#settings-default-profile').val(); + var externalComposePath = $('#settings-external-compose-path').val(); + var externalComposeFilePath = $('#settings-external-compose-file').val(); + if (externalComposePath && externalComposeFilePath) { + swal({ + type: 'error', + title: 'Save Failed', + text: 'Set either External Compose Path or External Compose File, not both.' + }); + return $.Deferred().resolve(false).promise(); + } + var stackPath = (editorModal.filePaths.stackMeta || '').replace(/\/$/, ''); + if (externalComposeFilePath && stackPath && externalComposeFilePath.startsWith(stackPath + '/')) { + swal({ + type: 'error', + title: 'Save Failed', + text: 'External Compose File cannot be inside the stack project folder. Use a path that is external to this stack.' + }); + return $.Deferred().resolve(false).promise(); + } + var useDefaultComposeFiles = $('#settings-use-default-compose-files').is(':checked') ? 'true' : 'false'; + savePromises.push( + $.post(caURL, { + action: 'setStackSettings', + script: project, + iconUrl: iconUrl, + webuiUrl: webuiUrl, + envPath: envPath, + defaultProfile: defaultProfile, + externalComposePath: externalComposePath, + externalComposeFilePath: externalComposeFilePath, + useDefaultComposeFiles: useDefaultComposeFiles + }).then(function(data) { + if (data) { + try { + var response = JSON.parse(data); + } catch (e) { + if (saveErrors) saveErrors.push('Invalid server response when saving settings.'); + return false; + } + if (response.result === 'success') { + editorModal.originalSettings['icon-url'] = iconUrl; + editorModal.originalSettings['webui-url'] = webuiUrl; + editorModal.originalSettings['env-path'] = envPath; + editorModal.originalSettings['default-profile'] = defaultProfile; + editorModal.originalSettings['external-compose-path'] = externalComposePath; + editorModal.originalSettings['external-compose-file'] = externalComposeFilePath; + editorModal.originalSettings['use-default-compose-files'] = useDefaultComposeFiles; + editorModal.modifiedSettings.delete('icon-url'); + editorModal.modifiedSettings.delete('webui-url'); + editorModal.modifiedSettings.delete('env-path'); + editorModal.modifiedSettings.delete('default-profile'); + editorModal.modifiedSettings.delete('external-compose-path'); + editorModal.modifiedSettings.delete('external-compose-file'); + editorModal.modifiedSettings.delete('use-default-compose-files'); + return true; + } else { + // Collect error message from server + if (saveErrors) saveErrors.push(response.message || 'Failed to save stack settings.'); + } + } + return false; + }).fail(function() { + if (saveErrors) saveErrors.push('Failed to save stack settings (network error).'); + return false; + }) + ); + } + + // Save labels view mode if modified + if (editorModal.modifiedSettings.has('labels-view-mode')) { + var labelsViewMode = editorModal.pendingLabelsViewMode === 'advanced' ? 'advanced' : 'basic'; + savePromises.push( + $.post(caURL, { + action: 'setLabelsViewMode', + script: project, + labelsViewMode: labelsViewMode + }).then(function(data) { + if (data) { + try { + var response = JSON.parse(data); + } catch (e) { + if (saveErrors) saveErrors.push('Invalid server response when saving override management mode.'); + return false; + } + if (response.result === 'success') { + editorModal.originalSettings['labels-view-mode'] = labelsViewMode; + editorModal.pendingLabelsViewMode = labelsViewMode; + editorModal.modifiedSettings.delete('labels-view-mode'); + toggleLabelsViewMode(labelsViewMode === 'advanced', true); + return true; + } + if (saveErrors) saveErrors.push(response.message || 'Failed to save override management mode.'); + return false; + } + if (saveErrors) saveErrors.push('Failed to save override management mode. Empty server response.'); + return false; + }).fail(function() { + if (saveErrors) saveErrors.push('Failed to save override management mode (network error).'); + return false; + }) + ); + } + + return $.when.apply($, savePromises).then(function() { + var results = Array.prototype.slice.call(arguments); + var allSucceeded = savePromises.length === 0 || results.every(function(result) { + return result === true; + }); + + updateTabModifiedState(); + updateSaveButtonState(); + return allSucceeded; + }); +} + +// Save labels to override file +function saveLabels(saveErrors) { + var project = editorModal.currentProject; + + if (!editorModal.labelsData) { + return $.Deferred().reject().promise(); + } + + if (editorModal.labelsData.overrideHasCustomTags) { + if (saveErrors) { + saveErrors.push('WebUI labels cannot be saved because compose.override.yaml uses !override, !reset, or !merge tags. Edit the override file directly to preserve those tags.'); + } + return $.Deferred().resolve(false).promise(); + } + + var mainDoc = editorModal.labelsData.mainDoc; + var overrideDoc = editorModal.labelsData.overrideDoc; + + // Update override doc with values from the form + for (var serviceKey in mainDoc.services) { + if (!(serviceKey in overrideDoc.services)) { + overrideDoc.services[serviceKey] = { + labels: {} + }; + overrideDoc.services[serviceKey].labels[managed_label] = managed_label_name; + } + + var iconValue = $('#label-' + serviceKey + '-icon').val() || ''; + var webuiValue = $('#label-' + serviceKey + '-webui').val() || ''; + var shellValue = $('#label-' + serviceKey + '-shell').val() || ''; + + if (!overrideDoc.services[serviceKey].labels) { + overrideDoc.services[serviceKey].labels = {}; + } + + overrideDoc.services[serviceKey].labels[icon_label] = iconValue; + overrideDoc.services[serviceKey].labels[webui_label] = webuiValue; + overrideDoc.services[serviceKey].labels[shell_label] = shellValue; + } + + // Remove services from override that are no longer in main. + // This is required because docker compose will fail if override has + // services that don't exist in the main compose file (no image defined). + for (var serviceKey in overrideDoc.services) { + if (!(serviceKey in mainDoc.services)) { + delete overrideDoc.services[serviceKey]; + } + } + + // Convert to YAML and save + var rawOverride = jsyaml.dump(overrideDoc, { + 'forceQuotes': true + }); + + return $.post(caURL, { + action: 'saveOverride', + script: project, + scriptContents: rawOverride, + managed: editorModal.labelsViewMode === 'basic' ? 1 : 0, + expectedPath: getExpectedSavePathForTab('override') + }).then(function(data) { + if (!parseSaveResponse(data, saveErrors, 'WebUI labels')) { + return false; + } + + // Collect services whose icon changed, then clear webgui icon cache + var changedIconServices = []; + for (var serviceKey in mainDoc.services) { + var oldIcon = editorModal.originalLabels[serviceKey + '_icon'] || ''; + var newIcon = $('#label-' + serviceKey + '-icon').val() || ''; + if (oldIcon !== newIcon) { + changedIconServices.push(serviceKey); + } + } + if (changedIconServices.length > 0) { + $.post(caURL, { + action: 'clearIconCache', + script: project, + services: JSON.stringify(changedIconServices) + }); + } + + // Update original labels to match current values + for (var serviceKey in mainDoc.services) { + editorModal.originalLabels[serviceKey + '_icon'] = $('#label-' + serviceKey + '-icon').val() || ''; + editorModal.originalLabels[serviceKey + '_webui'] = $('#label-' + serviceKey + '-webui').val() || ''; + editorModal.originalLabels[serviceKey + '_shell'] = $('#label-' + serviceKey + '-shell').val() || ''; + } + editorModal.labelsData.overrideContent = rawOverride; + editorModal.labelsData.overrideHasCustomTags = false; + editorModal.modifiedLabels.clear(); + updateTabModifiedState(); + updateSaveButtonState(); + return true; + }).fail(function() { + swal({ + title: "Save Failed", + text: "Failed to save WebUI labels. Please try again.", + type: "error" + }); + return false; + }); +} + +// Keep saveAllTabs for backwards compatibility +function saveAllTabs() { + saveAllChanges(true); +} + +function closeEditorModal() { + var totalChanges = editorModal.modifiedTabs.size + editorModal.modifiedSettings.size + editorModal.modifiedLabels.size; + if (totalChanges > 0) { + swal({ + title: "Unsaved Changes", + text: "You have unsaved changes. Are you sure you want to close?", + type: "warning", + showCancelButton: true, + confirmButtonText: "Discard Changes", + cancelButtonText: "Cancel" + }, function(confirmed) { + if (confirmed) { + doCloseEditorModal(); + } + }); + } else { + doCloseEditorModal(); + } +} + +function doCloseEditorModal() { + $('#editor-modal-overlay').removeClass('active').appendTo('body'); + editorModal.currentProject = null; + editorModal.currentProjectName = null; + editorModal.currentTab = 'compose'; + editorModal.modifiedTabs = new Set(); + editorModal.modifiedSettings = new Set(); + editorModal.modifiedLabels = new Set(); + editorModal.originalContent = {}; + editorModal.originalSettings = {}; + editorModal.originalLabels = {}; + editorModal.labelsData = null; + editorModal.labelsViewMode = 'basic'; + editorModal.pendingLabelsViewMode = 'basic'; + + // Clear editor content to avoid showing stale content on next open + ['compose', 'env', 'override'].forEach(function(type) { + if (editorModal.editors[type]) { + editorModal.editors[type].setValue('', -1); + } + }); + + // Reset labels view to basic; stack-specific mode reloads on next open. + toggleLabelsViewMode(false, true); + $('#editor-validation-override').html(' Ready').removeClass('valid error warning'); + + // Reset settings fields + $('#settings-name').val(''); + $('#settings-description').val(''); + $('#settings-icon-url').val(''); + $('#settings-webui-url').val(''); + $('#settings-env-path').val(''); + $('#settings-default-profile').val(''); + $('#settings-external-compose-path').val(''); + $('#settings-external-compose-file').val(''); + $('#settings-use-default-compose-files').prop('checked', false); + $('#settings-override-management').prop('checked', true); + $('#settings-override-management-label').text('Automatic'); + $('#settings-icon-preview').hide(); + $('#settings-available-profiles').hide(); + $('#settings-external-compose-info').hide(); + $('#settings-invalid-indirect-warning').hide(); + + // Hide any open file-tree pickers (so they don't float outside the modal) + $('.fileTree').slideUp('fast'); + + // Clear labels container + $('#labels-services-container').html(''); + + // Reset tab states + $('.editor-tab').removeClass('modified'); +} + +function deleteStackByProject(project, projectName) { + var msgHtml = "Are you sure you want to delete " + composeEscapeHtml(projectName) + " (" + composeEscapeHtml(compose_root) + "/" + composeEscapeHtml(project) + ")?"; + swal({ + title: "Delete Stack?", + text: msgHtml, + html: true, + type: "warning", + showCancelButton: true, + confirmButtonText: "Delete", + cancelButtonText: "Cancel" + }, function(confirmed) { + if (confirmed) { + setStackActionInProgress(project, true, 'Deleting stack...'); + $.post(caURL, { + action: 'deleteStack', + stackName: project + }, function(data) { + try { + if (data) { + var response = JSON.parse(data); + if (response.result == "warning") { + setTimeout(function() { + swal({ + title: "Files remain on disk.", + text: response.message, + type: "warning" + }, function() { + composeLoadlist(); + }); + }, 100); + return; + } + } + } catch (e) { + composeLogger('Delete response parse error', { + project: project, + error: e + }, 'user', 'error', 'stack-action'); + } + }).fail(function() { + composeLogger('Delete request failed for project', { + project: project + }, 'user', 'error', 'stack-action'); + }); + composeLoadlist(); + } + }); +} + +// ============================================ +// Expandable Stack Details Functions +// ============================================ +function toggleStackDetails(stackId) { + var $row = $('#stack-row-' + stackId); + var $detailsRow = $('#details-row-' + stackId); + var $expandIcon = $('#expand-icon-' + stackId); + var project = $row.data('project'); + + if (expandedStacks[stackId]) { + // Collapse + $detailsRow.slideUp(200); + $expandIcon.removeClass('expanded'); + expandedStacks[stackId] = false; + } else { + $expandIcon.addClass('expanded'); + expandedStacks[stackId] = true; + + if (stackContainersCache[stackId]) { + // Cached — always re-render from cache before showing. + // This prevents stale hidden DOM content when a stack changed + // state while details were collapsed. + renderContainerDetails(stackId, stackContainersCache[stackId], project); + $detailsRow.slideDown(200); + } else { + // First load: fetch data, row stays hidden until render completes + loadStackContainerDetails(stackId, project); + } + } +} + +function loadStackContainerDetails(stackId, project) { + var $container = $('#details-container-' + stackId); + + // Prevent parallel loads for same stack + if (stackDetailsLoading[stackId]) { + composeLogger('already-loading', { + stackId: stackId, + project: project + }, 'user', 'warning', 'container-details'); + return; + } + stackDetailsLoading[stackId] = true; + composeLogger('start', { + stackId: stackId, + project: project + }, 'user', 'info', 'container-details'); + + // Show loading state + $container.html('
Loading container details...
'); + + $.post(caURL, { + action: 'getStackContainers', + script: project + }, function(data) { + if (data) { + try { + var response = JSON.parse(data); + if (response.result === 'success') { + var containers = response.containers; + + // Normalize all containers via factory function (PascalCase→camelCase) + containers = containers.map(createContainerInfo).filter(Boolean); + + // Merge update status from stackUpdateStatus if available + mergeUpdateStatus(containers, project); + + stackContainersCache[stackId] = containers; + if (response.startedAt) stackStartedAtCache[stackId] = response.startedAt; + composeLogger('success', { + stackId: stackId, + project: project, + containers: containers.length + }, 'user', 'info', 'container-details'); + renderContainerDetails(stackId, containers, project); + // Slide down details row now that content is rendered + $('#details-row-' + stackId).slideDown(200); + } else { + // Escape error message to prevent XSS + var errorMsg = composeEscapeHtml(response.message || 'Failed to load container details'); + $container.html('
' + errorMsg + '
'); + $('#details-row-' + stackId).slideDown(200); + composeLogger('error', { + stackId: stackId, + project: project, + message: errorMsg + }, 'user', 'error', 'container-details'); + } + } catch (e) { + $container.html('
Failed to parse container details response
'); + $('#details-row-' + stackId).slideDown(200); + composeLogger('parse-error', { + stackId: stackId, + project: project, + err: e.toString() + }, 'user', 'error', 'container-details'); + } + } else { + $container.html('
Failed to load container details
'); + $('#details-row-' + stackId).slideDown(200); + composeLogger('empty-response', { + stackId: stackId, + project: project + }, 'user', 'warning', 'container-details'); + } + stackDetailsLoading[stackId] = false; + }).fail(function() { + $container.html('
Failed to load container details
'); + $('#details-row-' + stackId).slideDown(200); + stackDetailsLoading[stackId] = false; + composeLogger('failed', { + stackId: stackId, + project: project + }, 'user', 'error', 'container-details'); + }); +} + +function renderContainerDetails(stackId, containers, project) { + var $container = $('#details-container-' + stackId); + + if (!containers || containers.length === 0) { + $container.html('
No containers found. Stack may not be running.
'); + return; + } + + // Mini Docker table - matches Docker tab columns + var html = ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + containers.forEach(function(container, idx) { + var containerName = container.name || container.service || 'Unknown'; + var shortName = container.service || containerName.replace(/^[^-]+-/, ''); // Prefer service name; fall back to stripping project prefix + var image = container.image || ''; + + // Parse image - handle docker.io/ prefix and @sha256: digest + // Format could be: docker.io/library/redis:6.2-alpine@sha256:abc123... + var imageForParsing = image; + if (imageForParsing.indexOf('docker.io/') === 0) { + imageForParsing = imageForParsing.substring(10); + } + + // Check for @sha256: digest suffix + var digestSuffix = ''; + var digestPos = imageForParsing.indexOf('@sha256:'); + if (digestPos !== -1) { + digestSuffix = '@' + imageForParsing.substring(digestPos + 1, digestPos + 20); // @sha256:xxxx (first 12 chars of digest) + imageForParsing = imageForParsing.substring(0, digestPos); + } + + // Now split by : for tag + var imageParts = imageForParsing.split(':'); + var imageSource = imageParts[0] || ''; // Image name without tag + var imageTag = (imageParts[1] || 'latest') + digestSuffix; // Include digest suffix if present + var state = container.state || 'unknown'; + var containerId = String(container.id || containerName || '').substring(0, 12); + var uniqueId = 'ct-' + stackId + '-' + idx; + + // Status like Docker tab + var shape = state === 'running' ? 'play' : (state === 'paused' ? 'pause' : 'square'); + var statusText = state === 'running' ? 'started' : (state === 'paused' ? 'paused' : 'stopped'); + var color = state === 'running' ? 'green-text' : (state === 'paused' ? 'orange-text' : 'grey-text'); + var outerClass = state === 'running' ? 'started' : (state === 'paused' ? 'paused' : 'stopped'); + + // Get networks and IPs + var networkNames = []; + var ipAddresses = []; + if (container.networks && container.networks.length > 0) { + container.networks.forEach(function(net) { + networkNames.push(net.name || '-'); + ipAddresses.push(net.ip || '-'); + }); + } + if (networkNames.length === 0) { + networkNames.push('-'); + ipAddresses.push('-'); + } + + // Format ports - separate container ports and mapped ports + var containerPorts = []; + var lanPorts = []; + if (container.ports && container.ports.length > 0) { + container.ports.forEach(function(p) { + // Format: "192.168.1.10:8080->80/tcp" or "80/tcp" + var parts = p.split('->'); + if (parts.length === 2) { + lanPorts.push(parts[0]); + containerPorts.push(parts[1]); + } else { + containerPorts.push(p); + } + }); + } + if (containerPorts.length === 0) containerPorts.push('-'); + if (lanPorts.length === 0) lanPorts.push('-'); + + // WebUI + // WebUI — already resolved server-side by exec.php + var webui = ''; + if (container.webUI) { + webui = container.webUI; + if (!isValidWebUIUrl(webui)) webui = ''; + } + + html += ''; + + // Container name column - matches Docker tab exactly + html += ''; + + // Update column - shows update status for this container (like Docker tab) + html += ''; + + // Source (image name without tag) + html += ''; + + // Tag (image tag) — truncated with ellipsis via CSS if too long + html += ''; + + // Network + html += ''; + + // Container IP + html += ''; + + // CPU & Memory load (advanced only) — populated by dockerload WebSocket + html += ''; + + // Container Port + html += ''; + + // LAN IP:Port + html += ''; + + html += ''; + }); + + html += '
ContainerUpdateSourceTagNetworkContainer IPCPU & Memory loadContainer PortLAN IP:Port
'; + html += ''; + var containerShell = container.shell || '/bin/sh'; + html += ''; + // Use actual image like Docker tab - either container icon or default question.png + var iconSrc = (container.icon && isValidIconSrc(container.icon)) ? + container.icon : + '/plugins/dynamix.docker.manager/images/question.png'; + html += ''; + html += ''; + html += '' + composeEscapeHtml(shortName) + '
'; + html += '' + statusText + ''; + html += '
'; + html += '
'; + var ctHasUpdate = container.hasUpdate || false; + var ctUpdateStatus = container.updateStatus || ''; + var ctLocalSha = container.localSha || ''; + var ctRemoteSha = container.remoteSha || ''; + var ctIsPinned = container.isPinned || false; + var ctPinnedDigest = container.pinnedDigest || ''; + + if (ctIsPinned) { + // Image is pinned with SHA256 digest - show pinned status + html += ' pinned'; + if (ctPinnedDigest) { + html += '
' + composeEscapeHtml(ctPinnedDigest.substring(0, 12)) + '
'; + } + } else if (ctHasUpdate) { + // Update available - orange "update ready" style with SHA diff + html += ''; + html += ' update ready'; + html += ''; + if (ctLocalSha && ctRemoteSha) { + // Always show SHA diff (not just in advanced view) + html += '
'; + html += '' + composeEscapeHtml(ctLocalSha.substring(0, 8)) + ''; + html += ' '; + html += '' + composeEscapeHtml(ctRemoteSha.substring(0, 8)) + ''; + html += '
'; + } + } else if (ctUpdateStatus === 'up-to-date') { + // No update - green "up-to-date" style + html += ' up-to-date'; + if (ctLocalSha) { + // Show SHA in advanced view only for up-to-date containers (15 chars) + html += '
' + composeEscapeHtml(ctLocalSha.substring(0, 15)) + '
'; + } + } else { + // Unknown/not checked + html += ' not checked'; + } + html += '
' + composeEscapeHtml(imageSource) + '' + composeEscapeHtml(imageTag) + '' + networkNames.map(composeEscapeHtml).join('
') + '
' + ipAddresses.map(composeEscapeHtml).join('
') + '
'; + if (state === 'running') { + html += '0%'; + html += '
'; + html += '
0B / 0B'; + } else { + html += '-'; + html += ''; + } + html += '
' + containerPorts.map(composeEscapeHtml).join('
') + '
' + lanPorts.map(composeEscapeHtml).join('
') + '
'; + + $container.html(html); + + // Update the parent stack row shortly after rendering so counts and status + // reflect the latest state. Use a short timeout to avoid racing with other + // DOM updates (e.g. a full list reload) that may remove the row. + try { + setTimeout(function() { + // Mark as just rendered before any parent-row update so + // updateStackUpdateUI can suppress re-entrant detail reloads. + try { + stackDetailsJustRendered[stackId] = true; + } catch (ex) { + composeLogger('set-just-rendered-failed', { + err: ex.toString(), + stackId: stackId, + project: project + }, 'user', 'error', 'container-details'); + } + try { + updateParentStackFromContainers(stackId, project); + } catch (e) { + composeLogger('update-parent-failed', { + err: e.toString(), + stackId: stackId, + project: project + }, 'user', 'error', 'container-details'); + } + composeLogger('just-rendered', { + stackId: stackId, + project: project + }, 'user', 'info', 'container-details'); + // Clear the flag after a short window + setTimeout(function() { + try { + stackDetailsJustRendered[stackId] = false; + } catch (ex) {} + }, 1000); + // Clear loading flag now that render finished + try { + stackDetailsLoading[stackId] = false; + } catch (ex) {} + }, 120); + } catch (e) {} + + // Apply readmore to container details — destroy first to avoid nesting wrappers + $container.find('.docker_readmore').readmore('destroy'); + $container.find('.docker_readmore').readmore({ + maxHeight: 32, + moreLink: "", + lessLink: "" + }); + + // Attach context menus to each container icon (like Docker tab) + containers.forEach(function(container, idx) { + var uniqueId = 'ct-' + stackId + '-' + idx; + addComposeContainerContext(uniqueId); + }); + + // If this stack was queued for a compose-list reload (due to containerAction), + // process it now: remove from pending list and reload the compose list so + // the parent stack row (status icon, counts) is refreshed. + // NOTE: avoid scheduling a parent-list reload here. Reloads should only be + // queued from actions that change container state (e.g. containerAction()). + // Scheduling a reload from a pure render path can cause repeated cycles + // when the parent row update triggers re-renders. Container actions will + // queue the stack reload explicitly. +} + +// Build a condensed stackInfo object from the stackContainersCache for a stack +function buildStackInfoFromCache(stackId, project) { + var containers = stackContainersCache[stackId] || []; + return createStackInfo(project, containers); +} + +// Update only the parent stack row using cached container details +function updateParentStackFromContainers(stackId, project) { + try { + var $stackRow = $('#compose_stacks tr.compose-sortable[data-project="' + project + '"]'); + if ($stackRow.length === 0) { + // If the row isn't present, fall back to a full reload + composeLoadlist(); + return; + } + + // Detect if an update check is currently in progress (spinner visible) + var $updateCell = $stackRow.find('td.compose-updatecolumn'); + var isChecking = $updateCell.find('.fa-refresh.fa-spin').length > 0; + + // Update the update-column using existing helper (expects stackInfo) + var stackInfo = buildStackInfoFromCache(stackId, project); + // Merge any previously saved update status so we don't lose 'checked' state + mergeStackUpdateStatus(stackInfo, stackUpdateStatus[project] || {}); + + // Cache the merged update status and apply UI update + stackUpdateStatus[project] = stackInfo; + // Skip updating the update column if a check is currently in progress + if (!isChecking) { + updateStackUpdateUI(project, stackInfo); + } + + // If the stack has an in-progress action, keep the temporary status icon/text until completion. + if (composeStackActionInProgress[project]) { + composeLogger('skipping icon/state update while action in progress', { + stackId: stackId, + project: project + }, 'user', 'debug', 'container-details'); + return; + } + + // Update the stack row status icon and state text based on container states + var $stateEl = $stackRow.find('.state'); + var origText = $stateEl.data('orig-text') || $stateEl.text(); + // Derive state from containers using centralized helper + var stateInfo = deriveStackState(stackInfo.containers); + var runningCount = stateInfo.runningCount; + var totalCount = stateInfo.totalCount; + var anyRunning = runningCount > 0; + var newState = stateInfo.state; + $stateEl.text(stateInfo.label); + + // Update the containers count cell to reflect cached values + try { + var $containersCell = $stackRow.find('td.col-containers'); + var containersClass = stateInfo.colorClass; + $containersCell.html('' + runningCount + ' / ' + totalCount + ''); + } catch (e) {} + + // Update the status icon to match the new state and color + var $icon = $stackRow.find('.compose-status-icon'); + if ($icon.length) { + var shape = stateInfo.shape; + var colorClass = stateInfo.colorClass; + + // Remove spinner / temporary classes and any previous fa- classes + $icon.removeClass('fa-refresh fa-spin compose-status-spinner'); + // Use a regex that matches the full fa- (including hyphens) to ensure + // icons like fa-exclamation-circle are removed completely. + $icon.removeClass(function(i, cls) { + return (cls.match(/fa-[^\s]+/g) || []).join(' '); + }); + + // Remove any previous color classes + $icon.removeClass('green-text orange-text grey-text cyan-text'); + + // Apply the new shape and color + $icon.addClass('fa fa-' + shape + ' ' + colorClass + ' compose-status-icon'); + + // Clear any saved orig-class since we've now applied the new state + if ($icon.data('orig-class')) { + $icon.removeData('orig-class'); + } + } + + // Update data-isup and data-running so context menu reflects new state + var newIsUp = anyRunning ? '1' : '0'; + $stackRow.data('isup', newIsUp).attr('data-isup', newIsUp); + var $iconSpan = $stackRow.find('span[data-stackid]'); + $iconSpan.data('isup', newIsUp).attr('data-isup', newIsUp); + $iconSpan.data('running', runningCount).attr('data-running', runningCount); + + // Rebind stack context menu so options (Up/Down/Stop/etc.) match new state + var stackElementId = 'stack-' + stackId; + if ($('#' + stackElementId).length) { + addComposeStackContext(stackElementId); + } + + // Update the uptime column using stack-level started_at (same + // source as the initial PHP render in compose_list.php) so the + // displayed value doesn't jump when details are expanded. + try { + var $uptimeCell = $stackRow.find('td.col-uptime'); + var uptimeText = 'stopped'; + var uptimeClass = 'grey-text'; + if (anyRunning) { + var stackStarted = stackStartedAtCache[stackId] || null; + if (stackStarted) { + var t = new Date(stackStarted).getTime(); + if (!isNaN(t)) { + var secs = Math.max(0, Math.floor((Date.now() - t) / 1000)); + var mins = Math.floor(secs / 60); + var hours = Math.floor(secs / 3600); + var days = Math.floor(secs / 86400); + var weeks = Math.floor(days / 7); + var months = Math.floor(days / 30); + if (mins < 120) uptimeText = mins + ' min' + (mins !== 1 ? 's' : ''); + else if (hours < 48) uptimeText = hours + ' hour' + (hours !== 1 ? 's' : ''); + else if (days < 14) uptimeText = days + ' day' + (days !== 1 ? 's' : ''); + else if (weeks < 8) uptimeText = weeks + ' week' + (weeks !== 1 ? 's' : ''); + else if (months < 24) uptimeText = months + ' month' + (months !== 1 ? 's' : ''); + else { + var years = Math.floor(days / 365); + uptimeText = years + ' year' + (years !== 1 ? 's' : ''); + } + } else { + uptimeText = 'running'; + } + } else { + uptimeText = 'running'; + } + uptimeClass = 'green-text'; + } + $uptimeCell.html('' + uptimeText + ''); + } catch (e) {} + + // Re-apply view mode (advanced/basic) to ensure column content visibility + applyListView(); + } catch (e) { + composeLogger('updateParentStackFromContainers error', { + err: e.toString(), + stackId: stackId, + project: project + }, 'user', 'error', 'container-details'); + // If anything goes wrong, fallback to full reload + composeLoadlist(); + } +} + +// Attach context menu to container icon (like Docker tab's addDockerContainerContext) +function addComposeContainerContext(elementId) { + var $el = $('#' + elementId); + var containerName = $el.data('name'); + var state = $el.data('state'); + var webui = $el.data('webui'); + var stackId = $el.data('stackid'); + var shell = $el.data('shell') || '/bin/bash'; + var running = state === 'running'; + var paused = state === 'paused'; + + var opts = []; + context.settings({ + right: false, + above: 'auto' + }); + + // WebUI (if running) + if (running && webui) { + opts.push({ + text: 'WebUI', + icon: 'fa-globe', + action: function(e) { + e.preventDefault(); + window.open(webui, '_blank'); + } + }); + opts.push({ + divider: true + }); + } + + // Console (if running) — start writable ttyd, open in new window + if (running) { + opts.push({ + text: 'Console', + icon: 'fa-terminal', + action: function(e) { + e.preventDefault(); + $.post(compURL, { + action: 'containerConsole', + container: containerName, + shell: shell + }, function(data) { + if (data) { + var height = Math.min(screen.availHeight, 800); + var width = Math.min(screen.availWidth, 1200); + window.open(data, 'Console_' + containerName.replace(/[^a-zA-Z0-9]/g, '_'), + 'height=' + height + ',width=' + width + ',resizable=yes,scrollbars=yes'); + } + }); + } + }); + opts.push({ + divider: true + }); + } + + // Start/Stop/Pause/Resume + if (running) { + opts.push({ + text: 'Stop', + icon: 'fa-stop', + action: function(e) { + e.preventDefault(); + containerAction(containerName, 'stop', stackId); + } + }); + opts.push({ + text: 'Pause', + icon: 'fa-pause', + action: function(e) { + e.preventDefault(); + containerAction(containerName, 'pause', stackId); + } + }); + opts.push({ + text: 'Restart', + icon: 'fa-refresh', + action: function(e) { + e.preventDefault(); + containerAction(containerName, 'restart', stackId); + } + }); + } else if (paused) { + opts.push({ + text: 'Resume', + icon: 'fa-play', + action: function(e) { + e.preventDefault(); + containerAction(containerName, 'unpause', stackId); + } + }); + } else { + opts.push({ + text: 'Start', + icon: 'fa-play', + action: function(e) { + e.preventDefault(); + containerAction(containerName, 'start', stackId); + } + }); + } + + opts.push({ + divider: true + }); + + // Logs — start ttyd via plugin, open in new window (same as stack logs) + opts.push({ + text: 'Logs', + icon: 'fa-navicon', + action: function(e) { + e.preventDefault(); + $.post(compURL, { + action: 'containerLogs', + container: containerName + }, function(data) { + if (data) { + var height = Math.min(screen.availHeight, 800); + var width = Math.min(screen.availWidth, 1200); + window.open(data, 'Logs_' + containerName.replace(/[^a-zA-Z0-9]/g, '_'), + 'height=' + height + ',width=' + width + ',resizable=yes,scrollbars=yes'); + } + }); + } + }); + + // Ensure stale menu bindings don't persist across state transitions + $el.off('contextmenu'); + context.attach('#' + elementId, opts); +} + +function containerAction(containerName, action, stackId) { + // Show spinner by replacing the status icon (play/stop) in-place + var $iconWrap = $('[data-name="' + containerName + '"]').first(); + // The status icon lives in the sibling '.inner' span next to the '.hand' wrapper + var $statusIcon = $iconWrap.closest('td').find('.inner i').first(); + var statusOrigClass = null; + var __spinnerInserted = false; + // Also preserve/modify the state text (e.g. 'starting', 'stopping') while action runs + var $stateTextEl = $iconWrap.closest('td').find('.inner .state').first(); + var stateOrigText = null; + var actionStatusTextMap = { + start: 'starting', + stop: 'stopping', + restart: 'restarting', + pause: 'pausing', + unpause: 'resuming' + }; + var actionStatusText = actionStatusTextMap[action] || 'working'; + if ($statusIcon.length) { + try { + statusOrigClass = $statusIcon.attr('class') || ''; + $statusIcon.attr('data-orig-class', statusOrigClass); + $statusIcon.removeClass().addClass('fa fa-refresh fa-spin compose-status-spinner'); + __spinnerInserted = true; + } catch (e) { + __spinnerInserted = false; + } + } else { + // Fallback to previous behavior: if there's an or inside the hand, use that + var $icon = $iconWrap.find('i,img').first(); + var originalClass = $icon.attr('class'); + if ($icon.is('i')) { + $icon.removeClass().addClass('fa fa-refresh fa-spin'); + __spinnerInserted = true; + } else if ($icon.is('img')) { + // As a last resort, overlay a spinner on the image (should be rare now) + try { + $iconWrap.css('position', 'relative'); + $icon.css('opacity', 0.35); + $iconWrap.append(''); + __spinnerInserted = true; + } catch (e) { + __spinnerInserted = false; + } + } + } + + // If we inserted a spinner, set temporary state text and save original so we can restore on failure + if (__spinnerInserted && $stateTextEl.length) { + try { + stateOrigText = $stateTextEl.text(); + $stateTextEl.attr('data-orig-text', stateOrigText); + $stateTextEl.text(actionStatusText); + composeLogger('set-status', { + container: containerName, + action: action, + stackId: stackId, + statusText: actionStatusText + }, 'user', 'info', 'container-action'); + } catch (e) {} + } + + $.post(caURL, { + action: 'containerAction', + container: containerName, + containerAction: action + }, function(data) { + if (data) { + try { + var response = JSON.parse(data); + } catch (e) { + return; + } + if (response.result === 'success') { + // Refresh the container details + // Also mark the parent stack for a compose-list reload so the stack-level + // status (play/stop icon, running count) is refreshed after the container action. + try { + var project = $('#stack-row-' + stackId).data('project'); + if (project) { + if (pendingComposeReloadStacks.indexOf(project) === -1) pendingComposeReloadStacks.push(project); + composeLogger('queued-stack-reload', { + container: containerName, + action: action, + stack: project, + pending: pendingComposeReloadStacks.slice() + }, 'user', 'info', 'container-action'); + // Show per-stack spinner immediately + try { + setStackActionInProgress(project, true); + } catch (e) {} + } + } catch (e) {} + + // Refresh the container details after a short delay to let docker settle + setTimeout(function() { + var project = $('#stack-row-' + stackId).data('project'); + loadStackContainerDetails(stackId, project); + }, 1000); + // Schedule a debounced parent-row update so multiple signals collapse + try { + schedulePendingComposeReloads(1200); + } catch (e) {} + // Also refresh Unraid's Docker containers widget + if (typeof window.loadlist === 'function') { + setTimeout(function() { + window.loadlist(); + }, 1500); + } + } else { + // Restore status icon or remove overlay spinner + if (__spinnerInserted) { + // Restore status icon if we replaced it + var $restStatus = $iconWrap.closest('td').find('.inner i').first(); + if ($restStatus.length && $restStatus.attr('data-orig-class')) { + $restStatus.removeClass().addClass($restStatus.attr('data-orig-class')); + $restStatus.removeAttr('data-orig-class'); + } else { + // Fallback: restore any overlay on the image + try { + $icon.css('opacity', 1); + $iconWrap.find('.compose-container-spinner').remove(); + } catch (e) {} + } + // Restore state text if we changed it + try { + var $stateEl = $iconWrap.closest('td').find('.inner .state').first(); + if ($stateEl.length && $stateEl.attr('data-orig-text')) { + $stateEl.text($stateEl.attr('data-orig-text')); + $stateEl.removeAttr('data-orig-text'); + } + } catch (e) {} + } + swal({ + title: 'Action Failed', + text: composeEscapeHtml(response.message) || 'Failed to ' + action + ' container', + type: 'error' + }); + } + } + }).fail(function() { + // Restore status icon or remove overlay spinner + if (__spinnerInserted) { + var $restStatus = $iconWrap.closest('td').find('.inner i').first(); + if ($restStatus.length && $restStatus.attr('data-orig-class')) { + $restStatus.removeClass().addClass($restStatus.attr('data-orig-class')); + $restStatus.removeAttr('data-orig-class'); + } else { + try { + $icon.css('opacity', 1); + $iconWrap.find('.compose-container-spinner').remove(); + } catch (e) {} + } + // Restore state text if we changed it + try { + var $stateEl = $iconWrap.closest('td').find('.inner .state').first(); + if ($stateEl.length && $stateEl.attr('data-orig-text')) { + $stateEl.text($stateEl.attr('data-orig-text')); + $stateEl.removeAttr('data-orig-text'); + } + } catch (e) {} + } + swal({ + title: 'Action Failed', + text: 'Failed to ' + action + ' container', + type: 'error' + }); + }); +} + +// Attach context menu to stack icon (like Docker tab's container context menu) +function addComposeStackContext(elementId) { + var $el = $('#' + elementId); + var stackId = $el.data('stackid'); + var project = $el.data('project'); + var projectName = $el.data('projectname'); + var isUp = $el.data('isup') == "1"; + var running = parseInt($el.data('running') || 0); + + var $row = $('#stack-row-' + stackId); + var path = $row.data('path'); + var profiles = $row.data('profiles') || []; + var webuiUrl = $row.data('webui') || ''; + var hasBuild = $row.data('hasbuild') == "1"; + var hasExistingContainers = false; + var hasKnownNetworks = false; + + // Prefer the rendered row data first; this reflects current known stack containers. + try { + var rowContainers = JSON.parse($row.attr('data-containers') || '[]'); + hasExistingContainers = Array.isArray(rowContainers) && rowContainers.length > 0; + } catch (e) { + hasExistingContainers = false; + } + + // Fallback to cached short IDs if data-containers is empty/unavailable. + if (!hasExistingContainers) { + var ctidsAttr = ($row.attr('data-ctids') || '').trim(); + hasExistingContainers = ctidsAttr.length > 0; + } + + // If details were loaded, use container network attachments as an additional signal. + // This also keeps behavior resilient during in-page state transitions. + try { + var cachedContainers = stackContainersCache[stackId] || []; + hasKnownNetworks = cachedContainers.some(function(c) { + return Array.isArray(c.networks) && c.networks.length > 0; + }); + } catch (e) { + hasKnownNetworks = false; + } + + var canComposeDownStopped = hasExistingContainers || hasKnownNetworks; + + // Check if updates are available for this stack + var hasUpdates = false; + if (stackUpdateStatus[project] && stackUpdateStatus[project].hasUpdate) { + hasUpdates = true; + } + + var opts = []; + context.settings({ + right: false, + above: 'auto' + }); + + // ===== STACK IS RUNNING ===== + if (isUp) { + // WebUI link (if configured) + if (webuiUrl) { + opts.push({ + text: 'WebUI', + icon: 'fa-globe', + action: function(e) { + e.preventDefault(); + var url = processWebUIUrl(webuiUrl); + if (isValidWebUIUrl(url)) { + window.open(url, '_blank'); + } + } + }); + opts.push({ + divider: true + }); + } + + // Compose Up (allows starting additional profile-scoped services) + opts.push({ + text: 'Compose Up', + icon: 'fa-play', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('up', path, profiles); + } else { + ComposeUp(path); + } + } + }); + + // Compose Down (stop and remove containers) + opts.push({ + text: 'Compose Down', + icon: 'fa-stop', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('down', path, profiles); + } else { + ComposeDown(path); + } + } + }); + + // Compose Stop (stop without removing) + opts.push({ + text: 'Compose Stop', + icon: 'fa-pause', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('stop', path, profiles); + } else { + ComposeStop(path); + } + } + }); + + // Compose Restart + opts.push({ + text: 'Compose Restart', + icon: 'fa-refresh', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('restart', path, profiles); + } else { + ComposeRestart(path); + } + } + }); + + opts.push({ + divider: true + }); + + // Update options based on whether updates are available + if (hasUpdates) { + // Update (when updates are available) + var updateLabel = hasBuild ? 'Update & Rebuild' : 'Update'; + opts.push({ + text: updateLabel, + icon: 'fa-cloud-download', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('update', path, profiles); + } else { + UpdateStack(path); + } + } + }); + } else { + // Force Update (when no updates detected) + var forceLabel = hasBuild ? 'Force Update & Rebuild' : 'Force Update'; + opts.push({ + text: forceLabel, + icon: 'fa-cloud-download', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('forceUpdate', path, profiles); + } else { + ForceUpdateStack(path); + } + } + }); + } + + // ===== STACK IS STOPPED ===== + } else { + // Compose Up + opts.push({ + text: 'Compose Up', + icon: 'fa-play', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('up', path, profiles); + } else { + ComposeUp(path); + } + } + }); + + // Compose Down (only when there are existing resources to remove) + if (canComposeDownStopped) { + opts.push({ + text: 'Compose Down', + icon: 'fa-stop', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('down', path, profiles); + } else { + ComposeDown(path); + } + } + }); + } + + opts.push({ + divider: true + }); + + // Pull/Build only (without starting) + var pullOnlyLabel = hasBuild ? 'Build' : 'Pull'; + opts.push({ + text: pullOnlyLabel, + icon: 'fa-download', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('pull', path, profiles); + } else { + ComposePull(path); + } + } + }); + + // Update (pull/build and start) + var updateLabel = hasBuild ? 'Build & Up' : 'Pull & Up'; + opts.push({ + text: updateLabel, + icon: 'fa-cloud-download', + action: function(e) { + e.preventDefault(); + if (profiles.length > 0) { + showProfileSelector('update', path, profiles); + } else { + UpdateStack(path); + } + } + }); + } + + opts.push({ + divider: true + }); + + // Check for Updates (always available) + opts.push({ + text: 'Check for Updates', + icon: 'fa-search', + action: function(e) { + e.preventDefault(); + checkStackUpdates(project); + } + }); + + // Edit Stack (always available) + opts.push({ + text: 'Edit Stack', + icon: 'fa-edit', + action: function(e) { + e.preventDefault(); + openEditorModalByProject(project, projectName); + } + }); + + opts.push({ + divider: true + }); + + // View Logs (always available) + opts.push({ + text: 'View Logs', + icon: 'fa-navicon', + action: function(e) { + e.preventDefault(); + ComposeLogs(project); + } + }); + + // View Last Cmd Log (always available) + opts.push({ + text: 'View Last Cmd Log', + icon: 'fa-list-alt', + action: function(e) { + e.preventDefault(); + ViewLastCmdLog(project, projectName); + } + }); + + opts.push({ + divider: true + }); + + // Delete Stack + if (!isUp) { + opts.push({ + text: 'Delete Stack', + icon: 'fa-trash', + action: function(e) { + e.preventDefault(); + deleteStackByProject(project, projectName); + } + }); + } else { + opts.push({ + text: 'Delete Stack (Stop first)', + icon: 'fa-trash', + disabled: true + }); + } + + context.destroy('#' + elementId); + context.attach('#' + elementId, opts); +} + +// Event delegation for docker-style container actions +$(document).on('click', '.docker-action[data-action]', function(e) { + e.preventDefault(); + var $action = $(this); + var $row = $action.closest('.docker-row'); + var containerName = $row.data('container'); + var action = $action.data('action'); + if (containerName && action) { + containerAction(containerName, action); + } +}); + +// Row click handler - expand/collapse stack details +$(document).on('click', 'tr.compose-sortable[id^="stack-row-"]', function(e) { + if (isComposeSortModeEnabled()) { + return; + } + + var $target = $(e.target); + + // Don't expand if clicking on interactive elements + if ($target.closest('[data-stackid]').length || // Stack icon (context menu) + $target.closest('.expand-icon').length || // Expand arrow + $target.closest('.compose-updatecolumn a').length || // Update links + $target.closest('.compose-updatecolumn .exec').length || // Update actions + $target.closest('.auto_start').length || // Autostart toggle + $target.closest('.switchButton').length || // Switch button wrapper + $target.closest('a').length || // Any link + $target.closest('button').length || // Any button + $target.closest('input').length) { // Any input + return; + } + + var stackId = this.id.replace('stack-row-', ''); + if (stackId) { + toggleStackDetails(stackId); + } +}); + +// Right-click anywhere on a stack row opens the stack context menu +$(document).on('contextmenu', 'tr.compose-sortable[id^="stack-row-"]', function(e) { + if (isComposeSortModeEnabled()) { + return; + } + + var $icon = $(this).find('[data-stackid]').first(); + if ($icon.length) { + e.preventDefault(); + $icon.trigger($.Event('click', { + pageX: e.pageX, + pageY: e.pageY + })); + } +}); + +// Right-click anywhere on a container detail row opens the container context menu +$(document).on('contextmenu', '#compose_stacks tr[data-container][data-stackid]', function(e) { + var $icon = $(this).find('.hand[id^="ct-"]').first(); + if ($icon.length) { + e.preventDefault(); + $icon.trigger($.Event('click', { + pageX: e.pageX, + pageY: e.pageY + })); + } +}); + +// Close actions menu when clicking outside +$(document).on('click', function(e) { + if (!$(e.target).closest('#stack-actions-modal, .stack-kebab-btn').length) { + closeStackActionsMenu(); + } +}); + +// Close actions menu on escape key +$(document).on('keydown', function(e) { + if (e.key === 'Escape') { + closeStackActionsMenu(); + } +}); + +// Event delegation for container refresh button +$(document).on('click', '.container-refresh-btn[data-stack-id]', function(e) { + e.preventDefault(); + var stackId = $(this).data('stack-id'); + var project = $('#stack-row-' + stackId).data('project'); + if (stackId && project) { + loadStackContainerDetails(stackId, project); + } +}); + +// Keyboard support for expand toggle (Enter/Space) +$(document).on('keydown', '.stack-expand-toggle', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + $(this).click(); + } +}); \ No newline at end of file diff --git a/source/compose.manager/javascript/composeManagerPageInit.js b/source/compose.manager/javascript/composeManagerPageInit.js new file mode 100644 index 00000000..51254b01 --- /dev/null +++ b/source/compose.manager/javascript/composeManagerPageInit.js @@ -0,0 +1,154 @@ +// Initialize editor modal after DOM is fully loaded +$(function() { + try { + updateModalOffset(); + $(window).on('resize', updateModalOffset); + initEditorModal(); + } catch (e) { + composeLogger('Editor init error (non-fatal)', { + error: e && e.toString() + }, 'user', 'warn', 'editorInit'); + } + // Attach Unraid folder/file browser to all path inputs on the page. + // The picker popup needs manual positioning for modals and overlay stacks. + if ($.fn.fileTreeAttach) { + var $pathInputs = $('input[data-pickroot]'); + composeBindFileTreeInputs($pathInputs, { + zIndex: 100010, + minWidth: 320, + addClass: true + }); + } +}); + +// Reorder Compose section above Docker Containers if configured +// Runs in its own $(function) to avoid being blocked by editor init errors +$(function() { + (function reorderComposeAboveDocker() { + if (!showComposeOnTop) return; + // In tabbed mode the sections live in separate tabs; reordering is not applicable + if ($('.tabs').length) return; + + var $content = $('div.content'); + if (!$content.length) return; + + // Locate the two title divs by their text content + // Use find() with a filter instead of children() to handle any nesting edge-cases + var $dockerTitle = null; + var $composeTitle = null; + $content.children('div.title').each(function() { + var txt = $(this).text().trim(); + if (!$dockerTitle && /Docker\s*Containers/i.test(txt)) $dockerTitle = $(this); + if (!$composeTitle && /Compose/i.test(txt) && !/Docker/i.test(txt)) $composeTitle = $(this); + }); + + if (!$dockerTitle || !$composeTitle) { + composeLogger('Reorder Compose above Docker skipped', { + dockerTitle: !!$dockerTitle, + composeTitle: !!$composeTitle + }, 'user', 'warn', 'reorderComposeAboveDocker'); + return; + } + + // Collect all nodes from the Compose title to the end of .content + var composeNodes = []; + var found = false; + $content.contents().each(function() { + if (this === $composeTitle[0]) found = true; + if (found) composeNodes.push(this); + }); + + // Move them before the Docker title + composeNodes.forEach(function(node) { + $content[0].insertBefore(node, $dockerTitle[0]); + }); + })(); +}); + +// Hide compose-managed containers from Docker Containers table if configured +// Runs in its own $(function) to avoid being blocked by other init errors +$(function() { + (function hideComposeContainersFromDocker() { + if (!hideComposeFromDocker) return; + + function getComposeContainerNames() { + var names = {}; + // Primary source: data-containers attribute on stack rows + // (populated by PHP at list-load time — always available) + $('#compose_stacks .compose-sortable[data-containers]').each(function() { + try { + var list = JSON.parse($(this).attr('data-containers') || '[]'); + for (var i = 0; i < list.length; i++) { + if (list[i]) names[list[i].toLowerCase()] = true; + } + } catch (e) {} + }); + // Fallback: stack detail rows (if any stacks have been expanded) + $('#compose_stacks .stack-details-row').each(function() { + $(this).find('tr[data-container]').each(function() { + var name = $(this).attr('data-container'); + if (name) names[name.toLowerCase()] = true; + }); + }); + // Fallback: stackUpdateStatus (populated after update checks) + if (typeof stackUpdateStatus !== 'undefined') { + for (var stackName in stackUpdateStatus) { + var info = stackUpdateStatus[stackName]; + if (info.containers) { + for (var i = 0; i < info.containers.length; i++) { + var n = info.containers[i].name; + if (n) names[n.toLowerCase()] = true; + } + } + } + } + return names; + } + + function doHide() { + var $dockerTable = $('#docker_list'); + if (!$dockerTable.length) return; + + var composeNames = getComposeContainerNames(); + if (Object.keys(composeNames).length === 0) return; + + $dockerTable.find('tr.sortable').each(function() { + var $row = $(this); + // Use a broad selector — just find the appname span anywhere in the first cell + var rowName = $row.find('td:first span.appname').first().text().trim(); + if (!rowName) rowName = $row.find('td:first').text().trim(); + + if (composeNames[rowName.toLowerCase()]) { + $row.hide(); + } + }); + } + + // Re-run whenever compose list reloads (event fired by composeLoadlist) + $(document).on('compose-list-loaded', doHide); + + // Watch for Docker table changes (rows load asynchronously via AJAX) + // Use polling until #docker_list exists, then attach MutationObserver + function attachDockerObserver() { + var dockerTable = document.getElementById('docker_list'); + if (dockerTable) { + var obs = new MutationObserver(function() { + setTimeout(doHide, 300); + }); + obs.observe(dockerTable, { + childList: true, + subtree: true + }); + // Initial run now that docker table exists + setTimeout(doHide, 500); + } else { + // docker_list not in DOM yet — retry + setTimeout(attachDockerObserver, 500); + } + } + attachDockerObserver(); + + // Also run after a generous delay as final fallback + setTimeout(doHide, 4000); + })(); +}); diff --git a/source/compose.manager/javascript/composeSortable.js b/source/compose.manager/javascript/composeSortable.js index 04321bd6..a0d2ce0d 100644 --- a/source/compose.manager/javascript/composeSortable.js +++ b/source/compose.manager/javascript/composeSortable.js @@ -37,14 +37,14 @@ function updateComposeLockButtonUI() { // ── Persist sort order ───────────────────────────────────────────── function saveComposeSortOrder() { - var projects = $('#compose_list > tr.compose-sortable').map(function() { + var projects = $('#compose_list > tr.compose-sortable').map(function () { return $(this).data('project'); }).get(); return $.post(caURL, { action: 'saveStackOrder', projects: projects - }).fail(function(xhr) { + }).fail(function (xhr) { composeLogger('failed', { status: xhr.status, response: xhr.responseText @@ -80,7 +80,7 @@ function normalizeComposeDetailsRowOrder($tbody) { return; } - $tbody.children('tr.compose-sortable').each(function() { + $tbody.children('tr.compose-sortable').each(function () { var $stackRow = $(this); var $detailsRow = getComposeDetailsRowForItem($stackRow); if ($detailsRow.length) { @@ -118,16 +118,16 @@ function initComposeSortable() { opacity: 0.5, zIndex: 9999, forcePlaceholderSize: true, - start: function(event, ui) { + start: function (event, ui) { var $detailsRow = getComposeDetailsRowForItem(ui.item); if ($detailsRow.length) { ui.item.data('compose-details-row', $detailsRow.detach()); } }, - update: function() { + update: function () { saveComposeSortOrder(); }, - stop: function(event, ui) { + stop: function (event, ui) { reattachComposeDetailsRow(ui.item); normalizeComposeDetailsRowOrder($tbody); } @@ -138,7 +138,7 @@ function initComposeSortable() { function syncComposeSortModeUI() { var unlocked = isComposeSortModeEnabled(); - $('#compose_stacks tr.compose-sortable').each(function() { + $('#compose_stacks tr.compose-sortable').each(function () { var $row = $(this); $row.find('.expand-icon').toggle(!unlocked); $row.find('.mover').toggle(unlocked); diff --git a/source/compose.manager/javascript/composeStackUtils.js b/source/compose.manager/javascript/composeStackUtils.js index 909811eb..c4d172b9 100644 --- a/source/compose.manager/javascript/composeStackUtils.js +++ b/source/compose.manager/javascript/composeStackUtils.js @@ -34,7 +34,7 @@ function deriveStackState(containers) { state = 'started'; } else { // Check for paused containers - var anyPaused = containers.some(function(c) { + var anyPaused = containers.some(function (c) { return c && !c.isRunning && c.state === 'paused'; }); state = (anyPaused && totalCount > 0) ? 'paused' : 'stopped'; diff --git a/source/compose.manager/scripts/common.sh b/source/compose.manager/scripts/common.sh index b4940341..d4c9d0b7 100644 --- a/source/compose.manager/scripts/common.sh +++ b/source/compose.manager/scripts/common.sh @@ -50,14 +50,28 @@ composeLogger() { logger -t 'compose.manager' -p "$priority" "${prefix} ${msg}" } -# Sanitize a string for use as a Docker Compose project name. -# Replaces spaces, dots, and dashes with underscores and lowercases. -sanitize() { - local s="${1?need a string}" - s="${s// /_}" - s="${s//./_}" - s="${s//-/_}" - echo "${s,,}" +# Canonical Docker Compose project-name sanitizer. +# Delegates to the dependency-free PHP helper so every code path shares one +# implementation. +canonicalize_project_name() { + local raw_name="${1-}" + local php_cmd="${COMPOSE_MANAGER_PHP:-php}" + local helper_file + helper_file="$(dirname "${BASH_SOURCE[0]}")/../include/ProjectNameSanitizer.php" + + local canonical_name + # shellcheck disable=SC2016 # $argv[] are PHP variables, not shell — single quotes are intentional. + canonical_name=$("$php_cmd" -r ' +require_once $argv[1]; +echo compose_manager_sanitize_project_name((string) ($argv[2] ?? "")); +' "$helper_file" "$raw_name" 2>/dev/null) + + if [ -z "$canonical_name" ]; then + composeLogger "Failed to canonicalize project name: $raw_name" error compose + return 1 + fi + + echo "$canonical_name" } # Find the compose file in a directory using Docker Compose spec priority. @@ -91,3 +105,186 @@ find_compose_override_file() { has_compose_file() { find_compose_file "$1" > /dev/null 2>&1 } + +# Resolve effective env file for a stack. +# Order: +# 1) /envpath when it points to a readable file +# 2) /.env (local fallback) +# +# Compose source is the indirect directory when /indirect exists, +# otherwise the stack directory itself. +# Empty envpath values are treated as unset. +# Invalid envpath values are ignored and fallback is attempted. +# Prints the resolved env file path when found and returns 0. +# Returns 1 when no usable env file is available. +resolve_stack_env_file() { + local stack_dir="$1" + local compose_source="$stack_dir" + + if [ -f "$stack_dir/indirect" ]; then + local indirect + indirect=$(< "$stack_dir/indirect") + if [ -n "$indirect" ]; then + compose_source="$indirect" + fi + fi + + if [ -f "$stack_dir/envpath" ]; then + local envpath + envpath=$(< "$stack_dir/envpath") + envpath="$(echo "$envpath" | xargs)" + if [ -n "$envpath" ] && [ -f "$envpath" ]; then + echo "$envpath" + return 0 + fi + if [ -n "$envpath" ]; then + composeLogger "Ignoring invalid envpath for stack '$stack_dir': $envpath" warning stack + fi + fi + + local local_env="$compose_source/.env" + if [ -f "$local_env" ]; then + echo "$local_env" + return 0 + fi + + return 1 +} + +# Load compose command args for a stack/action from the PHP builder. +# +# Populates global variables: +# COMPOSE_SPEC_PROJECT_NAME +# COMPOSE_SPEC_STACK_PATH +# COMPOSE_SPEC_PROJECT_DIR +# COMPOSE_SPEC_USE_DEFAULT_FILE_DISCOVERY +# COMPOSE_SPEC_ENV_FILE_PATH +# COMPOSE_SPEC_COMPOSE_FILES (array) +# COMPOSE_SPEC_PROFILES (array) +# +# Returns 0 on success, 1 on parse/build failure. +load_compose_action_spec() { + local compose_root="$1" + local project="$2" + local action="$3" + local stack_path="${4:-}" + + local php_cmd="${COMPOSE_MANAGER_PHP:-php}" + local args_script="/usr/local/emhttp/plugins/compose.manager/scripts/compose_args.php" + + local -a cmd=( + "$php_cmd" + "$args_script" + --compose-root "$compose_root" + --project "$project" + --action "$action" + --format tsv + ) + if [ -n "$stack_path" ]; then + cmd+=(--stack-path "$stack_path") + fi + + COMPOSE_SPEC_ERROR_MESSAGE="" + + local output="" + local stderr_file="" + local stderr_output="" + local command_status=0 + + stderr_file=$(mktemp /tmp/compose_args_stderr.XXXXXX 2>/dev/null || true) + if [ -n "$stderr_file" ]; then + output="$("${cmd[@]}" 2>"$stderr_file")" + command_status=$? + if [ -s "$stderr_file" ]; then + stderr_output=$(tr '\n' ' ' < "$stderr_file" | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//') + fi + rm -f "$stderr_file" + else + output="$("${cmd[@]}" 2>&1)" + command_status=$? + fi + + if [ $command_status -ne 0 ]; then + if [ -n "$stderr_output" ]; then + COMPOSE_SPEC_ERROR_MESSAGE="$stderr_output" + elif [ -n "$output" ]; then + COMPOSE_SPEC_ERROR_MESSAGE=$(echo "$output" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//') + else + COMPOSE_SPEC_ERROR_MESSAGE="compose args provider command failed" + fi + + composeLogger "Failed to resolve compose args for '$project' action '$action': $COMPOSE_SPEC_ERROR_MESSAGE" warning compose-args + return 1 + fi + + COMPOSE_SPEC_PROJECT_NAME="" + COMPOSE_SPEC_STACK_PATH="" + # shellcheck disable=SC2034 # Populated here, consumed by scripts that source common.sh. + COMPOSE_SPEC_PROJECT_DIR="" + # shellcheck disable=SC2034 # Populated here, consumed by scripts that source common.sh. + COMPOSE_SPEC_USE_DEFAULT_FILE_DISCOVERY="false" + # shellcheck disable=SC2034 # Populated here, consumed by scripts that source common.sh. + COMPOSE_SPEC_ENV_FILE_PATH="" + COMPOSE_SPEC_ERROR_MESSAGE="" + COMPOSE_SPEC_COMPOSE_FILES=() + COMPOSE_SPEC_PROFILES=() + + local result="" + while IFS=$'\t' read -r key value; do + case "$key" in + result) + result="$value" + ;; + message) + COMPOSE_SPEC_ERROR_MESSAGE="$value" + ;; + projectName) + COMPOSE_SPEC_PROJECT_NAME="$value" + ;; + stackPath) + COMPOSE_SPEC_STACK_PATH="$value" + ;; + projectDirectory) + # shellcheck disable=SC2034 # Value consumed by scripts after load_compose_action_spec returns. + COMPOSE_SPEC_PROJECT_DIR="$value" + ;; + useDefaultFileDiscovery) + COMPOSE_SPEC_USE_DEFAULT_FILE_DISCOVERY="$value" + ;; + envFilePath) + # shellcheck disable=SC2034 # Populated here, consumed by scripts that source common.sh. + COMPOSE_SPEC_ENV_FILE_PATH="$value" + ;; + composeFile) + COMPOSE_SPEC_COMPOSE_FILES+=("$value") + ;; + profile) + COMPOSE_SPEC_PROFILES+=("$value") + ;; + esac + done <<< "$output" + + if [ "$result" != "success" ]; then + if [ -z "$COMPOSE_SPEC_ERROR_MESSAGE" ]; then + COMPOSE_SPEC_ERROR_MESSAGE="compose args provider returned an invalid response" + fi + composeLogger "Compose args resolution failed for '$project': $COMPOSE_SPEC_ERROR_MESSAGE" warning compose-args + return 1 + fi + + if [ -z "$COMPOSE_SPEC_PROJECT_NAME" ]; then + composeLogger "Compose args resolution returned incomplete data for '$project'" warning compose-args + return 1 + fi + + if [ "${COMPOSE_SPEC_USE_DEFAULT_FILE_DISCOVERY}" != "true" ] && [ ${#COMPOSE_SPEC_COMPOSE_FILES[@]} -eq 0 ]; then + composeLogger "Compose args resolution returned no compose files for '$project'" warning compose-args + return 1 + fi + + if [ -z "$COMPOSE_SPEC_STACK_PATH" ]; then + COMPOSE_SPEC_STACK_PATH="$stack_path" + fi + + return 0 +} diff --git a/source/compose.manager/scripts/compose.sh b/source/compose.manager/scripts/compose.sh index 4daf9561..0b0fefb9 100755 --- a/source/compose.manager/scripts/compose.sh +++ b/source/compose.manager/scripts/compose.sh @@ -11,8 +11,8 @@ export HOME=/root LOCK_TIMEOUT=${COMPOSE_LOCK_TIMEOUT:-30} LOCK_DIR="/var/run/compose.manager" -SHORT=e:,c:,f:,p:,d:,o:,g:,s: -LONG=env,command:,file:,project_name:,project_dir:,override:,profile:,debug,recreate,stack-path: +SHORT=e:,c:,f:,p:,d:,o:,g:,s:,w: +LONG=env,command:,file:,project_name:,project_dir:,override:,profile:,debug,recreate,stack-path:,workdir: OPTS=$(getopt -a -n compose --options $SHORT --longoptions $LONG -- "$@") eval set -- "$OPTS" @@ -22,6 +22,7 @@ env_args=() file_args=() profile_names=() profile_args=() +project_dir_args=() cmd_args=() stack_path="" debug=false @@ -149,6 +150,15 @@ do profile_names+=("$2") shift 2 ;; + -w | --workdir ) + if [ -d "$2" ]; then + project_dir_args=("--project-directory" "$2") + else + log_msg "ERROR" "Project directory does not exist: $2" + exit 1 + fi + shift 2 + ;; --recreate ) cmd_args+=("--force-recreate") shift; @@ -178,16 +188,19 @@ for profile_name in "${profile_names[@]}"; do done # Build the compose base command as an array (no eval needed) -compose_base=(docker compose "${env_args[@]}" "${file_args[@]}" "${profile_args[@]}") +compose_base=(docker compose "${project_dir_args[@]}" "${env_args[@]}" "${file_args[@]}" "${profile_args[@]}") -# Sanitize the project name for Docker Compose (must be lowercase alphanumeric, hyphens, underscores) -name=$(echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/_/g; s/__*/_/g; s/^[_-]*//; s/[_-]*$//') +# Canonicalize project name through shared PHP sanitizer. +if ! name=$(canonicalize_project_name "$name"); then + log_msg "ERROR" "Could not canonicalize project name" + exit 1 +fi # Acquire lock for operations that modify state (not for read-only commands) case $command in up|down|pull|update|stop) - # Sanitize name for lock file (same as PHP sanitizeStr) - lock_name=$(echo "$name" | tr ' .-' '___' | tr '[:upper:]' '[:lower:]') + # Lock by canonical project name so every path uses the same stack identity. + lock_name="$name" if ! acquire_lock "$lock_name"; then exit 1 fi @@ -387,9 +400,9 @@ case $command in logs) if [ "$debug" = true ]; then - log_msg "DEBUG" "${compose_base[*]} logs -f" + log_msg "DEBUG" "${compose_base[*]} -p $name logs -f" fi - "${compose_base[@]}" logs -f 2>&1 + "${compose_base[@]}" -p "$name" logs -f 2>&1 exit_code=$? if [ $exit_code -ne 0 ]; then log_msg "ERROR" "Failed to stream logs (exit code: $exit_code)" diff --git a/source/compose.manager/scripts/compose_args.php b/source/compose.manager/scripts/compose_args.php new file mode 100644 index 00000000..24d9b53c --- /dev/null +++ b/source/compose.manager/scripts/compose_args.php @@ -0,0 +1,72 @@ + $data + */ +function emitSuccess(array $data, string $format): void +{ + if ($format === 'tsv') { + echo "result\tsuccess\n"; + echo "action\t" . ($data['action'] ?? '') . "\n"; + echo "projectName\t" . ($data['projectName'] ?? '') . "\n"; + echo "stackPath\t" . ($data['stackPath'] ?? '') . "\n"; + echo "projectDirectory\t" . ($data['projectDirectory'] ?? '') . "\n"; + echo "useDefaultFileDiscovery\t" . ((($data['useDefaultFileDiscovery'] ?? false) ? 'true' : 'false')) . "\n"; + echo "envFilePath\t" . ($data['envFilePath'] ?? '') . "\n"; + foreach (($data['composeFiles'] ?? []) as $filePath) { + echo "composeFile\t" . $filePath . "\n"; + } + foreach (($data['profiles'] ?? []) as $profile) { + echo "profile\t" . $profile . "\n"; + } + return; + } + + echo json_encode(['result' => 'success', 'data' => $data], JSON_UNESCAPED_SLASHES) . PHP_EOL; +} + +function emitError(string $message, string $format): void +{ + if ($format === 'tsv') { + echo "result\terror\n"; + echo "message\t" . $message . "\n"; + return; + } + + echo json_encode(['result' => 'error', 'message' => $message]) . PHP_EOL; +} + +if ($composeRoot === '' || $project === '' || $action === '') { + fwrite(STDERR, "Usage: compose_args.php --compose-root --project --action [--stack-path ]\n"); + emitError('Missing required arguments', $format); + exit(2); +} + +try { + $data = ComposeCommandBuilder::fromProject($composeRoot, $project, $action, $stackPath); + emitSuccess($data, $format); + exit(0); +} catch (\Throwable $e) { + emitError($e->getMessage(), $format); + exit(1); +} diff --git a/source/compose.manager/scripts/compose_autoupdate.sh b/source/compose.manager/scripts/compose_autoupdate.sh index 5bcbd6ed..5a9cafac 100644 --- a/source/compose.manager/scripts/compose_autoupdate.sh +++ b/source/compose.manager/scripts/compose_autoupdate.sh @@ -1,19 +1,104 @@ -#!/bin/sh +#!/bin/bash # Compose auto-update runner # Args: +# or: with COMPOSE_FILE_LIST / COMPOSE_ENV_FILE set. # shellcheck disable=SC1091 . "$(dirname "$0")/common.sh" -COMPOSE_FILE="$1" +COMPOSE_FILE_ARG="$1" PROJECT_NAME="$2" +COMPOSE_FILE_LIST="${COMPOSE_FILE_LIST:-}" +COMPOSE_ENV_FILE="${COMPOSE_ENV_FILE:-}" +COMPOSE_PROJECT_DIR="${COMPOSE_PROJECT_DIR:-}" +COMPOSE_FILE="${COMPOSE_FILE_ARG:-${COMPOSE_FILE:-}}" + +# If this script is invoked by the background runner, the first positional +# argument is the project name and compose files are supplied through env vars. +if [ -z "$PROJECT_NAME" ] && [ -n "$COMPOSE_FILE_LIST" ]; then + PROJECT_NAME="$COMPOSE_FILE_ARG" + COMPOSE_FILE_ARG="" +fi + +# If the script is invoked with a single arg and COMPOSE_FILE is set via +# the environment, treat the first arg as project name. +if [ -z "$PROJECT_NAME" ] && [ -n "$COMPOSE_FILE" ]; then + PROJECT_NAME="$COMPOSE_FILE_ARG" + COMPOSE_FILE_ARG="" +fi + NOTIFY="/usr/local/emhttp/webGui/scripts/notify" LOCK_DIR="/var/run/compose.manager" LOCK_TIMEOUT=${COMPOSE_LOCK_TIMEOUT:-30} COMMAND_TIMEOUT=${COMPOSE_COMMAND_TIMEOUT:-1800} -if [ -z "$COMPOSE_FILE" ] || [ -z "$PROJECT_NAME" ]; then - echo "Usage: $0 " +trim() { + local var="$*" + # Remove leading whitespace + var="${var#"${var%%[![:space:]]*}"}" + # Remove trailing whitespace + var="${var%"${var##*[![:space:]]}"}" + echo "$var" +} + +compose_file_args=() +env_file_args=() +project_dir_args=() +build_compose_file_args() { + local file_spec + file_spec="$(trim "$1")" + local sep="${COMPOSE_PATH_SEPARATOR:-:}" + local parts + + if [ -z "$file_spec" ]; then + return 0 + fi + + case "$file_spec" in + *"$sep"*) + IFS="$sep" read -r -a parts <<< "$file_spec" + ;; + *) + parts=("$file_spec") + ;; + esac + + for file in "${parts[@]}"; do + file="$(trim "$file")" + if [ -n "$file" ]; then + compose_file_args+=("-f" "$file") + fi + done +} + +build_env_file_args() { + local path + path="$(trim "$1")" + if [ -n "$path" ] && [ -f "$path" ]; then + env_file_args+=(--env-file "$path") + fi +} + +if [ -z "$PROJECT_NAME" ]; then + echo "Usage: $0 " >&2 + echo " or: COMPOSE_FILE_LIST=... COMPOSE_ENV_FILE=... $0 " >&2 + exit 2 +fi + +if [ -n "$COMPOSE_FILE_LIST" ]; then + build_compose_file_args "$COMPOSE_FILE_LIST" +elif [ -n "$COMPOSE_FILE_ARG" ]; then + build_compose_file_args "$COMPOSE_FILE_ARG" +fi + +build_env_file_args "$COMPOSE_ENV_FILE" + +if [ -n "$COMPOSE_PROJECT_DIR" ] && [ -d "$COMPOSE_PROJECT_DIR" ]; then + project_dir_args=("--project-directory" "$COMPOSE_PROJECT_DIR") +fi + +if [ ${#compose_file_args[@]} -eq 0 ] && [ ${#project_dir_args[@]} -eq 0 ]; then + echo "No compose file paths or project directory were provided" >&2 exit 2 fi @@ -52,7 +137,7 @@ summarize_output() { # Get current image digests before pull # Uses docker compose images to list service images, then inspects each for RepoDigests get_image_digests() { - docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" images -q 2>/dev/null | while read -r img_id; do + docker compose "${project_dir_args[@]}" "${compose_file_args[@]}" "${env_file_args[@]}" -p "$PROJECT_NAME" images -q 2>/dev/null | while read -r img_id; do if [ -n "$img_id" ]; then docker inspect --format='{{index .RepoDigests 0}}' "$img_id" 2>/dev/null || echo "$img_id" fi @@ -63,7 +148,7 @@ OLD_DIGESTS=$(get_image_digests || true) # Run pull and capture output (timeout prevents indefinite hangs on unresponsive registries) # --ignore-buildable: skip services with build sections (they should be rebuilt, not pulled) -timeout "$COMMAND_TIMEOUT" docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" pull --ignore-buildable > "$OUT" 2>&1 || RC=$? +timeout "$COMMAND_TIMEOUT" docker compose "${project_dir_args[@]}" "${compose_file_args[@]}" "${env_file_args[@]}" -p "$PROJECT_NAME" pull --ignore-buildable > "$OUT" 2>&1 || RC=$? if [ "$RC" -ne 0 ]; then ERRMSG="Auto-update pull failed for '$PROJECT_NAME'. Recent output: $(summarize_output)" @@ -82,7 +167,7 @@ NEW_DIGESTS=$(get_image_digests || true) # Compare digests to determine if any images were updated if [ "$OLD_DIGESTS" != "$NEW_DIGESTS" ]; then # Images changed - run recreate/up - timeout "$COMMAND_TIMEOUT" docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d >> "$OUT" 2>&1 || RC=$? + timeout "$COMMAND_TIMEOUT" docker compose "${project_dir_args[@]}" "${compose_file_args[@]}" "${env_file_args[@]}" -p "$PROJECT_NAME" up -d >> "$OUT" 2>&1 || RC=$? if [ "$RC" -ne 0 ]; then ERRMSG="Auto-update up failed for '$PROJECT_NAME'. Recent output: $(summarize_output)" diff --git a/source/compose.manager/sheets/ComboButton.css b/source/compose.manager/sheets/ComboButton.css index 32fe4c3c..b714bae7 100644 --- a/source/compose.manager/sheets/ComboButton.css +++ b/source/compose.manager/sheets/ComboButton.css @@ -42,13 +42,16 @@ color: var(--text-color); border-radius: 8px; min-width: 350px; - max-width: 400px; + max-width: 90vw; width: 100%; + max-height: 80vh; padding: 0; overflow: hidden; font-family: inherit; border: 1px solid var(--border-color); box-shadow: var(--panel-box-shadow); + display: flex; + flex-direction: column; } .compose-modal-header { @@ -64,7 +67,20 @@ .compose-modal-body { padding: 24px; + overflow-y: auto; + max-height: 60vh; + min-height: 40px; + /* If body has a list, let it scroll inside */ + display: flex; + flex-direction: column; + } + /* Helper: scrollable container list inside modal body */ + .compose-modal-list { + overflow-y: auto; + max-height: 40vh; + margin-bottom: 16px; } + .compose-modal-footer { background: var(--alt-background-color); diff --git a/source/compose.manager/sheets/EditorModal.css b/source/compose.manager/sheets/EditorModal.css index 9d89d422..dca07b95 100644 --- a/source/compose.manager/sheets/EditorModal.css +++ b/source/compose.manager/sheets/EditorModal.css @@ -162,6 +162,32 @@ bottom: 0; } +.editor-file-wrap { + flex: 1; + overflow: hidden; + position: relative; +} + +.editor-file-wrap > .editor-container { + height: 100%; + min-height: 0; +} + +.editor-empty-state { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + color: #aaa; +} + +.editor-empty-state p { + margin: 0; + text-align: center; +} + .editor-validation { padding: 10px 20px; background-color: var(--background-color); @@ -485,30 +511,126 @@ color: var(--brand-orange); } -.editor-modal-footer { +/* Labels panel header row (description + toggle side-by-side) */ +.labels-panel-header-row { display: flex; + align-items: flex-start; justify-content: space-between; + gap: 16px; +} + +.labels-panel-header-row p { + margin: 0; + flex: 1; +} + +/* Override editor view (advanced mode) */ +.labels-override-view { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.labels-override-header { + padding: 12px 20px; + background-color: var(--dynamix-tablesorter-tbody-row-alt-bg-color); + border-bottom: 1px solid var(--border-color); +} + +.labels-override-header-row { + display: flex; align-items: center; + justify-content: space-between; + gap: 16px; +} + +.labels-override-title { + font-size: 1.1rem; + font-weight: bold; + color: var(--brand-orange); + margin-right: 12px; +} + +.labels-override-desc { + font-size: 0.95rem; + color: var(--alt-text-color); +} + +.labels-override-editor-wrap { + flex: 1; +} + +.editor-modal-footer { + display: flex; + justify-content: space-between; + align-items: flex-end; padding: 12px 20px; background-color: var(--dynamix-tablesorter-tbody-row-alt-bg-color); border-top: 1px solid var(--border-color); border-radius: 0 0 8px 8px; + gap: 16px; } .editor-footer-left { display: flex; - align-items: center; - gap: 15px; + flex: 1; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 8px; } .editor-footer-right { display: flex; + align-items: center; gap: 10px; + flex-shrink: 0; } -.editor-file-info { +.editor-change-count { + color: var(--alt-text-color); + font-size: 0.9rem; + min-width: 80px; + text-align: right; +} + +.editor-paths { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + min-width: 0; +} + +.editor-path-row { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} + +.editor-path-label { + color: var(--alt-text-color); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + flex-shrink: 0; +} + +.editor-path-value { + color: var(--text-color); + font-family: var(--font-bitstream); + font-size: 1rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-path-value[data-empty="true"] { color: var(--alt-text-color); - font-size: 1.1rem; } .editor-btn { @@ -557,4 +679,27 @@ padding: 2px 6px; border-radius: 3px; font-family: monospace; +} + +@media (max-width: 1100px) { + .editor-modal-footer { + flex-direction: column; + align-items: stretch; + } + + .editor-footer-right { + justify-content: flex-end; + } +} + +@media (max-width: 760px) { + .editor-path-row { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .editor-footer-right { + flex-wrap: wrap; + } } \ No newline at end of file diff --git a/source/pkg_build.sh b/source/pkg_build.sh index 960baf2b..7cc84ca7 100755 --- a/source/pkg_build.sh +++ b/source/pkg_build.sh @@ -46,6 +46,75 @@ run_quiet() { # Note: run_quiet uses tee so log is live and stays in /tmp/build.log. +download_file_quiet() { + local url="$1" + local output_path="$2" + local label="$3" + + # Keep network transfer output out of console; preserve details in build log on failure. + # shellcheck disable=SC2046 + if ! wget $(wget_args) -q -O "$output_path" "$url" >>"$LOG_FILE" 2>&1; then + echo "Download failed for ${label}: ${url}" | tee -a "$LOG_FILE" + exit 9 + fi +} + +DOWNLOAD_CACHE_DIR="${DOWNLOAD_CACHE_DIR:-}" +if [[ -n "$DOWNLOAD_CACHE_DIR" ]]; then + mkdir -p "$DOWNLOAD_CACHE_DIR" +fi + +sha256_file() { + sha256sum "$1" | awk '{print $1}' +} + +download_with_sha_cache() { + local artifact_url="$1" + local checksum_url="$2" + local artifact_name="$3" + local checksum_name="${artifact_name}.sha256" + local expected_sha="" + local cache_file="" + + # Always refresh checksum so cache validation tracks upstream updates. + echo "Fetching checksum for $artifact_name..." | tee -a "$LOG_FILE" + download_file_quiet "$checksum_url" "$checksum_name" "$artifact_name checksum" + expected_sha="$(awk 'NF {print $1; exit}' "$checksum_name")" + if [[ -z "$expected_sha" ]]; then + echo "Failed to parse SHA256 from $checksum_name" | tee -a "$LOG_FILE" + exit 7 + fi + + cache_file="${DOWNLOAD_CACHE_DIR%/}/${artifact_name}" + if [[ -n "$DOWNLOAD_CACHE_DIR" && -f "$cache_file" ]]; then + local cached_sha + cached_sha="$(sha256_file "$cache_file")" + if [[ "$cached_sha" == "$expected_sha" ]]; then + echo "Reusing cached $artifact_name (SHA256 match: $cached_sha) from $cache_file" | tee -a "$LOG_FILE" + run_quiet cp "$cache_file" "$artifact_name" + else + echo "Cached $artifact_name SHA mismatch (cached=$cached_sha expected=$expected_sha); re-downloading." | tee -a "$LOG_FILE" + run_quiet rm -f "$cache_file" + fi + fi + + if [[ ! -f "$artifact_name" ]]; then + echo "Downloading $artifact_name..." | tee -a "$LOG_FILE" + download_file_quiet "$artifact_url" "$artifact_name" "$artifact_name" + fi + + local artifact_sha + artifact_sha="$(sha256_file "$artifact_name")" + if [[ "$artifact_sha" != "$expected_sha" ]]; then + echo "Downloaded $artifact_name SHA mismatch; expected $expected_sha got $artifact_sha" | tee -a "$LOG_FILE" + exit 8 + fi + + if [[ -n "$DOWNLOAD_CACHE_DIR" ]]; then + run_quiet cp "$artifact_name" "$cache_file" + fi +} + wget_args() { local args=("--https-only" "--secure-protocol=TLSv1_2") if [[ -n "$CA_CERT" && -f "$CA_CERT" ]]; then @@ -58,9 +127,10 @@ wget_args() { echo "Installing unzip dependency..." INFOZIP_PKG="infozip-6.0-x86_64-8.txz" -run_quiet wget $(wget_args) "https://mirrors.slackware.com/slackware/slackware64-current/slackware64/a/${INFOZIP_PKG}" -run_quiet wget $(wget_args) "https://mirrors.slackware.com/slackware/slackware64-current/slackware64/a/${INFOZIP_PKG}.sha256" -run_quiet sha256sum -c "${INFOZIP_PKG}.sha256" +download_with_sha_cache \ + "https://mirrors.slackware.com/slackware/slackware64-current/slackware64/a/${INFOZIP_PKG}" \ + "https://mirrors.slackware.com/slackware/slackware64-current/slackware64/a/${INFOZIP_PKG}.sha256" \ + "$INFOZIP_PKG" run_quiet rm -f "${INFOZIP_PKG}.sha256" run_quiet upgradepkg --install-new "${INFOZIP_PKG}" @@ -80,9 +150,10 @@ run_quiet chmod -R +x "$tmpdir/usr/local/emhttp/plugins/compose.manager/scripts/ run_quiet chmod -R +x "$tmpdir/usr/local/emhttp/plugins/compose.manager/include/" echo "Downloading Docker Compose CLI plugin v${COMPOSE_VERSION}..." -run_quiet wget $(wget_args) "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" -run_quiet wget $(wget_args) "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64.sha256" -run_quiet sha256sum -c docker-compose-linux-x86_64.sha256 | grep -q OK || exit 4 +download_with_sha_cache \ + "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \ + "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64.sha256" \ + "docker-compose-linux-x86_64" run_quiet rm docker-compose-linux-x86_64.sha256 echo "Installing Docker Compose CLI plugin v${COMPOSE_VERSION}..." diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f73f7a7b..058cc6d6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -27,6 +27,19 @@ function composeLogger($message, $data = null, $type = 'daemon', $level = 'info' } } +// Pre-define /boot and /var/lib/docker constants before Defines.php is loaded +// so they resolve to writable temp paths on CI where /boot does not exist. +$_bootConfigTemp = sys_get_temp_dir() . '/compose_manager_boot_config'; +if (!is_dir($_bootConfigTemp)) { + mkdir($_bootConfigTemp, 0755, true); +} +define('COMPOSE_UPDATE_STATUS_FILE', $_bootConfigTemp . '/update-status.json'); +define('COMPOSE_STACK_ORDER_FILE', $_bootConfigTemp . '/stack-order.json'); +define('UNRAID_UPDATE_STATUS_FILE', sys_get_temp_dir() . '/unraid-update-status.json'); +define('PENDING_RECHECK_FILE', $_bootConfigTemp . '/pending-recheck.json'); +define('COMPOSE_TTYD_SOCKET_DIR', sys_get_temp_dir()); +unset($_bootConfigTemp); + // Load the plugin-tests framework require_once __DIR__ . '/framework/src/php/bootstrap.php'; diff --git a/tests/framework b/tests/framework index 01aac4b5..5aed44fb 160000 --- a/tests/framework +++ b/tests/framework @@ -1 +1 @@ -Subproject commit 01aac4b586ce269c4149ef9e87b6f5763276deee +Subproject commit 5aed44fbd9b0781a9472a5c5e125cb2b428289c0 diff --git a/tests/unit/AutoupdateTest.php b/tests/unit/AutoupdateTest.php index 42ac3b3b..1b82f2c9 100644 --- a/tests/unit/AutoupdateTest.php +++ b/tests/unit/AutoupdateTest.php @@ -28,7 +28,11 @@ protected function setUp(): void $shim = <<<'PHP' $argv, + ])); + } exit(0); PHP; file_put_contents($this->wrapperPath, $shim); @@ -82,7 +86,7 @@ public function testRunNowExecutesScript(): void { // Create a fake stack under compose_root so path validation passes global $plugin_root, $compose_root; - $tmp = $compose_root . '/autoupdate_test_' . getmypid(); + $tmp = $compose_root . '/PrintMaster_' . getmypid(); if (!is_dir($tmp)) mkdir($tmp, 0755, true); file_put_contents($tmp . '/docker-compose.yml', "services:\n a:\n image: busybox\n"); @@ -105,6 +109,13 @@ public function testRunNowExecutesScript(): void $this->assertEquals(0, $r['rc']); $this->assertFileExists($marker); + $payload = json_decode((string) file_get_contents($marker), true); + $this->assertIsArray($payload); + $this->assertIsArray($payload['argv'] ?? null); + $this->assertGreaterThanOrEqual(3, count($payload['argv'])); + $expectedProjectName = \StackInfo::sanitizeProjectString(basename($tmp)); + $this->assertSame($expectedProjectName, $payload['argv'][2]); + // cleanup unlink($marker); unlink($scriptPath); diff --git a/tests/unit/ComposeCommandBuilderTest.php b/tests/unit/ComposeCommandBuilderTest.php new file mode 100644 index 00000000..4bcb568b --- /dev/null +++ b/tests/unit/ComposeCommandBuilderTest.php @@ -0,0 +1,144 @@ +tempRoot = sys_get_temp_dir() . '/compose_builder_test_' . getmypid() . '_' . uniqid(); + mkdir($this->tempRoot, 0755, true); + } + + protected function tearDown(): void + { + \StackInfo::clearCache(); + $this->recursiveDelete($this->tempRoot); + parent::tearDown(); + } + + private function recursiveDelete(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $entries = scandir($dir); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + if (is_dir($path)) { + $this->recursiveDelete($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } + + public function testBuildForActionUsesDefaultProfilesForUp(): void + { + $stack = 'profiles-up'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + file_put_contents($stackDir . '/default_profile', 'dev,prod'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $spec = \ComposeCommandBuilder::buildForAction($info, 'up'); + + $this->assertSame('up', $spec['action']); + $this->assertSame(['dev', 'prod'], $spec['profiles']); + $this->assertSame($info->projectName, $spec['projectName']); + $this->assertSame($stackDir, $spec['stackPath']); + } + + public function testBuildForActionUsesRunningProfilesForUpdate(): void + { + $stack = 'profiles-update-running'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + file_put_contents($stackDir . '/default_profile', 'dev,prod'); + file_put_contents($stackDir . '/running_profiles', 'hotfix,metrics'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $spec = \ComposeCommandBuilder::buildForAction($info, 'update'); + + $this->assertSame(['hotfix', 'metrics'], $spec['profiles']); + } + + public function testBuildForActionFallsBackToDefaultProfilesForUpdate(): void + { + $stack = 'profiles-update-default'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + file_put_contents($stackDir . '/default_profile', 'dev,prod'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $spec = \ComposeCommandBuilder::buildForAction($info, 'update'); + + $this->assertSame(['dev', 'prod'], $spec['profiles']); + } + + public function testBuildForActionUsesWildcardForDown(): void + { + $stack = 'profiles-down'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + file_put_contents($stackDir . '/default_profile', 'dev,prod'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $spec = \ComposeCommandBuilder::buildForAction($info, 'down'); + + $this->assertSame(['*'], $spec['profiles']); + } + + public function testBuildForActionIncludesEffectiveEnvPathFallback(): void + { + $stack = 'env-fallback'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + file_put_contents($stackDir . '/.env', "KEY=value\n"); + file_put_contents($stackDir . '/envpath', '/not/real/path.env'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $spec = \ComposeCommandBuilder::buildForAction($info, 'up'); + + $this->assertSame($stackDir . '/.env', $spec['envFilePath']); + } + + public function testFromProjectUsesProvidedStackPathOverride(): void + { + $stack = 'stack-path-override'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + + $customStackPath = '/custom/stack/path'; + $spec = \ComposeCommandBuilder::fromProject($this->tempRoot, $stack, 'up', $customStackPath); + + $this->assertSame($customStackPath, $spec['stackPath']); + } +} diff --git a/tests/unit/ComposeManagerMainSourceTest.php b/tests/unit/ComposeManagerMainSourceTest.php index 24a4e2e6..c2830758 100644 --- a/tests/unit/ComposeManagerMainSourceTest.php +++ b/tests/unit/ComposeManagerMainSourceTest.php @@ -15,29 +15,40 @@ class ComposeManagerMainSourceTest extends TestCase { private string $mainPagePath; + private string $mainScriptPath; + private string $dockerStartedEventPath; protected function setUp(): void { parent::setUp(); $this->mainPagePath = __DIR__ . '/../../source/compose.manager/include/ComposeManager.php'; + $this->mainScriptPath = __DIR__ . '/../../source/compose.manager/javascript/composeManagerMain.js'; + $this->dockerStartedEventPath = __DIR__ . '/../../source/compose.manager/event/docker_started'; $this->assertFileExists($this->mainPagePath, 'ComposeManager.php must exist'); + $this->assertFileExists($this->mainScriptPath, 'composeManagerMain.js must exist'); + $this->assertFileExists($this->dockerStartedEventPath, 'event/docker_started must exist'); } - private function getPageSource(): string + private function getPhpSource(): string { return file_get_contents($this->mainPagePath); } + private function getJsSource(): string + { + return file_get_contents($this->mainScriptPath); + } + public function testCpuSpecCountHelperExists(): void { - $source = $this->getPageSource(); + $source = $this->getPhpSource(); $this->assertStringContainsString('function compose_manager_cpu_spec_count($cpuSpec)', $source); $this->assertStringContainsString('explode(\',\', trim((string)$cpuSpec))', $source); } public function testCpuCountSumsAllCpuSpecs(): void { - $source = $this->getPageSource(); + $source = $this->getPhpSource(); $this->assertStringContainsString('$cpuCount = 0;', $source); $this->assertStringContainsString('foreach ($cpus as $cpuSpec)', $source); $this->assertStringContainsString('$cpuCount += compose_manager_cpu_spec_count($cpuSpec);', $source); @@ -45,7 +56,7 @@ public function testCpuCountSumsAllCpuSpecs(): void public function testCpuCountHasFallbackGuards(): void { - $source = $this->getPageSource(); + $source = $this->getPhpSource(); $this->assertStringContainsString("trim(shell_exec('nproc 2>/dev/null') ?: '1')", $source); $this->assertStringContainsString('if ($cpuCount <= 0) {', $source); $this->assertStringContainsString('$cpuCount = 1;', $source); @@ -53,7 +64,7 @@ public function testCpuCountHasFallbackGuards(): void public function testStackAggregationTracksMemoryLimits(): void { - $source = $this->getPageSource(); + $source = $this->getJsSource(); $this->assertStringContainsString('var totalMemLimitBytes = 0;', $source); $this->assertStringContainsString('totalMemLimitBytes += composeLoadById[ctId].memLimitBytes || 0;', $source); $this->assertStringContainsString('var stackMemTotalBytes = 0;', $source); @@ -62,14 +73,14 @@ public function testStackAggregationTracksMemoryLimits(): void public function testDockerLoadMapStoresParsedLimitBytes(): void { - $source = $this->getPageSource(); + $source = $this->getJsSource(); $this->assertStringContainsString('var memPair = parseMemUsagePair(parts[2]);', $source); $this->assertStringContainsString('memLimitBytes: memPair.limit,', $source); } public function testComposeCustomTagSchemaSupportIsDeclared(): void { - $source = $this->getPageSource(); + $source = $this->getJsSource(); $this->assertStringContainsString("var customTags = ['!override', '!reset', '!merge'];", $source); $this->assertStringContainsString('function buildComposeYamlSchema()', $source); $this->assertStringContainsString("if (typeof jsyaml !== 'undefined') {", $source); @@ -78,11 +89,176 @@ public function testComposeCustomTagSchemaSupportIsDeclared(): void public function testLabelSaveBlocksTaggedOverrideRewrite(): void { - $source = $this->getPageSource(); + $source = $this->getJsSource(); $this->assertStringContainsString('overrideHasCustomTags: composeYamlContainsCustomTags(overrideData.content || \'\')', $source); $this->assertStringContainsString('WebUI labels cannot be saved because compose.override.yaml uses !override, !reset, or !merge tags.', $source); } + public function testInvalidJsonSaveResponseIsTreatedAsFailure(): void + { + $source = $this->getJsSource(); + $this->assertStringContainsString('Failed to save ' . "' + saveTarget + '" . '. Invalid server response.', $source); + $this->assertStringContainsString('return false;', $source); + } + + public function testManualModeSaveAllUsesPartialSaveWarning(): void + { + $source = $this->getJsSource(); + $this->assertStringContainsString('var skippedManualLabels = false;', $source); + $this->assertStringContainsString('skippedManualLabels = true;', $source); + $this->assertStringContainsString('title: "Partially Saved"', $source); + $this->assertStringContainsString('Non-label changes were saved.', $source); + } + + public function testOverrideManagementModePersistsOnSaveSettings(): void + { + $phpSource = $this->getPhpSource(); + $jsSource = $this->getJsSource(); + + $this->assertStringContainsString('id="settings-override-management"', $phpSource); + $this->assertStringNotContainsString('id="settings-override-management" onchange=', $phpSource); + $this->assertStringContainsString("editorModal.modifiedSettings.has('labels-view-mode')", $jsSource); + $this->assertStringContainsString("editorModal.pendingLabelsViewMode = mode;", $jsSource); + $this->assertStringContainsString("action: 'setLabelsViewMode'", $jsSource); + $this->assertStringContainsString("editorModal.originalSettings['labels-view-mode'] = labelsViewMode;", $jsSource); + $this->assertStringContainsString("toggleLabelsViewMode(labelsViewMode === 'advanced', true);", $jsSource); + $this->assertStringContainsString("$('#editor-tab-labels-text').text(labelsViewMode === 'advanced' ? 'Override' : 'Labels');", $jsSource); + } + + public function testEditorHasOkayApplyCloseButtonsAndChangeCounter(): void + { + $phpSource = $this->getPhpSource(); + $jsSource = $this->getJsSource(); + + $this->assertStringContainsString('id="editor-change-count"', $phpSource); + $this->assertStringContainsString('id="editor-btn-okay"', $phpSource); + $this->assertStringContainsString('onclick="handleOkayAction()"', $phpSource); + $this->assertStringContainsString('id="editor-btn-apply"', $phpSource); + $this->assertStringContainsString('onclick="saveAllChanges(false)"', $phpSource); + $this->assertStringContainsString('id="editor-btn-close"', $phpSource); + $this->assertStringContainsString("function handleOkayAction()", $jsSource); + $this->assertStringContainsString("saveAllChanges(true);", $jsSource); + $this->assertStringContainsString("doCloseEditorModal();", $jsSource); + $this->assertStringContainsString("function saveAllChanges(closeAfterSave)", $jsSource); + $this->assertStringContainsString("$('#editor-btn-apply').prop('disabled', !hasChanges);", $jsSource); + $this->assertStringContainsString("$('#editor-change-count').text(totalChanges + (totalChanges === 1 ? ' change' : ' changes'));", $jsSource); + $this->assertStringContainsString("promptRecreateContainers(closeAfterSave);", $jsSource); + } + + public function testSaveAllChangesDefaultsToApplyMode(): void + { + $source = $this->getJsSource(); + $this->assertStringContainsString('function saveAllChanges(closeAfterSave) {', $source); + $this->assertStringContainsString("if (typeof closeAfterSave === 'undefined') {", $source); + $this->assertStringContainsString('closeAfterSave = false;', $source); + } + + public function testHandleOkayActionSavesAndClosesWhenModified(): void + { + $source = $this->getJsSource(); + + $this->assertMatchesRegularExpression( + '/function\\s+handleOkayAction\\s*\\(\\)\\s*\\{[\\s\\S]*?if\\s*\\(totalChanges\\s*>\\s*0\\)\\s*\\{[\\s\\S]*?saveAllChanges\\(true\\);[\\s\\S]*?\\}\\s*else\\s*\\{[\\s\\S]*?doCloseEditorModal\\(\\);/m', + $source, + 'Okay must save with closeAfterSave=true and close immediately when no changes.' + ); + } + + public function testNonLabelSaveOnlyClosesWhenRequested(): void + { + $source = $this->getJsSource(); + + $this->assertMatchesRegularExpression( + '/if\\s*\\(closeAfterSave\\)\\s*\\{\\s*doCloseEditorModal\\(\\);\\s*\\}[\\s\\S]*?refreshStackByProject\\(project\\);/m', + $source, + 'Non-label success path should only close modal when closeAfterSave=true.' + ); + } + + public function testApplyLabelSaveSkipsRecreatePromptAndKeepsModalOpen(): void + { + $source = $this->getJsSource(); + + $this->assertMatchesRegularExpression( + '/function\\s+promptRecreateContainers\\s*\\(closeAfterSave\\)\\s*\\{[\\s\\S]*?if\\s*\\(!closeAfterSave\\)\\s*\\{[\\s\\S]*?title:\\s*\"Saved!\"[\\s\\S]*?recreate or restart containers to apply the changes\\.[\\s\\S]*?refreshStackByProject\\(project\\);[\\s\\S]*?return;[\\s\\S]*?\\}/mi', + $source, + 'Apply flow after label save should show informational message and avoid recreate-confirm dialog.' + ); + } + + public function testSettingsPageIncludesExternalComposeFileField(): void + { + $source = $this->getPhpSource(); + + $this->assertStringContainsString('id="settings-external-compose-file"', $source); + $this->assertStringContainsString('data-pickfilter="yml,yaml"', $source); + $this->assertStringContainsString('Path to a specific external compose file', $source); + } + + public function testAddStackModalIncludesIndirectComposeFileField(): void + { + $source = $this->getJsSource(); + + $this->assertStringContainsString('id="compose-stack-indirect-file"', $source); + $this->assertStringContainsString('stackFilePath: indirectFile,', $source); + $this->assertStringContainsString('Set either Indirect Path or Indirect Compose File, not both.', $source); + $this->assertStringContainsString('Default discovery disabled because Indirect Compose File is set.', $source); + } + + public function testSettingsSaveHandlesExternalComposeFileField(): void + { + $source = $this->getJsSource(); + + $this->assertStringContainsString("editorModal.modifiedSettings.has('external-compose-file')", $source); + $this->assertStringContainsString("var externalComposeFilePath = $('#settings-external-compose-file').val();", $source); + $this->assertStringContainsString('External Compose File cannot be inside the stack project folder.', $source); + $this->assertStringContainsString('externalComposeFilePath: externalComposeFilePath,', $source); + } + + public function testOverrideEditorShowsBlankStateAndCreateButton(): void + { + $source = $this->getPhpSource(); + + $this->assertStringContainsString('id="labels-override-empty-state"', $source); + $this->assertStringContainsString('Create Override Template', $source); + } + + public function testCreateOverrideTemplateActionIsWiredInJs(): void + { + $source = $this->getJsSource(); + + $this->assertStringContainsString("action: 'createOverrideTemplate'", $source); + $this->assertStringContainsString('function createOverrideTemplate()', $source); + $this->assertStringContainsString('overrideExists = true', $source); + } + + public function testEnvEditorShowsBlankStateAndCreateButton(): void + { + $source = $this->getPhpSource(); + + $this->assertStringContainsString('id="env-empty-state"', $source); + $this->assertStringContainsString('Create .env Template', $source); + } + + public function testCreateEnvTemplateActionIsWiredInJs(): void + { + $source = $this->getJsSource(); + + $this->assertStringContainsString("action: 'createEnvTemplate'", $source); + $this->assertStringContainsString('function createEnvTemplate()', $source); + $this->assertStringContainsString('response.exists === true', $source); + } + + public function testInvalidIndirectRepairRoutesFileModeToExternalComposeFileField(): void + { + $source = $this->getJsSource(); + + $this->assertStringContainsString("var indirectMode = response.indirectMode || '';", $source); + $this->assertStringContainsString("if (indirectMode === 'file') {", $source); + $this->assertStringContainsString("$('#settings-external-compose-file').val(invalidIndirectPath);", $source); + $this->assertStringContainsString("editorModal.modifiedSettings.add('external-compose-file');", $source); + } + // =========================================== // Regression Tests // =========================================== @@ -93,7 +269,7 @@ public function testLabelSaveBlocksTaggedOverrideRewrite(): void */ public function testUncheckedRunningStackShowsCheckForUpdates(): void { - $source = $this->getPageSource(); + $source = $this->getJsSource(); // The else branch for no containers must call checkStackUpdates $this->assertStringContainsString( "onclick=\"checkStackUpdates(\\'' + composeEscapeAttr(stackName) + '\\');\"", @@ -110,11 +286,21 @@ public function testUncheckedRunningStackShowsCheckForUpdates(): void */ public function testStoppedStacksReturnEarlyUnconditionally(): void { - $source = $this->getPageSource(); + $source = $this->getJsSource(); // The stopped check should NOT reference hasCheckedData — it must be // a simple !isRunning guard. $this->assertStringNotContainsString('!isRunning && !hasCheckedData', $source, 'Stopped stack early return must be unconditional (!isRunning only, not gated on hasCheckedData)'); } + public function testAutostartHookUsesSavedStackOrderOnly(): void + { + $source = file_get_contents($this->dockerStartedEventPath); + + $this->assertStringContainsString('load_saved_stack_order()', $source); + $this->assertStringContainsString('stack-order.json', $source); + $this->assertStringNotContainsString('startup_priority', $source); + $this->assertStringNotContainsString('priority=50', $source); + } + } diff --git a/tests/unit/ComposeUtilTest.php b/tests/unit/ComposeUtilTest.php index 09eb0237..01159b55 100644 --- a/tests/unit/ComposeUtilTest.php +++ b/tests/unit/ComposeUtilTest.php @@ -271,7 +271,7 @@ public function testEchoComposeCommandWithOverrideFile(): void mkdir($stackDir, 0755, true); file_put_contents("$stackDir/" . COMPOSE_FILE_NAMES[0], "services:\n web:\n image: nginx\n"); - $overrideInfo = OverrideInfo::fromStack($tempDir, $stackName); + $overrideInfo = \StackInfo::fromProject($tempDir, $stackName)->overrideInfo; file_put_contents($overrideInfo->getOverridePath(), "services:\n web:\n ports:\n - 80:80\n"); $sanitizedStackName = \StackInfo::sanitizeProjectString($stackName); @@ -316,7 +316,9 @@ public function testEchoComposeCommandWithEnvPath(): void mkdir($stackDir, 0755, true); file_put_contents("$stackDir/" . COMPOSE_FILE_NAMES[0], "services:\n web:\n image: nginx\n"); file_put_contents("$stackDir/name", $stackName); - file_put_contents("$stackDir/envpath", "/custom/path/.env"); + $customEnvPath = "$tempDir/custom.env"; + file_put_contents($customEnvPath, "TEST_VAR=1\n"); + file_put_contents("$stackDir/envpath", $customEnvPath); // Ensure array is started $varIniDir = sys_get_temp_dir() . '/emhttp_test_' . uniqid(); @@ -334,7 +336,7 @@ public function testEchoComposeCommandWithEnvPath(): void $output = ob_get_clean(); // Should include env path - $this->assertStringContainsString('-e/custom/path/.env', $output); + $this->assertStringContainsString('-e' . $customEnvPath, $output); // Cleanup unlink("$varIniDir/var.ini"); @@ -373,8 +375,8 @@ public function testGetLastCmdLogFileForComposeAction(): void $this->assertSame('/test-stack/last_cmd.log', getLastCmdLogFileForComposeAction('down', $path)); } + #[\PHPUnit\Framework\Attributes\DataProvider('actionsProvider')] /** - * @dataProvider actionsProvider * Test various compose actions */ public function testEchoComposeCommandActions(string $action, string $expectedArg): void diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index 473ba7e7..24cdf870 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -23,6 +23,8 @@ class ExecActionsTest extends TestCase { private string $testComposeRoot; + /** @var string[] */ + private array $externalCleanupPaths = []; protected function setUp(): void { @@ -51,6 +53,16 @@ protected function tearDown(): void if (is_dir($this->testComposeRoot)) { $this->recursiveDelete($this->testComposeRoot); } + foreach ($this->externalCleanupPaths as $path) { + if (is_link($path) || is_file($path)) { + @unlink($path); + continue; + } + if (is_dir($path)) { + $this->recursiveDelete($path); + } + } + $this->externalCleanupPaths = []; $_POST = []; parent::tearDown(); } @@ -113,6 +125,52 @@ private function createTestStack(string $name, array $files = []): string return $stackPath; } + /** + * Prepare a symlink path under an allowed root (/mnt or /boot/config). + * + * @return array{symlink:string,target:string} + */ + private function createAllowedSymlinkedPathFixture(bool $createComposeFile = false): array + { + $bases = ['/mnt', '/boot/config']; + $base = null; + foreach ($bases as $candidate) { + if (!is_dir($candidate) && !@mkdir($candidate, 0755, true)) { + continue; + } + if (is_writable($candidate)) { + $base = $candidate; + break; + } + } + + if ($base === null) { + $this->markTestSkipped('Requires writable /mnt or /boot/config for allowed-path fixture.'); + } + + $suffix = 'compose_exec_fixture_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $target = $base . '/' . $suffix . '_target'; + $symlink = $base . '/' . $suffix . '_link'; + + if (!@mkdir($target, 0755, true) && !is_dir($target)) { + $this->markTestSkipped('Unable to create allowed-path fixture directory.'); + } + + if ($createComposeFile) { + file_put_contents($target . '/compose.yaml', "services:\n app:\n image: redis\n"); + } + + if (!@symlink($target, $symlink)) { + $this->recursiveDelete($target); + $this->markTestSkipped('Symlink creation is unavailable in this environment.'); + } + + $this->externalCleanupPaths[] = $symlink; + $this->externalCleanupPaths[] = $target; + + return ['symlink' => $symlink, 'target' => $target]; + } + // =========================================== // changeName Action Tests // =========================================== @@ -328,6 +386,8 @@ public function testGetEnvReturnsContent(): void $result = json_decode($output, true); $this->assertEquals('success', $result['result']); $this->assertEquals($envContent, $result['content']); + $this->assertTrue($result['exists']); + $this->assertSame($stackPath . '/.env', $result['fileName']); } /** @@ -347,6 +407,96 @@ public function testGetEnvUsesCustomEnvPath(): void $result = json_decode($output, true); $this->assertEquals('success', $result['result']); $this->assertEquals("CUSTOM_VAR=custom_value", $result['content']); + $this->assertTrue($result['exists']); + $this->assertSame($customEnvPath, $result['fileName']); + } + + /** + * Test getEnv returns blank content and exists=false when no env file + */ + public function testGetEnvReturnsBlankWhenNoFile(): void + { + $stackPath = $this->createTestStack('test-stack'); + @unlink($stackPath . '/.env'); + + $output = $this->executeAction('getEnv', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + $this->assertSame('', $result['content']); + $this->assertFalse($result['exists']); + $this->assertSame($stackPath . '/.env', $result['fileName']); + } + + // =========================================== + // createEnvTemplate Action Tests + // =========================================== + + /** + * Test createEnvTemplate writes default .env template in stack path + */ + public function testCreateEnvTemplateWritesDefaultPath(): void + { + $stackPath = $this->createTestStack('test-stack'); + @unlink($stackPath . '/.env'); + + $output = $this->executeAction('createEnvTemplate', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + $this->assertTrue($result['exists']); + $this->assertSame($stackPath . '/.env', $result['fileName']); + $this->assertFileExists($stackPath . '/.env'); + $this->assertStringContainsString('IMAGE_TAG=latest', $result['content']); + } + + /** + * Test createEnvTemplate writes .env in indirect folder when stack is indirect + */ + public function testCreateEnvTemplateUsesIndirectFolder(): void + { + $stackPath = $this->createTestStack('test-stack'); + $indirectDir = $this->testComposeRoot . '/external-env'; + mkdir($indirectDir, 0755, true); + file_put_contents($indirectDir . '/compose.yaml', "services:\n app:\n image: nginx:alpine\n"); + @unlink($indirectDir . '/.env'); + + file_put_contents($stackPath . '/indirect', $indirectDir); + file_put_contents($stackPath . '/indirect_mode', 'folder'); + + $output = $this->executeAction('createEnvTemplate', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + $this->assertTrue($result['exists']); + $this->assertSame($indirectDir . '/.env', $result['fileName']); + $this->assertFileExists($indirectDir . '/.env'); + } + + /** + * Test createEnvTemplate returns error when target path is a directory + */ + public function testCreateEnvTemplateFailsWhenTargetIsDirectory(): void + { + $stackPath = $this->createTestStack('test-stack'); + $dirTarget = $this->testComposeRoot . '/env-dir-target'; + mkdir($dirTarget, 0755, true); + file_put_contents($stackPath . '/envpath', $dirTarget); + + $output = $this->executeAction('createEnvTemplate', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('error', $result['result']); + $this->assertStringContainsString('directory', $result['message']); + $this->assertSame($dirTarget, file_get_contents($stackPath . '/envpath')); } // =========================================== @@ -390,24 +540,74 @@ public function testGetOverrideReturnsContent(): void $result = json_decode($output, true); $this->assertEquals('success', $result['result']); $this->assertEquals($overrideContent, $result['content']); + $this->assertEquals($stackPath . '/compose.override.yaml', $result['fileName']); + $this->assertArrayNotHasKey('readingFromIndirect', $result); + $this->assertArrayNotHasKey('projectOverridePath', $result); } /** * Test getOverride returns empty when no file */ - public function testGetOverrideCreatesWhenNoFile(): void + public function testGetOverrideReturnsBlankWhenNoFile(): void { - $this->createTestStack('test-stack'); - + $stackPath = $this->createTestStack('test-stack'); + $output = $this->executeAction('getOverride', [ 'script' => 'test-stack', ]); - $overrideContent = "# Override file for UI labels (icon, webui, shell)\n"; - $overrideContent .= "# This file is managed by Compose Manager\n"; - $overrideContent .= "services: {}\n"; + $result = json_decode($output, true); $this->assertEquals('success', $result['result']); - $this->assertEquals($overrideContent, $result['content']); + $this->assertSame('', $result['content']); + $this->assertSame($stackPath . '/compose.override.yaml', $result['fileName']); + $this->assertFalse($result['exists']); + $this->assertFileDoesNotExist($stackPath . '/compose.override.yaml'); + $this->assertArrayNotHasKey('readingFromIndirect', $result); + $this->assertArrayNotHasKey('projectOverridePath', $result); + } + + // =========================================== + // createOverrideTemplate Action Tests + // =========================================== + + /** + * Test createOverrideTemplate writes the default template + */ + public function testCreateOverrideTemplateWritesTemplate(): void + { + $stackPath = $this->createTestStack('test-stack'); + + $output = $this->executeAction('createOverrideTemplate', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + $this->assertSame($stackPath . '/compose.override.yaml', $result['fileName']); + $this->assertTrue($result['exists']); + $this->assertStringContainsString('Manual example:', $result['content']); + $this->assertStringContainsString('net.unraid.docker.webui', $result['content']); + $this->assertFileExists($stackPath . '/compose.override.yaml'); + $this->assertStringContainsString('services: {}', file_get_contents($stackPath . '/compose.override.yaml')); + } + + /** + * Test createOverrideTemplate returns error when target path is a directory + */ + public function testCreateOverrideTemplateFailsWhenTargetIsDirectory(): void + { + $stackPath = $this->createTestStack('test-stack'); + @unlink($stackPath . '/compose.override.yaml'); + mkdir($stackPath . '/compose.override.yaml', 0755, true); + + $output = $this->executeAction('createOverrideTemplate', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('error', $result['result']); + $this->assertStringContainsString('directory', $result['message']); + $this->assertTrue(is_dir($stackPath . '/compose.override.yaml')); } // =========================================== @@ -577,6 +777,66 @@ public function testGetStackSettingsReturnsData(): void $this->assertEquals('production', $result['defaultProfile']); } + public function testGetStackSettingsReturnsExternalComposeFileForFileMode(): void + { + $stackPath = $this->createTestStack('test-stack'); + $externalDir = $this->testComposeRoot . '/external'; + $externalFile = $externalDir . '/custom.compose.yaml'; + mkdir($externalDir, 0755, true); + file_put_contents($externalFile, "services:\n app:\n image: redis\n"); + file_put_contents($stackPath . '/indirect', $externalFile); + file_put_contents($stackPath . '/indirect_mode', 'file'); + + $output = $this->executeAction('getStackSettings', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + $this->assertSame('file', $result['indirectMode']); + $this->assertSame($externalFile, $result['externalComposeFilePath']); + $this->assertSame('', $result['externalComposePath']); + } + + public function testGetStackSettingsPreservesBrokenIndirectFileModePath(): void + { + $stackPath = $this->createTestStack('test-stack', [COMPOSE_FILE_NAMES[0] => null]); + $missingFile = $this->testComposeRoot . '/external/missing.compose.yaml'; + file_put_contents($stackPath . '/indirect', $missingFile); + file_put_contents($stackPath . '/indirect_mode', 'file'); + @unlink($stackPath . '/' . COMPOSE_FILE_NAMES[0]); + + $output = $this->executeAction('getStackSettings', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + $this->assertSame('file', $result['indirectMode']); + $this->assertSame($missingFile, $result['invalidIndirectPath']); + $this->assertSame('', $result['externalComposeFilePath']); + } + + public function testGetStackSettingsReturnsEffectiveDefaultDiscoveryFalseForFileMode(): void + { + $stackPath = $this->createTestStack('test-stack'); + $externalDir = $this->testComposeRoot . '/external-discovery'; + $externalFile = $externalDir . '/compose.yaml'; + mkdir($externalDir, 0755, true); + file_put_contents($externalFile, "services:\n app:\n image: redis\n"); + file_put_contents($stackPath . '/indirect', $externalFile); + file_put_contents($stackPath . '/indirect_mode', 'file'); + file_put_contents($stackPath . '/use_default_compose_files', 'true'); + + $output = $this->executeAction('getStackSettings', [ + 'script' => 'test-stack', + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + $this->assertFalse($result['useDefaultComposeFiles']); + } + /** * Test getStackSettings error when stack not specified */ @@ -611,6 +871,72 @@ public function testSetStackSettingsSavesSettings(): void $this->assertEquals('success', $result['result']); } + public function testSetStackSettingsRejectsBothExternalComposePathAndFile(): void + { + $this->createTestStack('test-stack'); + + $output = $this->executeAction('setStackSettings', [ + 'script' => 'test-stack', + 'externalComposePath' => '/mnt/user/appdata/example', + 'externalComposeFilePath' => '/mnt/user/appdata/example/compose.yaml', + ]); + + $result = json_decode($output, true); + $this->assertEquals('error', $result['result']); + $this->assertStringContainsString('Set either External Compose Path or External Compose File', $result['message']); + } + + public function testAddStackRejectsBothIndirectPathAndFile(): void + { + $output = $this->executeAction('addStack', [ + 'stackName' => 'My Stack', + 'stackPath' => '/mnt/user/appdata/example', + 'stackFilePath' => '/mnt/user/appdata/example/compose.yaml', + ]); + + $result = json_decode($output, true); + $this->assertEquals('error', $result['result']); + $this->assertStringContainsString('Set either Indirect Path or Indirect Compose File', $result['message']); + } + + public function testAddStackPreservesUserProvidedSymlinkIndirectFolderPath(): void + { + $fixture = $this->createAllowedSymlinkedPathFixture(); + + $output = $this->executeAction('addStack', [ + 'stackName' => 'Symlink Preserve Folder', + 'stackPath' => $fixture['symlink'], + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + + $project = $result['project'] ?? ''; + $this->assertNotSame('', $project); + $indirectFile = $this->testComposeRoot . '/' . $project . '/indirect'; + $this->assertFileExists($indirectFile); + $this->assertSame($fixture['symlink'], trim((string) file_get_contents($indirectFile))); + } + + public function testSetStackSettingsPreservesUserProvidedSymlinkExternalComposeFilePath(): void + { + $stackPath = $this->createTestStack('test-stack'); + $fixture = $this->createAllowedSymlinkedPathFixture(true); + $symlinkedComposeFile = $fixture['symlink'] . '/compose.yaml'; + + $output = $this->executeAction('setStackSettings', [ + 'script' => 'test-stack', + 'externalComposeFilePath' => $symlinkedComposeFile, + ]); + + $result = json_decode($output, true); + $this->assertEquals('success', $result['result']); + + $indirectFile = $stackPath . '/indirect'; + $this->assertFileExists($indirectFile); + $this->assertSame($symlinkedComposeFile, trim((string) file_get_contents($indirectFile))); + } + /** * Test setStackSettings accepts exact SVG data URL icon */ diff --git a/tests/unit/OverrideInfoTest.php b/tests/unit/OverrideInfoTest.php index 0e13a54a..4bd5dbcf 100644 --- a/tests/unit/OverrideInfoTest.php +++ b/tests/unit/OverrideInfoTest.php @@ -6,7 +6,6 @@ use PluginTests\TestCase; -// Load the actual source file via stream wrapper require_once '/usr/local/emhttp/plugins/compose.manager/include/Util.php'; class OverrideInfoTest extends TestCase @@ -20,12 +19,20 @@ protected function setUp(): void $this->tempRoot = $this->createTempDir(); } - public function testFromStackCreatesInstance(): void + private function getOverrideInfo(string $stack): \OverrideInfo + { + return \StackInfo::fromProject($this->tempRoot, $stack)->overrideInfo; + } + + public function testFromStackInfoCreatesInstance(): void { $stack = 'teststack'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + + $info = $this->getOverrideInfo($stack); + $this->assertInstanceOf(\OverrideInfo::class, $info); } @@ -34,7 +41,10 @@ public function testComputedNameDefault(): void $stack = 'stack1'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + + $info = $this->getOverrideInfo($stack); + $this->assertStringContainsString('override', $info->computedName); } @@ -43,7 +53,10 @@ public function testProjectOverridePath(): void $stack = 'stack2'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + + $info = $this->getOverrideInfo($stack); + $this->assertStringContainsString($stackDir, $info->projectOverride); } @@ -51,11 +64,13 @@ public function testIndirectOverridePath(): void { $stack = 'stack3'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $indirectTarget = $this->tempRoot . '/indirect_target'; + mkdir($stackDir); mkdir($indirectTarget); file_put_contents($stackDir . '/indirect', $indirectTarget); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + + $info = $this->getOverrideInfo($stack); + $this->assertEquals($indirectTarget . '/' . $info->computedName, $info->indirectOverride); } @@ -63,13 +78,14 @@ public function testUseIndirectTrueWhenIndirectOverrideExists(): void { $stack = 'stack4'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $indirectTarget = $this->tempRoot . '/indirect_target2'; + mkdir($stackDir); mkdir($indirectTarget); file_put_contents($stackDir . '/indirect', $indirectTarget); - $overridePath = $indirectTarget . '/compose.override.yaml'; - file_put_contents($overridePath, '# override'); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($indirectTarget . '/compose.override.yaml', '# override'); + + $info = $this->getOverrideInfo($stack); + $this->assertTrue($info->useIndirect); } @@ -77,13 +93,14 @@ public function testMismatchIndirectLegacy(): void { $stack = 'stack5'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $indirectTarget = $this->tempRoot . '/indirect_target3'; + mkdir($stackDir); mkdir($indirectTarget); file_put_contents($stackDir . '/indirect', $indirectTarget); - $legacyPath = $indirectTarget . '/docker-compose.override.yml'; - file_put_contents($legacyPath, '# legacy'); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($indirectTarget . '/docker-compose.override.yml', '# legacy'); + + $info = $this->getOverrideInfo($stack); + $this->assertFalse($info->mismatchIndirectLegacy); $this->assertTrue($info->useIndirect); } @@ -92,13 +109,15 @@ public function testGetOverridePathPrefersIndirect(): void { $stack = 'stack6'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $indirectTarget = $this->tempRoot . '/indirect_target4'; + $overridePath = $indirectTarget . '/compose.override.yaml'; + mkdir($stackDir); mkdir($indirectTarget); file_put_contents($stackDir . '/indirect', $indirectTarget); - $overridePath = $indirectTarget . '/compose.override.yaml'; file_put_contents($overridePath, '# override'); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + + $info = $this->getOverrideInfo($stack); + $this->assertEquals($overridePath, $info->getOverridePath()); } @@ -106,10 +125,13 @@ public function testGetOverridePathFallsBackToProject(): void { $stack = 'stack7'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $overridePath = $stackDir . '/compose.override.yaml'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); file_put_contents($overridePath, '# override'); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + + $info = $this->getOverrideInfo($stack); + $this->assertEquals($overridePath, $info->getOverridePath()); } @@ -117,10 +139,13 @@ public function testLegacyProjectOverrideIsPreserved(): void { $stack = 'stack8'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $legacyPath = $stackDir . '/docker-compose.override.yml'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); file_put_contents($legacyPath, '# legacy'); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + + $info = $this->getOverrideInfo($stack); + $this->assertFileExists($legacyPath); $this->assertSame($legacyPath, $info->projectOverride); $this->assertSame($legacyPath, $info->getOverridePath()); @@ -130,12 +155,15 @@ public function testLegacyProjectOverrideNoBakWhenComputedExists(): void { $stack = 'stack9'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $legacyPath = $stackDir . '/docker-compose.override.yml'; $computedPath = $stackDir . '/compose.override.yaml'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); file_put_contents($legacyPath, '# legacy'); file_put_contents($computedPath, '# computed'); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + + $info = $this->getOverrideInfo($stack); + $this->assertFileExists($computedPath); $this->assertFileExists($legacyPath); $this->assertSame($computedPath, $info->projectOverride); @@ -146,29 +174,29 @@ public function testLegacyIndirectOverrideIsPreserved(): void { $stack = 'stack10'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $indirectTarget = $this->tempRoot . '/indirect_target5'; + $legacyPath = $indirectTarget . '/docker-compose.override.yml'; + mkdir($stackDir); mkdir($indirectTarget); file_put_contents($stackDir . '/indirect', $indirectTarget); - $legacyPath = $indirectTarget . '/docker-compose.override.yml'; file_put_contents($legacyPath, '# legacy indirect'); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + $info = $this->getOverrideInfo($stack); $this->assertTrue($info->useIndirect); $this->assertSame($legacyPath, $info->getOverridePath()); } - // =========================================== - // composeFilePath Tests - // =========================================== - public function testComposeFilePathIsNullWhenNoComposeFile(): void { + // Use a broken-indirect stack: isIndirect=true but target missing → degraded load → composeFilePath=null $stack = 'no-compose'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($stackDir . '/indirect', '/mnt/user/nonexistent_share_for_test'); + + $info = $this->getOverrideInfo($stack); + $this->assertNull($info->composeFilePath); } @@ -177,42 +205,43 @@ public function testComposeFilePathIsSetWhenComposeFileExists(): void $stack = 'has-compose'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); - $this->assertEquals("$stackDir/compose.yaml", $info->composeFilePath); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n"); + + $info = $this->getOverrideInfo($stack); + + $this->assertEquals($stackDir . '/compose.yaml', $info->composeFilePath); } public function testComposeFilePathResolvesIndirect(): void { $stack = 'indirect-compose'; $stackDir = $this->tempRoot . '/' . $stack; - mkdir($stackDir); $indirectTarget = $this->tempRoot . '/indirect_compose_target'; + mkdir($stackDir); mkdir($indirectTarget); - file_put_contents("$stackDir/indirect", $indirectTarget); - file_put_contents("$indirectTarget/docker-compose.yml", "services:\n app:\n image: redis\n"); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); - $this->assertEquals("$indirectTarget/docker-compose.yml", $info->composeFilePath); - } + file_put_contents($stackDir . '/indirect', $indirectTarget); + file_put_contents($indirectTarget . '/docker-compose.yml', "services:\n app:\n image: redis\n"); - // =========================================== - // pruneOrphanServices Tests - // =========================================== + $info = $this->getOverrideInfo($stack); + + $this->assertEquals($indirectTarget . '/docker-compose.yml', $info->composeFilePath); + } public function testPruneOrphanServicesReturnsUnchangedWhenNoOverride(): void { $stack = 'prune-no-override'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); - // Delete the auto-created override so there's nothing to prune - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n"); + + $info = $this->getOverrideInfo($stack); $overridePath = $info->getOverridePath(); if ($overridePath && is_file($overridePath)) { unlink($overridePath); } - // Just test via the public API by removing the override file + $result = $info->pruneOrphanServices(['web']); + $this->assertFalse($result['changed']); $this->assertEquals([], $result['removed']); } @@ -222,11 +251,9 @@ public function testPruneOrphanServicesRemovesStaleEntries(): void $stack = 'prune-stale'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n"); + $info = $this->getOverrideInfo($stack); $overridePath = $info->getOverridePath(); - - // Write an override with an orphaned service $overrideContent = "services:\n" . " web:\n" . " labels:\n" . @@ -236,14 +263,11 @@ public function testPruneOrphanServicesRemovesStaleEntries(): void " test: \"2\"\n"; file_put_contents($overridePath, $overrideContent); - // Pass valid services — 'web' is valid, 'deleted-svc' is orphaned $result = $info->pruneOrphanServices(['web']); + $newContent = file_get_contents($overridePath); $this->assertTrue($result['changed']); $this->assertEquals(['deleted-svc'], $result['removed']); - - // Verify override file was updated - $newContent = file_get_contents($overridePath); $this->assertStringContainsString(" web:\n", $newContent); $this->assertStringNotContainsString(" deleted-svc:\n", $newContent); } @@ -253,10 +277,9 @@ public function testPruneOrphanServicesNoChangeWhenAllValid(): void $stack = 'prune-valid'; $stackDir = $this->tempRoot . '/' . $stack; mkdir($stackDir); - file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n api:\n image: node\n"); - $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n api:\n image: node\n"); + $info = $this->getOverrideInfo($stack); $overridePath = $info->getOverridePath(); - $overrideContent = "services:\n" . " web:\n" . " labels:\n" . @@ -266,7 +289,6 @@ public function testPruneOrphanServicesNoChangeWhenAllValid(): void " test: \"2\"\n"; file_put_contents($overridePath, $overrideContent); - // Both services are valid $result = $info->pruneOrphanServices(['web', 'api']); $this->assertFalse($result['changed']); diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index b6215b6f..6570a0e3 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -11,12 +11,26 @@ class StackInfoTest extends TestCase { private string $tempRoot; + private string $stackOrderFile; protected function setUp(): void { parent::setUp(); \StackInfo::clearCache(); $this->tempRoot = $this->createTempDir(); + $this->stackOrderFile = COMPOSE_STACK_ORDER_FILE; + if (is_file($this->stackOrderFile)) { + @unlink($this->stackOrderFile); + } + } + + protected function tearDown(): void + { + if (is_file($this->stackOrderFile)) { + @unlink($this->stackOrderFile); + } + + parent::tearDown(); } // =========================================== @@ -122,9 +136,9 @@ public function testStructurallyInvalidIndirectPreserved(): void $info = \StackInfo::fromProject($this->tempRoot, $stack); $this->assertFalse($info->isIndirect); - // indirect file should be preserved (non-destructive handling) + // Structurally invalid path is silently ignored — not stored as invalidIndirectPath $this->assertFileExists("$stackDir/indirect"); - $this->assertSame('/mnt/user/../etc/passwd', $info->invalidIndirectPath); + $this->assertNull($info->invalidIndirectPath); } public function testMissingDirIndirectPreserved(): void @@ -138,8 +152,10 @@ public function testMissingDirIndirectPreserved(): void $info = \StackInfo::fromProject($this->tempRoot, $stack); - $this->assertFalse($info->isIndirect); - // indirect file should be preserved (non-destructive handling) + $this->assertTrue($info->isIndirect); + $this->assertSame('folder', $info->indirectMode); + $this->assertSame('/mnt/user/nonexistent_share', $info->composeSource); + $this->assertNull($info->composeFilePath); $this->assertFileExists("$stackDir/indirect"); $this->assertSame('/mnt/user/nonexistent_share', $info->invalidIndirectPath); } @@ -155,12 +171,31 @@ public function testInvalidIndirectLoadsDegradedWithNoLocalCompose(): void // Should NOT throw — constructs in degraded mode $info = \StackInfo::fromProject($this->tempRoot, $stack); - $this->assertFalse($info->isIndirect); + $this->assertTrue($info->isIndirect); + $this->assertSame('folder', $info->indirectMode); $this->assertNull($info->composeFilePath); $this->assertSame('/mnt/user/gone', $info->invalidIndirectPath); $this->assertInstanceOf(\OverrideInfo::class, $info->overrideInfo); } + public function testMissingIndirectFilePreservedInFileMode(): void + { + $stack = 'missing-file-indirect'; + $stackDir = $this->tempRoot . '/' . $stack; + $missingFile = $this->tempRoot . '/external/missing.compose.yaml'; + mkdir($stackDir); + file_put_contents("$stackDir/indirect", $missingFile); + file_put_contents("$stackDir/indirect_mode", 'file'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertTrue($info->isIndirect); + $this->assertSame('file', $info->indirectMode); + $this->assertSame(dirname($missingFile), $info->composeSource); + $this->assertNull($info->composeFilePath); + $this->assertSame($missingFile, $info->invalidIndirectPath); + } + public function testValidIndirectHasNoInvalidPath(): void { $stack = 'valid-indirect'; @@ -265,6 +300,19 @@ public function testListProjectFoldersNonexistentRootReturnsEmptyArray(): void $this->assertSame([], $folders); } + public function testListProjectFoldersHonorsSavedStackOrder(): void + { + mkdir($this->tempRoot . '/stack-a'); + mkdir($this->tempRoot . '/stack-b'); + mkdir($this->tempRoot . '/stack-c'); + + $this->assertTrue(saveComposeStackOrder($this->tempRoot, ['stack-c', 'stack-a', 'stack-b'])); + + $folders = \StackInfo::listProjectFolders($this->tempRoot); + + $this->assertSame(['stack-c', 'stack-a', 'stack-b'], $folders); + } + // =========================================== // Compose File Resolution Tests // =========================================== @@ -382,6 +430,36 @@ public function testGetOverridePathDelegates(): void $this->assertSame("$stackDir/compose.override.yaml", $info->getOverridePath()); } + public function testPreferredOverridePathUsesIndirectFolder(): void + { + $stack = 'preferred-folder'; + $stackDir = $this->tempRoot . '/' . $stack; + $indirectDir = $this->tempRoot . '/indirect-folder-target'; + mkdir($stackDir); + mkdir($indirectDir); + file_put_contents($stackDir . '/indirect', $indirectDir); + file_put_contents($indirectDir . '/compose.yaml', "services:\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame($indirectDir . '/compose.override.yaml', $info->getPreferredOverridePath()); + } + + public function testPreferredOverridePathUsesIndirectFile(): void + { + $stack = 'preferred-file'; + $stackDir = $this->tempRoot . '/' . $stack; + $indirectFile = $this->tempRoot . '/indirect-file-target/custom.compose.yaml'; + mkdir($stackDir); + mkdir(dirname($indirectFile), 0755, true); + file_put_contents($stackDir . '/indirect', $indirectFile); + file_put_contents($indirectFile, "services:\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame(dirname($indirectFile) . '/custom.compose.override.yaml', $info->getPreferredOverridePath()); + } + // =========================================== // Lazy Metadata Getter Tests // =========================================== @@ -455,6 +533,51 @@ public function testGetEnvFilePathNullWhenNoFile(): void $this->assertNull($info->getEnvFilePath()); } + public function testGetEffectiveEnvFilePathFallsBackWhenEnvpathEmpty(): void + { + $stack = 'empty-envpath-fallback'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n"); + file_put_contents("$stackDir/.env", "KEY=value\n"); + file_put_contents("$stackDir/envpath", " \n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame("$stackDir/.env", $info->getEffectiveEnvFilePath()); + } + + public function testGetEffectiveEnvFilePathFallsBackWhenEnvpathInvalid(): void + { + $stack = 'invalid-envpath-fallback'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n"); + file_put_contents("$stackDir/.env", "KEY=value\n"); + file_put_contents("$stackDir/envpath", "/path/that/does/not/exist/.env\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame("$stackDir/.env", $info->getEffectiveEnvFilePath()); + } + + public function testBuildComposeArgsUsesLocalEnvWhenEnvpathInvalid(): void + { + $stack = 'invalid-envpath-build-args'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + file_put_contents("$stackDir/.env", "KEY=value\n"); + file_put_contents("$stackDir/envpath", "/path/that/does/not/exist/.env\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + $this->assertStringContainsString('--env-file', $args['envFile']); + $this->assertStringContainsString($stackDir . '/.env', $args['envFile']); + $this->assertSame($stackDir . '/.env', $args['envFilePath']); + } + public function testGetIconUrlValid(): void { $stack = 'icon-stack'; @@ -785,6 +908,55 @@ public function testBuildComposeArgsWithEnvFile(): void $this->assertStringContainsString('--env-file', $args['envFile']); } + public function testBuildComposeArgsWithComposeFileInEnv(): void + { + $stack = 'env-compose-file'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + file_put_contents("$stackDir/compose.debug.yaml", "services:\n web:\n image: nginx:alpine\n"); + $envPath = $stackDir . '/.env'; + file_put_contents($envPath, "COMPOSE_FILE=compose.debug.yaml"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + $this->assertSame(3, count($args['filePaths'])); + $this->assertStringContainsString('compose.debug.yaml', $args['files']); + $this->assertContains($stackDir . '/compose.debug.yaml', $args['filePaths']); + } + + public function testBuildComposeArgsWithQuotedComposeFileInEnv(): void + { + $stack = 'env-compose-file-quoted'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + file_put_contents("$stackDir/compose.debug.yaml", "services:\n web:\n image: nginx:alpine\n"); + file_put_contents("$stackDir/compose.extra.yaml", "services:\n web:\n image: nginx:alpine\n"); + $envPath = $stackDir . '/.env'; + file_put_contents($envPath, 'COMPOSE_FILE="compose.debug.yaml":compose.extra.yaml'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + $this->assertSame(4, count($args['filePaths'])); + $this->assertStringContainsString('compose.debug.yaml', $args['files']); + $this->assertStringContainsString('compose.extra.yaml', $args['files']); + $this->assertContains($stackDir . '/compose.debug.yaml', $args['filePaths']); + $this->assertContains($stackDir . '/compose.extra.yaml', $args['filePaths']); + $this->assertContains($stackDir . '/compose.override.yaml', $args['filePaths']); + $this->assertSame( + implode(PATH_SEPARATOR, [ + $stackDir . '/compose.yaml', + $stackDir . '/compose.override.yaml', + $stackDir . '/compose.debug.yaml', + $stackDir . '/compose.extra.yaml', + ]), + $args['fileList'] + ); + } + public function testBuildComposeArgsWithOverride(): void { $stack = 'override-args'; @@ -796,11 +968,52 @@ public function testBuildComposeArgsWithOverride(): void $info = \StackInfo::fromProject($this->tempRoot, $stack); $args = $info->buildComposeArgs(); - // Should have two -f flags - $this->assertSame(2, substr_count($args['files'], '-f')); + // Should use two compose files: main + override + $this->assertSame(2, count($args['filePaths'])); $this->assertStringContainsString('compose.override.yaml', $args['files']); } + public function testBuildComposeArgsDefaultDiscoveryOmitsExplicitEnvWhenItMatchesLocalEnv(): void + { + $stack = 'default-discovery-local-env'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + file_put_contents("$stackDir/.env", "KEY=value\n"); + file_put_contents("$stackDir/envpath", $stackDir . '/./.env'); + file_put_contents("$stackDir/use_default_compose_files", 'true'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + $this->assertFalse($info->useDefaultComposeFileDiscovery(), 'Explicit envpath should disable default discovery.'); + $this->assertStringContainsString('--env-file', $args['envFile']); + $this->assertStringContainsString($stackDir . '/.env', $args['envFile']); + $this->assertSame($stackDir . '/.env', $args['envFilePath']); + } + + public function testBuildComposeArgsIndirectFileModeDisablesDefaultDiscovery(): void + { + $stack = 'indirect-file-args'; + $stackDir = $this->tempRoot . '/' . $stack; + $externalDir = $this->tempRoot . '/external-file-stack'; + $externalFile = $externalDir . '/custom.compose.yaml'; + mkdir($stackDir); + mkdir($externalDir, 0755, true); + file_put_contents($externalFile, "services:\n app:\n image: redis\n"); + file_put_contents("$stackDir/indirect", $externalFile); + file_put_contents("$stackDir/indirect_mode", 'file'); + file_put_contents("$stackDir/use_default_compose_files", 'true'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + $this->assertFalse($info->useDefaultComposeFileDiscovery()); + $this->assertStringContainsString('-f', $args['files']); + $this->assertStringContainsString($externalFile, $args['files']); + $this->assertStringNotContainsString('--project-directory', $args['files']); + } + // =========================================== // createNew() Tests // =========================================== @@ -871,6 +1084,23 @@ public function testCreateNewIndirectExistingComposeFile(): void $this->assertSame("services:\n web:\n image: nginx\n", file_get_contents("$indirectDir/compose.yaml")); } + public function testCreateNewWithIndirectComposeFileWritesFileModeWithoutCreatingDefaultCompose(): void + { + $indirectDir = $this->tempRoot . '/file-mode'; + $indirectFile = $indirectDir . '/custom.compose.yaml'; + mkdir($indirectDir, 0755, true); + file_put_contents($indirectFile, "services:\n web:\n image: nginx\n"); + + $stack = \StackInfo::createNew($this->tempRoot, 'Indirect File Stack', '', $indirectFile); + + $this->assertTrue($stack->isIndirect); + $this->assertSame('file', $stack->indirectMode); + $this->assertSame(dirname($indirectFile), $stack->composeSource); + $this->assertSame($indirectFile, $stack->composeFilePath); + $this->assertSame('file', trim(file_get_contents($stack->path . '/indirect_mode'))); + $this->assertFileDoesNotExist($indirectDir . '/compose.yaml'); + } + public function testCreateNewHandlesFolderCollision(): void { // Pre-create the folder that sanitizeProjectString would produce @@ -891,10 +1121,10 @@ public function testCreateNewInitializesOverride(): void $stack = \StackInfo::createNew($this->tempRoot, 'Override Init'); $this->assertInstanceOf(\OverrideInfo::class, $stack->overrideInfo); - // Override file should be created for non-indirect stacks + // Override file should not be created until explicitly requested $overridePath = $stack->getOverridePath(); $this->assertNotNull($overridePath); - $this->assertFileExists($overridePath); + $this->assertFileDoesNotExist($overridePath); } public function testCreateNewIsCached(): void diff --git a/tests/unit/UtilTest.php b/tests/unit/UtilTest.php index c99e6893..e2f60b95 100644 --- a/tests/unit/UtilTest.php +++ b/tests/unit/UtilTest.php @@ -90,6 +90,49 @@ public function testSanitizeProjectStringWithNumbers(): void $this->assertEquals('stack123_test', $result); } + public function testPathIsAbsolutePathUnix(): void + { + $this->assertTrue(\Path::isAbsolutePath('/usr/local/bin')); + } + + public function testPathIsAbsolutePathWindowsDrive(): void + { + $this->assertTrue(\Path::isAbsolutePath('C:\\Program Files\\Docker')); + } + + public function testPathIsAbsolutePathWindowsUnc(): void + { + $this->assertTrue(\Path::isAbsolutePath('\\\\server\\share\\compose.yml')); + } + + public function testPathIsAbsolutePathRelative(): void + { + $this->assertFalse(\Path::isAbsolutePath('compose.yml')); + } + + public function testPathRefersToSamePathNormalizesEquivalentPaths(): void + { + $tempDir = $this->createTempDir(); + $envFile = $tempDir . '/.env'; + file_put_contents($envFile, "KEY=value\n"); + + $this->assertTrue(\Path::refersToSamePath($envFile, $tempDir . '/./.env')); + } + + public function testPathIsAllowedPathMatchesRootAndChildren(): void + { + $tempDir = $this->createTempDir(); + $childDir = $tempDir . '/child'; + mkdir($childDir, 0755, true); + $childFile = $childDir . '/compose.yaml'; + file_put_contents($childFile, "services:\n"); + $otherDir = $this->createTempDir(); + + $this->assertTrue(\Path::isAllowedPath($tempDir, [$tempDir])); + $this->assertTrue(\Path::isAllowedPath($childFile, [$tempDir])); + $this->assertFalse(\Path::isAllowedPath($otherDir, [$tempDir])); + } + /** * Test sanitizeProjectString with multiple consecutive special chars (dashes preserved, underscores collapsed) */ @@ -99,6 +142,96 @@ public function testSanitizeProjectStringMultipleSpecialChars(): void $this->assertEquals('my_stack-name_here', $result); } + /** + * Broad style-matrix: every rule exercised in one data-driven test so + * PHP, shell (common.bats), and legacy-sed (compose.bats) results stay in sync. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('sanitizeProjectStringMatrixProvider')] + public function testSanitizeProjectStringMatrix(string $input, string $expected): void + { + $this->assertEquals($expected, \StackInfo::sanitizeProjectString($input)); + } + + /** @return array */ + public static function sanitizeProjectStringMatrixProvider(): array + { + return [ + // identity / casing + 'already valid lowercase' => ['mystack', 'mystack'], + 'uppercase only' => ['MyStack', 'mystack'], + 'mixed alphanumeric' => ['Stack2026v1', 'stack2026v1'], + 'underscore preserved' => ['my_stack_name', 'my_stack_name'], + 'dash preserved' => ['my-stack-name', 'my-stack-name'], + 'mixed valid separators' => ['my_stack-v2', 'my_stack-v2'], + + // space / dot replacement + 'single space' => ['my stack name', 'my_stack_name'], + 'multiple spaces' => ['Stack With Spaces', 'stack_with_spaces'], + 'dot replaced' => ['my.stack.name', 'my_stack_name'], + 'many consecutive dots' => ['name.with.many....dots', 'name_with_many_dots'], + + // separator collapsing + 'multiple underscores collapsed' => ['my___stack', 'my_stack'], + 'multiple dashes collapsed' => ['AdGuard---Home', 'adguard-home'], + 'mixed repeated separators' => ['Prod__API---V2', 'prod_api-v2'], + 'mixed A--B__C..D' => ['A--B__C..D', 'a-b_c_d'], + 'combined space+dot+dash' => ['My.Stack-Name Here', 'my_stack-name_here'], + 'consecutive specials all types' => ['my..stack--name here', 'my_stack-name_here'], + + // trim leading/trailing separators + 'leading dash' => ['-leading', 'leading'], + 'trailing dash' => ['trailing-', 'trailing'], + 'leading underscore' => ['_leading_underscore', 'leading_underscore'], + 'trailing underscore' => ['trailing_underscore_', 'trailing_underscore'], + 'leading+trailing mixed' => ['-_My Stack_-', 'my_stack'], + 'dots at boundaries' => ['.My Stack.', 'my_stack'], + 'complex boundary' => ['..-My Stack-Name-..', 'my_stack-name'], + + // special characters → underscore + 'plus and at' => ['Stack+Name@Home', 'stack_name_home'], + 'hash and exclamation' => ['Stack#Prod!Beta', 'stack_prod_beta'], + 'parens and brackets' => ['name(2026)[prod]{x}', 'name_2026_prod_x'], + 'comma semicolon equals' => ['app,backup;v2=final', 'app_backup_v2_final'], + 'dollar caret ampersand' => ['foo$bar^baz&qux', 'foo_bar_baz_qux'], + 'apostrophe' => ["rock'n'roll", 'rock_n_roll'], + 'slash' => ['stack/name', 'stack_name'], + 'colon' => ['stack:name', 'stack_name'], + 'at with version' => ['stack@v2.0', 'stack_v2_0'], + 'equals sign' => ['my.app=prod', 'my_app_prod'], + + // all-separator / empty → fallback + 'only dashes' => ['---', 'compose'], + 'only underscores' => ['___', 'compose'], + 'only dots' => ['...', 'compose'], + 'mixed separators only' => ['---___...', 'compose'], + 'whitespace only' => [' ', 'compose'], + 'empty string' => ['', 'compose'], + + // real-world stack names + 'Immich' => ['Immich', 'immich'], + 'AdGuard-Home' => ['AdGuard-Home', 'adguard-home'], + 'Pihole v6' => ['Pihole v6', 'pihole_v6'], + 'Jellyfin2025' => ['Jellyfin2025', 'jellyfin2025'], + 'traefik-v3.0' => ['traefik-v3.0', 'traefik-v3_0'], + 'Nginx_Proxy_Manager' => ['Nginx_Proxy_Manager', 'nginx_proxy_manager'], + 'WordPress' => ['WordPress', 'wordpress'], + 'Audible_Plex Downloader' => ['Audible_Plex Downloader', 'audible_plex_downloader'], + 'Home_Assistant' => ['Home_Assistant', 'home_assistant'], + 'Unifi_Network_Application' => ['Unifi_Network_Application', 'unifi_network_application'], + ]; + } + + /** + * sanitizeProjectString must be idempotent: running it twice produces the same output. + */ + #[\PHPUnit\Framework\Attributes\DataProvider('sanitizeProjectStringMatrixProvider')] + public function testSanitizeProjectStringIsIdempotent(string $input, string $_expected): void + { + $once = \StackInfo::sanitizeProjectString($input); + $twice = \StackInfo::sanitizeProjectString($once); + $this->assertEquals($once, $twice, "Not idempotent for input '$input'"); + } + // isIndirect() and getPath() were moved to StackInfo instance methods. // See StackInfoTest for indirect resolution coverage. @@ -271,7 +404,7 @@ public function testMigrateOnRenameDetectsByImage(): void file_put_contents($stackDir . '/compose.override.yaml', $override); try { - $overrideInfo = \OverrideInfo::fromStack($tempDir, $stackName); + $overrideInfo = \StackInfo::fromProject($tempDir, $stackName)->overrideInfo; $result = $overrideInfo->migrateOnRename($oldCompose, $newCompose); $this->assertTrue($result['migrated']); @@ -312,7 +445,7 @@ public function testMigrateOnRenameNoChangeWhenNoRenames(): void file_put_contents($stackDir . '/compose.override.yaml', $override); try { - $overrideInfo = \OverrideInfo::fromStack($tempDir, $stackName); + $overrideInfo = \StackInfo::fromProject($tempDir, $stackName)->overrideInfo; $result = $overrideInfo->migrateOnRename($compose, $compose); $this->assertFalse($result['migrated']); @@ -349,7 +482,7 @@ public function testMigrateOnRenameFallsBackToPositionalMatch(): void file_put_contents($stackDir . '/compose.override.yaml', $override); try { - $overrideInfo = \OverrideInfo::fromStack($tempDir, $stackName); + $overrideInfo = \StackInfo::fromProject($tempDir, $stackName)->overrideInfo; $result = $overrideInfo->migrateOnRename($oldCompose, $newCompose); $this->assertTrue($result['migrated']); diff --git a/tests/unit/common.bats b/tests/unit/common.bats index ec4bae57..1a97ba76 100644 --- a/tests/unit/common.bats +++ b/tests/unit/common.bats @@ -39,14 +39,139 @@ COMMON_SCRIPT="$BATS_TEST_DIRNAME/../../source/compose.manager/scripts/common.sh } # ============================================================ -# sanitize Tests +# canonicalize_project_name Tests # ============================================================ -@test "sanitize lowercases and replaces special chars with underscores" { +# Helper: source common.sh, run canonicalize_project_name, assert output matches. +assert_canonical_case() { + local input="$1" + local expected="$2" + + run canonicalize_project_name "$input" + [[ "$status" -eq 0 ]] + if [[ "$output" != "$expected" ]]; then + echo "canonicalize_project_name failed for '$input': expected '$expected', got '$output'" + return 1 + fi +} +@test "canonicalize_project_name lowercases and replaces spaces/dots with underscores, preserves dashes" { + # shellcheck disable=SC1090 + source "$COMMON_SCRIPT" + + run canonicalize_project_name "My Stack-Name.1" + + [[ "$status" -eq 0 ]] + [[ "$output" == "my_stack-name_1" ]] +} + +@test "canonicalize_project_name preserves already-valid project names" { + # shellcheck disable=SC1090 + source "$COMMON_SCRIPT" + + run canonicalize_project_name "stack-one" + + [[ "$status" -eq 0 ]] + [[ "$output" == "stack-one" ]] +} + +@test "canonicalize_project_name collapses repeated separators" { # shellcheck disable=SC1090 source "$COMMON_SCRIPT" - run sanitize "My Stack-Name.1" + run canonicalize_project_name "Stack---Name__1" + + [[ "$status" -eq 0 ]] + [[ "$output" == "stack-name_1" ]] +} + +@test "canonicalize_project_name trims leading and trailing separators" { + # shellcheck disable=SC1090 + source "$COMMON_SCRIPT" + + run canonicalize_project_name "..-My Stack-Name-.." + + [[ "$status" -eq 0 ]] + [[ "$output" == "my_stack-name" ]] +} + +@test "canonicalize_project_name falls back to compose for empty input" { + # shellcheck disable=SC1090 + source "$COMMON_SCRIPT" + + run canonicalize_project_name " " + + [[ "$status" -eq 0 ]] + [[ "$output" == "compose" ]] +} + +@test "canonicalize_project_name handles broad style matrix stably" { + # shellcheck disable=SC1090 + source "$COMMON_SCRIPT" + + while IFS='|' read -r input expected; do + assert_canonical_case "$input" "$expected" || return 1 + done <<'EOF' +mystack|mystack +MyStack|mystack +Stack2026v1|stack2026v1 +my_stack-v2|my_stack-v2 +My Stack-Name.1|my_stack-name_1 +My Stack.v2|my_stack_v2 +my___stack|my_stack +-_My Stack_-|my_stack +.My Stack.|my_stack +---___...|compose +Stack With Spaces|stack_with_spaces +name.with.many....dots|name_with_many_dots +Prod__API---V2|prod_api-v2 +A--B__C..D|a-b_c_d +MIXED_case-123|mixed_case-123 +stack/name|stack_name +stack:name|stack_name +stack@v2.0|stack_v2_0 +Stack+Name@Home|stack_name_home +Stack#Prod!Beta|stack_prod_beta +name(2026)[prod]{x}|name_2026_prod_x +app,backup;v2=final|app_backup_v2_final +foo$bar^baz&qux|foo_bar_baz_qux +rock'n'roll|rock_n_roll +-leading|leading +trailing-|trailing +_leading_underscore|leading_underscore +trailing_underscore_|trailing_underscore +---|compose +___|compose + |compose +Immich|immich +AdGuard-Home|adguard-home +Pihole v6|pihole_v6 +traefik-v3.0|traefik-v3_0 +Nginx_Proxy_Manager|nginx_proxy_manager +WordPress|wordpress +Audible_Plex Downloader|audible_plex_downloader +EOF +} + +@test "canonicalize_project_name is idempotent" { + # shellcheck disable=SC1090 + source "$COMMON_SCRIPT" - [[ "$output" == "my_stack_name_1" ]] + while IFS= read -r input; do + local once twice + run canonicalize_project_name "$input" + once="$output" + run canonicalize_project_name "$once" + twice="$output" + if [[ "$once" != "$twice" ]]; then + echo "idempotency failed for '$input': first='$once' second='$twice'" + return 1 + fi + done <<'EOF' +My Space Stack +Prod__API---V2 +my..stack--prod__v1 +Stack+Name@Home +---___... +-leading +EOF } diff --git a/tests/unit/compose.bats b/tests/unit/compose.bats index e260a2d3..7c10c646 100644 --- a/tests/unit/compose.bats +++ b/tests/unit/compose.bats @@ -98,6 +98,12 @@ test_setup() { assert_success } +@test "compose.sh logs action uses explicit project name" { + # Stack logs must use the sanitized project name the plugin started the stack with. + run grep -E '^\s*"\$\{compose_base\[@\]\}" -p "\$name" logs -f 2>&1' "$COMPOSE_SCRIPT" + assert_success +} + @test "compose.sh update action pull step uses --ignore-buildable" { # The update action pulls before 'up -d --build'; buildable services are handled by --build run grep -E 'pull --ignore-buildable' "$COMPOSE_SCRIPT" @@ -229,9 +235,16 @@ test_setup() { # Project Name Sanitizer Tests # ============================================================ +# Delegate to the canonical PHP sanitizer so these tests always exercise +# the same logic path as production PHP and shell code. +PROJECT_NAME_SANITIZER_PHP="$BATS_TEST_DIRNAME/../../source/compose.manager/include/ProjectNameSanitizer.php" + sanitize_project_name() { local value="$1" - echo "$value" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/_/g; s/__*/_/g; s/^[_-]*//; s/[_-]*$//' + php -r ' +require_once $argv[1]; +echo compose_manager_sanitize_project_name((string) ($argv[2] ?? "")); +' "$PROJECT_NAME_SANITIZER_PHP" "$value" } assert_sanitize_case() { @@ -265,7 +278,11 @@ My Stack.v2|my_stack_v2 my___stack|my_stack -_My Stack_-|my_stack .My Stack.|my_stack ----___...| +Stack With Spaces|stack_with_spaces +name.with.many....dots|name_with_many_dots +Prod__API---V2|prod_api-v2 +A--B__C..D|a-b_c_d +---___...|compose EOF } @@ -279,6 +296,10 @@ name(2026)[prod]{x}|name_2026_prod_x app,backup;v2=final|app_backup_v2_final foo$bar^baz&qux|foo_bar_baz_qux rock'n'roll|rock_n_roll +stack/name|stack_name +stack:name|stack_name +stack@v2.0|stack_v2_0 +my.app=prod|my_app_prod EOF } @@ -299,5 +320,46 @@ WordPress|wordpress audplexus|audplexus netdata|netdata Audible_Plex Downloader|audible_plex_downloader +Home_Assistant|home_assistant +AdGuard-Home|adguard-home +Pihole v6|pihole_v6 +Jellyfin2025|jellyfin2025 +traefik-v3.0|traefik-v3_0 +EOF +} + +@test "project name sanitizer boundary cases" { + while IFS='|' read -r input expected; do + assert_sanitize_case "$input" "$expected" || return 1 + done <<'EOF' +-leading|leading +trailing-|trailing +_leading_underscore|leading_underscore +trailing_underscore_|trailing_underscore +---|compose +___|compose + |compose +a|a +1|1 +a-b|a-b +a_b|a_b +EOF +} + +@test "project name sanitizer is idempotent" { + while IFS= read -r input; do + once=$(sanitize_project_name "$input") + twice=$(sanitize_project_name "$once") + if [ "$once" != "$twice" ]; then + echo "idempotency failed for '$input': first='$once' second='$twice'" + return 1 + fi + done <<'EOF' +My Space Stack +Prod__API---V2 +my..stack--prod__v1 +Stack+Name@Home +---___... +-leading EOF }