diff --git a/.github/workflows/sync-plugin-url.yml b/.github/workflows/sync-plugin-url.yml index d2b38d9b..3f59717a 100644 --- a/.github/workflows/sync-plugin-url.yml +++ b/.github/workflows/sync-plugin-url.yml @@ -80,7 +80,7 @@ jobs: if [[ "$CURRENT_URL_BRANCH" != "$BRANCH" ]]; then echo "Branch mismatch - fixing pluginURL..." - sed -i -E "s#^##" "$PLG_FILE" + sed -i -E "s#^##" "$PLG_FILE" EXPECTED_LINE="" if ! grep -Fxq "$EXPECTED_LINE" "$PLG_FILE"; then diff --git a/README.md b/README.md index 18a494d5..ae32bf65 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,12 @@ The built-in editor provides multiple tabs for managing your compose stack: - **Autostart & Shutdown** - Configurable per-stack autostart with optional force-recreate, wait-for-Docker, and configurable timeouts. Graceful shutdown handling. - **Visibility & Filtering** - Optionally hide Compose-managed containers from the native Docker UI and Dashboard tile to avoid duplicate entries (behavior varies by unRAID version). - **Backup & Restore** - Manual and scheduled backups with selective restore from the UI. -- **Web Terminal** - Integrated ttyd terminal for live, colorized compose command output; also supports a basic output mode. +- **Web Terminal** - Integrated ttyd terminal for live, colorized compose command output. - **Profiles** - Full support for Docker Compose profiles, including multi-profile selection per action. - **External Paths** - Compose files and env files can live outside the default projects folder (external compose path and env path per stack). - **Override File Management** - Centralized management of override files (`compose.override.yaml`, `compose.override.yml`, `docker-compose.override.yaml`, `docker-compose.override.yml`) with service rename handling. - **Stack Editor** - Full-screen modal editor with four tabs: Compose (YAML with live validation), ENV, Web UI Labels (icon/WebUI/shell per service via override file), and Settings (name, description, icon URL, WebUI URL, default profile, external paths). Ctrl+S to save, Esc to close. -- **Basic / Advanced View Toggle** - Toggle between a compact view and an advanced view exposing additional columns (SHA diffs, force-update links) — scoped to the Compose tab to avoid affecting the Docker tab. +- **Column Customizer & Layout** - Choose which stack and service columns are visible and in what order; extra detail fields (for example SHA snippets and force-update links) follow your saved layout in the Compose tab. - **Compose File Discovery** - Automatically detects all four standard compose file names (`compose.yaml`, `docker-compose.yaml`, `compose.yml`, `docker-compose.yml`). - **Filename Preservation** - Existing compose and override filenames are preserved; upgrades do not auto-rename your stack files. - **Build Stack Support** - Stacks with a `build:` section in the compose file are detected automatically; context menu and update labels adapt ("Build", "Build & Up", "Update & Rebuild"). diff --git a/compose.manager.plg b/compose.manager.plg index 864e5226..ca284bf1 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,15 +2,15 @@ - + - - + + - + @@ -36,67 +36,49 @@ > -###2026.07.02 -- Features (compose): enhance column customization with reorder functionality and UI updates -- Features (compose): implement stack row striping synchronization for improved UI consistency -- Features (compose): add health status column and related functionality to stack and container views -- Features: add custom interval settings for compose stats and enhance nchan publisher -- Features: refactor column customization UI with transfer lists and responsive design -- Features: enhance stack toggle functionality with batching and user intent tracking -- Features: add expand/collapse all functionality for compose stacks -- Features: promote col customizer and remove basic/advanced toggle -- Features: Implement snapshot loading for compose metrics and enhance data publishing to ensure data shows during progressive loading -- Features: Add column visibility customization for stack and service tables -- Features (tests): add tests for pruning auto-managed and legacy labels only orphans -- Features: add 'Remove Orphans by Default' option and integrate into compose actions -- Features: add progressive stack list loading -- Features (container): add persistent container cache and icon resolution logic -- Features: return to update dialog when changelog is dismissed -- Features: show docker.versions changelogs in the update dialog -- Bug Fixes (composeStats): enhance memory value parsing and sanitize docker stats fields -- Bug Fixes: enhance logging for stack management and settings handling -- Bug Fixes: add support for --remove-orphans flag in compose command -- Bug Fixes: add support for removeOrphans flag in performComposeAction -- Bug Fixes: delay stack refresh after prompt to ensure proper UI feedback -- Bug Fixes (cache): add endpoint to retrieve persistent container cache and update loading method -- Bug Fixes (deploy.sh, test.sh): update file permissions to make scripts executable -- Bug Fixes (Exec.php): update dockerVersionsInstalled check to verify plugin directory existence and cleanup tests -- Bug Fixes (ExecActionsTest): improve testGetSavedUpdateStatusDockerVersionsNotInstalled for determinism -- Bug Fixes (composeLoadlist): bind autostart change handler early for dynamic rows and update loading status on success -- Bug Fixes (Exec.php): improve dockerVersionsInstalled check to verify actual changelog assets -- Bug Fixes (sync-plugin-url): enhance branch detection and pluginURL synchronization logic -- Bug Fixes (docker.versions): conditionally load external styles for Docker versions if available -- Bug Fixes (compose): optimize cache invalidation logic based on stack/container structure changes -- Bug Fixes (changelog): conditionally load changelog script if available so it works when compose is on its own tab -- Bug Fixes (update): update button now triggers update action instead of always triggering force update -- Bug Fixes (composeSortable): enhance details row detachment logic during drag-and-drop -- Bug Fixes (composeSortable): improve drag-and-drop behavior by detaching non-sortable rows -- Bug Fixes (profiles): unify action profile picker and enforce profile precedence -- Bug Fixes: plug poll interval leak and skip dialog reopen when warnings disabled -- Bug Fixes: whitelist changelog message types, filter scaffolding noise -- Bug Fixes: discard stale Nchan buffer and concurrent changelog messages -- Bug Fixes: correct version and package details in plugin metadata -- Performance (compose): throttle dockerload cache paints during websocket bursts -- Performance (compose): avoid full-table applyListView during progressive row inserts -- Performance (compose): debounce composeListRefreshed during progressive row load -- Performance: implement parallel project loading with request queuing and progress tracking -- Refactoring: update echoComposeCommand and echoComposeCommandMultiple to use options array -- Refactoring: delegate changelog display to docker.versions' showChangeLog() -- Documentation: document docker.versions changelog integration in user guide -- Tests: add unit tests for getSavedUpdateStatus docker.versions detection -- Chores: update changelog for v2026.06.18.1637 [skip ci] -- Chores: update changelog for v2026.06.15.1000 [skip ci] -- Chores: update changelog for v2026.06.14.1531 [skip ci] -- Chores: update changelog for v2026.06.14.1530 [skip ci] -- Chores: update changelog for v2026.06.14.1451 [skip ci] -- Chores: update changelog for v2026.06.11.1526 [skip ci] -- Chores: update changelog for v2026.06.11.1517 [skip ci] +###2026.08.23.2252 +- Features: enhance error handling and metadata retrieval in compose loading process +- Features: implement client-side control for compose_info publisher and enhance WebSocket management for hidden tabs +- Features: add additional ignored error messages for PHPStan analysis +- Features: implement plugin-local update check for Compose Manager to enhance image update accuracy +- Features (tests): enhance foreground operation tests for Compose Manager with modal interactions and image validation +- Features (tests): add UI operation flow tests for Compose Manager and extract shared helpers +- Features (notification): add composeSendNotification function for stack delete notifications +- Features (e2e): add Playwright end-to-end tests for Compose Manager +- Features (cache): add function to purge deleted stack caches and update UI accordingly +- Features (settings): enhance auto-check updates functionality with mode selection and custom intervals +- Features (settings-page): add functions to count changed settings and update UI accordingly +- Features (settings-page): enhance global actions and settings form handling +- Features (settings-page): enhance defaults with auto/manual verbage +- Features (settings-page): enhance settings tab with descriptive help text and rephrase section titles +- Features (settings-page): reorganize and enhance update checking section +- Features (column-layout): implement shared column layout and visibility helpers +- Bug Fixes (tests): use PATH_SEPARATOR for COMPOSE_FILE in env file to support Windows development +- Bug Fixes (icons): resolve native Docker tab/Dashboard icons for local paths (#129) +- Bug Fixes: add missing Playwright dependencies installation step in E2E test setup +- Bug Fixes: ensure function definitions are wrapped in function_exists checks to prevent redeclaration errors +- Bug Fixes: remove HTML coverage report generation from PHPUnit configuration +- Bug Fixes: enhance image digest retrieval and add running container check in auto-update script to match manual update path Fixes #132 +- Bug Fixes: ensure new stacks create app-managed project override templates and update tests accordingly Fixes #134 +- Bug Fixes: ensure proper exit code propagation for compose commands and record operation exit status +- Bug Fixes (nchan): optimize docker stats publishing and address shared-memory exhaustion +- Bug Fixes: enhance stack update handling with improved reload queue management +- Bug Fixes (tests): ensure tests remove containers each time +- Bug Fixes (settings): remove unused referrer handling script for cleaner settings page +- Bug Fixes (settings): add debug logging functions for settings changes and state tracking +- Bug Fixes (stack): simplify removal logic in composeDeleteStackFolder function +- Bug Fixes (ui): make stack deletion dynamic without full reload +- Bug Fixes (stack): guard and harden deleteStack folder removal +- Bug Fixes (menu): centralize header menu visibility condition +- Bug Fixes (compose-pull): refactor ComposePullConfirmed function to use options object for parameters +- Bug Fixes (sync-plugin-url): correct escaping in sed command for pluginURL update +- Refactoring: remove basic output style handling +- Refactoring (settings-page): reorganize settings and update help funcs - Chores: sync pluginURL+README for dev branch [skip ci] -- Chores: remove test compose setup -- Fix dropdown z-index on docker page when clicking on stack icon -- Add support for additional compose files per stack -- Potential fix for pull request finding -- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.06.06...v2026.07.02) +- Chores (columns): cleanup advanced/basic view mode references and legacy logic +- Refactor IconTest and remove redundant tests; add ComposeListEndpointTest +- [PR #139](https://github.com/mstrhakr/compose_plugin/pull/139) +- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.07.02...v2026.08.23.2252) diff --git a/docs/testing.md b/docs/testing.md index 9640721d..ded3beea 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -30,6 +30,36 @@ php vendor/bin/phpunit --coverage-html tests/coverage/html docker run --rm -v "${PWD}:/code" -w /code bats/bats:latest tests/unit/*.bats ``` +### Running Playwright E2E (Live Server, Read-Only) + +The E2E suite is intentionally designed for non-destructive smoke checks against a real Unraid server. + +```bash +cd tests/e2e +npm install +npx playwright install chromium firefox +``` + +Create or refresh auth state: + +```bash +cd tests/e2e +E2E_BASE_URL="https://your-unraid-host" npx playwright codegen "$E2E_BASE_URL" --save-storage=.auth/storage-state.json +``` + +Run the suite: + +```bash +cd tests/e2e +E2E_BASE_URL="https://your-unraid-host" \ +E2E_STORAGE_STATE=".auth/storage-state.json" \ +E2E_COMPOSE_PATH="/Docker/Compose" \ +E2E_IGNORE_HTTPS_ERRORS="1" \ +npm test +``` + +See `tests/e2e/README.md` for details and safety guardrails. + ## VS Code Integration The workspace is configured with PHPUnit Test Explorer. After opening the project: @@ -41,7 +71,7 @@ The workspace is configured with PHPUnit Test Explorer. After opening the projec ## Test Structure -``` +```text tests/ ├── framework/ # Submodule: plugin-tests framework │ └── src/ diff --git a/phpstan.neon b/phpstan.neon index 28fcc203..074f97be 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -22,6 +22,7 @@ parameters: - "#my_scale_status#" - "#csrf_token#" - "#getComposeRoot#" + - "#getCurlHandle#" identifier: function.notFound # Platform classes (Docker management) - messages: @@ -29,6 +30,11 @@ parameters: - "#DockerUpdate#" - "#DockerUtil#" identifier: class.notFound + # method_exists() against platform classes above is always false in + # static analysis (the classes only exist at Unraid runtime), which is + # expected -- it's a runtime capability check, not a real bug. + - message: "#Call to function method_exists\\(\\) with 'DockerUtil' and#" + identifier: function.impossibleType # Global variables from platform/defines.php - message: "#Variable \\$\\w+ might not be defined#" identifier: variable.undefined diff --git a/phpunit.xml b/phpunit.xml index bf6745f1..cfbe15f8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,6 @@ - diff --git a/playwright.sh b/playwright.sh new file mode 100755 index 00000000..18cdb29c --- /dev/null +++ b/playwright.sh @@ -0,0 +1,233 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +E2E_DIR="$SCRIPT_DIR/tests/e2e" +SERVERS_ENV_FILE="$E2E_DIR/.auth/servers.env" +AUTH_ENV_FILE="$E2E_DIR/.auth/auth.env" + +REMOTE_HOST="" +USER_NAME="root" +REMOTE_DIR="/tmp" +BASE_URL="" +STORAGE_STATE="$E2E_DIR/.auth/storage-state.json" +COMPOSE_PATH="/Docker/Compose" +IGNORE_HTTPS_ERRORS="1" +TEST_STACK="" +ENABLE_MUTATION_TESTS="0" +TEST_STACK_PREFIX="pw-e2e" +EXTERNAL_TEST_DIR="" +COMPOSE_VERSION="" +SKIP_DEPLOY=false + +PLAYWRIGHT_ARGS=() + +load_env_file() { + local env_file="$1" + if [[ -f "$env_file" ]]; then + set -a + # shellcheck disable=SC1090 + # shellcheck source=/dev/null + source "$env_file" + set +a + fi +} + +load_env_file "$SERVERS_ENV_FILE" +load_env_file "$AUTH_ENV_FILE" + +# Honor pre-set environment values from sourced .env files unless overridden by CLI flags. +if [[ -n "${E2E_ENABLE_MUTATION_TESTS:-}" ]]; then + ENABLE_MUTATION_TESTS="$E2E_ENABLE_MUTATION_TESTS" +fi +if [[ -n "${E2E_TEST_STACK_PREFIX:-}" ]]; then + TEST_STACK_PREFIX="$E2E_TEST_STACK_PREFIX" +fi +if [[ -n "${E2E_EXTERNAL_TEST_DIR:-}" ]]; then + EXTERNAL_TEST_DIR="$E2E_EXTERNAL_TEST_DIR" +fi + +usage() { + cat <<'EOF' +Usage: ./playwright.sh [options] [-- ] + +Options: + -RemoteHost Required unless -SkipDeploy is used. + -User SSH user for deploy (default: root) + -RemoteDir Remote temp directory for deploy (default: /tmp) + -BaseURL E2E base URL (default: https://) + -StorageState Playwright auth state JSON path + -ComposePath Compose page path (default: /Docker/Compose) + -IgnoreHttpsErrors <0|1> Playwright TLS ignore toggle (default: 1) + -TestStack Optional stack name to target for single-stack read-only tests + -EnableMutationTests <0|1> Enable GUID isolated create/edit/start/stop/delete tests (default: 0) + -TestStackPrefix Prefix for generated GUID stack name (default: pw-e2e) + -ExternalTestDir Enables external-path mutation tests and sets their target directory + -ComposeVersion Optional compose version forwarded to deploy.sh + -SkipDeploy Skip deploy.sh -Dev and run tests only + -Help, -h Show this help + +Examples: + ./playwright.sh -RemoteHost saturn + ./playwright.sh -RemoteHost saturn -- --headed + ./playwright.sh -SkipDeploy -BaseURL https://tower.local -- --project firefox +EOF + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --) + shift + PLAYWRIGHT_ARGS+=("$@") + break + ;; + -RemoteHost|--RemoteHost) + REMOTE_HOST="$2" + shift 2 + ;; + -User|--User) + USER_NAME="$2" + shift 2 + ;; + -RemoteDir|--RemoteDir) + REMOTE_DIR="$2" + shift 2 + ;; + -BaseURL|--BaseURL) + BASE_URL="$2" + shift 2 + ;; + -StorageState|--StorageState) + STORAGE_STATE="$2" + shift 2 + ;; + -ComposePath|--ComposePath) + COMPOSE_PATH="$2" + shift 2 + ;; + -IgnoreHttpsErrors|--IgnoreHttpsErrors) + IGNORE_HTTPS_ERRORS="$2" + shift 2 + ;; + -TestStack|--TestStack) + TEST_STACK="$2" + shift 2 + ;; + -EnableMutationTests|--EnableMutationTests) + ENABLE_MUTATION_TESTS="$2" + shift 2 + ;; + -TestStackPrefix|--TestStackPrefix) + TEST_STACK_PREFIX="$2" + shift 2 + ;; + -ExternalTestDir|--ExternalTestDir) + EXTERNAL_TEST_DIR="$2" + shift 2 + ;; + -ComposeVersion|--ComposeVersion) + COMPOSE_VERSION="$2" + shift 2 + ;; + -SkipDeploy|--SkipDeploy) + SKIP_DEPLOY=true + shift + ;; + -Help|--Help|-h) + usage + ;; + *) + echo "Unknown option: $1" >&2 + usage + ;; + esac +done + +if [[ "$SKIP_DEPLOY" == false && -z "$REMOTE_HOST" ]]; then + echo "RemoteHost is required unless -SkipDeploy is used." >&2 + usage +fi + +if [[ -z "$BASE_URL" ]]; then + if [[ -n "$REMOTE_HOST" ]]; then + BASE_URL="https://$REMOTE_HOST" + else + echo "BaseURL is required when using -SkipDeploy without -RemoteHost." >&2 + usage + fi +fi + +if [[ "$SKIP_DEPLOY" == false ]]; then + deploy_cmd=(bash "$SCRIPT_DIR/deploy.sh" -Dev -RemoteHost "$REMOTE_HOST" -User "$USER_NAME" -RemoteDir "$REMOTE_DIR") + if [[ -n "$COMPOSE_VERSION" ]]; then + deploy_cmd+=(-ComposeVersion "$COMPOSE_VERSION") + fi + + echo "Running deploy first (includes build/test/static checks):" + printf ' %q' "${deploy_cmd[@]}" + printf '\n' + "${deploy_cmd[@]}" +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "npm is required to run Playwright tests." >&2 + exit 1 +fi + +if ! command -v npx >/dev/null 2>&1; then + echo "npx is required to run Playwright tests." >&2 + exit 1 +fi + +if [[ ! -d "$E2E_DIR" ]]; then + echo "E2E directory not found: $E2E_DIR" >&2 + exit 1 +fi + +if [[ ! -d "$E2E_DIR/node_modules" ]]; then + echo "Installing Playwright dependencies in $E2E_DIR ..." + (cd "$E2E_DIR" && npm install) +fi + +if [[ ! -f "$STORAGE_STATE" ]]; then + if [[ -n "${AUTH_USERNAME:-}" && -n "${AUTH_PASSWORD:-}" ]]; then + echo "Storage state not found. Attempting headless auth-state generation..." + ( + cd "$E2E_DIR" + E2E_BASE_URL="$BASE_URL" \ + E2E_STORAGE_STATE="$STORAGE_STATE" \ + E2E_IGNORE_HTTPS_ERRORS="$IGNORE_HTTPS_ERRORS" \ + AUTH_USERNAME="$AUTH_USERNAME" \ + AUTH_PASSWORD="$AUTH_PASSWORD" \ + node ./scripts/create-storage-state.mjs + ) + fi + + if [[ ! -f "$STORAGE_STATE" ]]; then + echo "Storage state not found: $STORAGE_STATE" >&2 + echo "Create it with one of:" >&2 + echo " 1) Fill tests/e2e/.auth/auth.env (AUTH_USERNAME/AUTH_PASSWORD) and re-run ./playwright.sh" >&2 + echo " 2) cd $E2E_DIR && E2E_BASE_URL=\"$BASE_URL\" npx playwright codegen \"$BASE_URL\" --save-storage=.auth/storage-state.json" >&2 + exit 1 + fi +fi + +run_cmd=(npx playwright test) +if [[ ${#PLAYWRIGHT_ARGS[@]} -gt 0 ]]; then + run_cmd+=("${PLAYWRIGHT_ARGS[@]}") +fi + +echo "Running Playwright E2E against $BASE_URL" +( + cd "$E2E_DIR" + E2E_BASE_URL="$BASE_URL" \ + E2E_STORAGE_STATE="$STORAGE_STATE" \ + E2E_COMPOSE_PATH="$COMPOSE_PATH" \ + E2E_IGNORE_HTTPS_ERRORS="$IGNORE_HTTPS_ERRORS" \ + E2E_TEST_STACK="$TEST_STACK" \ + E2E_ENABLE_MUTATION_TESTS="$ENABLE_MUTATION_TESTS" \ + E2E_TEST_STACK_PREFIX="$TEST_STACK_PREFIX" \ + E2E_EXTERNAL_TEST_DIR="$EXTERNAL_TEST_DIR" \ + "${run_cmd[@]}" +) diff --git a/source/compose.manager/Compose.page b/source/compose.manager/Compose.page index 668a7c66..e83effc1 100644 --- a/source/compose.manager/Compose.page +++ b/source/compose.manager/Compose.page @@ -5,7 +5,7 @@ Tag="fa-cubes" Code="f1b3" Lock="true" Nchan="compose_info" -Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep 'true'\")" +Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && exec('/usr/local/emhttp/plugins/compose.manager/scripts/header_menu_visibility.sh show')" --- 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.page b/source/compose.manager/compose.manager.page index f3b0c80f..45b753b1 100644 --- a/source/compose.manager/compose.manager.page +++ b/source/compose.manager/compose.manager.page @@ -4,7 +4,7 @@ Type="php" Menu="Docker:2" Lock="true" Nchan="compose_info" -Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && (!file_exists('/boot/config/plugins/compose.manager/compose.manager.cfg') ? true : exec(\"grep '^SHOW_COMPOSE_IN_HEADER_MENU=' /boot/config/plugins/compose.manager/compose.manager.cfg 2>/dev/null | grep -v 'true'\"))" +Cond="$var['fsState'] == 'Started' && exec('/etc/rc.d/rc.docker status | grep -v \"not\"') && exec('/usr/local/emhttp/plugins/compose.manager/scripts/header_menu_visibility.sh hide')" --- disable/enable hide containers option - function toggleHideContainers() { - var dashboardTileEnabled = $('#SHOW_DASHBOARD_TILE').is(':checked'); - var $hide = $('#HIDE_DOCKER_COMPOSE_CONTAINERS'); - var $wrapper = $hide.closest('dl'); - if (!dashboardTileEnabled) { - if ($hide.is(':checked')) { - $hide.prop('checked', false).change(); - } - $wrapper.css({ - opacity: 0.4, - pointerEvents: 'none' - }); - } else { - $wrapper.css({ - opacity: 1, - pointerEvents: '' - }); - } - } - // Patch UI toggle: confirm action and run patch.sh via server (uses existing ajax handlers) if (!skipPatchUI) { $('.compose-toggle[name="PATCH_UI"]').on('change', function(e) { @@ -811,10 +907,10 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' // the header menu on then off preserves the user's previous selections. function toggleComposeOnTop() { var headerMenuEnabled = $('#SHOW_COMPOSE_IN_HEADER_MENU').is(':checked'); - var $onTop = $('#SHOW_COMPOSE_ON_TOP'); - var $wrapper = $onTop.closest('dl'); + var $wrapper = $('#docker-page-integration-wrapper'); if (headerMenuEnabled) { // Save current state before disabling + var $onTop = $('#SHOW_COMPOSE_ON_TOP'); if (typeof $onTop.data('saved-state') === 'undefined') { $onTop.data('saved-state', $onTop.is(':checked')); } @@ -831,6 +927,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' pointerEvents: '' }); // Restore previously saved state + var $onTop = $('#SHOW_COMPOSE_ON_TOP'); var saved = $onTop.data('saved-state'); if (typeof saved !== 'undefined') { $onTop.prop('checked', saved).change(); @@ -844,10 +941,10 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' // Handle header menu toggle -> disable/enable hide compose from docker tab function toggleHideFromDocker() { var headerMenuEnabled = $('#SHOW_COMPOSE_IN_HEADER_MENU').is(':checked'); - var $hide = $('#HIDE_COMPOSE_FROM_DOCKER'); - var $wrapper = $hide.closest('dl'); + var $wrapper = $('#docker-page-hide-wrapper'); if (headerMenuEnabled) { // Save current state before disabling + var $hide = $('#HIDE_COMPOSE_FROM_DOCKER'); if (typeof $hide.data('saved-state') === 'undefined') { $hide.data('saved-state', $hide.is(':checked')); } @@ -864,6 +961,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' pointerEvents: '' }); // Restore previously saved state + var $hide = $('#HIDE_COMPOSE_FROM_DOCKER'); var saved = $hide.data('saved-state'); if (typeof saved !== 'undefined') { $hide.prop('checked', saved).change(); @@ -873,11 +971,14 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' } // Handle dashboard tile toggle -> disable/enable hide containers option - function toggleExpandByDefault() { - var stacksDefaultExpanded = $('#STACKS_DEFAULT_EXPANDED').is(':checked'); - var $hide = $('#ONLY_EXPAND_RUNNING_STACKS'); - var $wrapper = $hide.closest('dl'); - if (!stacksDefaultExpanded) { + function toggleHideFromDashboard() { + var dashboardTileEnabled = $('#SHOW_DASHBOARD_TILE').is(':checked'); + var $wrapper = $('#dashboard-hide-wrapper'); + if (!dashboardTileEnabled) { + var $hide = $('#HIDE_DOCKER_COMPOSE_CONTAINERS'); + if (typeof $hide.data('saved-state') === 'undefined') { + $hide.data('saved-state', $hide.is(':checked')); + } if ($hide.is(':checked')) { $hide.prop('checked', false).change(); } @@ -890,33 +991,63 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' opacity: 1, pointerEvents: '' }); + // Restore previously saved state + var $hide = $('#HIDE_DOCKER_COMPOSE_CONTAINERS'); + var saved = $hide.data('saved-state'); + if (typeof saved !== 'undefined') { + $hide.prop('checked', saved).change(); + $hide.removeData('saved-state'); + } } } // Convert checkbox values to true/false strings before form submission function serializeSettingsForm($form) { + $form.find('input.compose-generated-hidden').remove(); $('.compose-toggle').each(function() { var $checkbox = $(this); - var name = $checkbox.attr('name'); - // Remove any existing hidden field for this setting - $('input[type="hidden"][name="' + name + '"]').remove(); - var value; - if (name === 'OUTPUTSTYLE') { - value = $checkbox.is(':checked') ? 'ttyd' : 'basic'; - } else { - value = $checkbox.is(':checked') ? 'true' : 'false'; + var name = $checkbox.attr('name') || $checkbox.data('compose-original-name'); + if (!name) return; + + if (!$checkbox.data('compose-original-name')) { + $checkbox.data('compose-original-name', name); } - $checkbox.before(''); + + var value = $checkbox.is(':checked') ? 'true' : 'false'; + + $('') + .attr('name', name) + .val(value) + .insertBefore($checkbox); + $checkbox.removeAttr('name'); }); + + window.setTimeout(function() { + $('.compose-toggle').each(function() { + var $checkbox = $(this); + var originalName = $checkbox.data('compose-original-name'); + if (originalName && !$checkbox.attr('name')) { + $checkbox.attr('name', originalName); + } + }); + $form.find('input.compose-generated-hidden').remove(); + }, 300); } + var bypassComposeSettingsSubmitGuard = false; + $('form[name="compose_manager_settings"]').on('submit', function(e) { var $form = $(this); + if (bypassComposeSettingsSubmitGuard) { + bypassComposeSettingsSubmitGuard = false; + serializeSettingsForm($form); + return; + } + var currentDisableActionWarnings = $('#DISABLE_ACTION_WARNINGS').is(':checked'); var currentRunInBackgroundDefault = $('#RUN_IN_BACKGROUND_DEFAULT').is(':checked'); var isCrossed = currentDisableActionWarnings && currentRunInBackgroundDefault; - var isOrigCrossed = origDisableActionWarnings && origRunInBackgroundDefault; var eitherChanged = (currentDisableActionWarnings !== origDisableActionWarnings) || (currentRunInBackgroundDefault !== origRunInBackgroundDefault); if (isCrossed && eitherChanged) { @@ -932,9 +1063,8 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' // do nothing; keep user on settings page return; } - serializeSettingsForm($form); - $form.off('submit'); - $form.submit(); + bypassComposeSettingsSubmitGuard = true; + $form.trigger('submit'); }); return; } @@ -942,6 +1072,526 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' serializeSettingsForm($form); }); + var settingsUnifiedState = { + activeTab: 'settings', + updatesInitialized: false, + debugReady: false, + baseline: { + settings: '', + updates: '', + imageCheck: '', + backup: '' + }, + dirty: { + settings: false, + updates: false, + imageCheck: false, + backup: false + } + }; + + function safeStringify(value) { + return JSON.stringify(value); + } + + function captureSettingsSnapshot() { + var data = {}; + $('#compose-tab-settings').find('input, select, textarea').each(function() { + var $el = $(this); + var type = ($el.attr('type') || '').toLowerCase(); + if (type === 'hidden' || type === 'submit' || type === 'button') return; + var key = $el.attr('id') || $el.attr('name'); + if (!key) return; + if (type === 'checkbox' || type === 'radio') { + data[key] = $el.is(':checked') ? 'true' : 'false'; + } else { + data[key] = $el.val(); + } + }); + return safeStringify(data); + } + + function captureBackupSnapshot() { + var data = { + BACKUP_DESTINATION: $('#BACKUP_DESTINATION').val() || '', + BACKUP_RETENTION: $('#BACKUP_RETENTION').val() || '', + BACKUP_SCHEDULE_ENABLED: $('#BACKUP_SCHEDULE_ENABLED').is(':checked') ? 'true' : 'false', + BACKUP_SCHEDULE_FREQUENCY: $('#BACKUP_SCHEDULE_FREQUENCY').val() || '', + BACKUP_SCHEDULE_DAY: $('#BACKUP_SCHEDULE_DAY').val() || '', + BACKUP_SCHEDULE_TIME: $('#BACKUP_SCHEDULE_TIME').val() || '' + }; + return safeStringify(data); + } + + function captureUpdatesSnapshot() { + if (!settingsUnifiedState.updatesInitialized) { + return ''; + } + + var rows = []; + $('#updates-tbody tr').each(function() { + var $row = $(this); + var path = $row.data('path'); + if (!path) return; + rows.push({ + path: path, + enabled: $row.find('.au-enabled').is(':checked') ? 'true' : 'false', + schedule: $row.find('.au-schedule').val() || '', + time: $row.find('.au-time').val() || '', + weekday: $row.find('.au-weekday').val() || '', + monthday: $row.find('.au-monthday').val() || '' + }); + }); + + rows.sort(function(a, b) { + return String(a.path).localeCompare(String(b.path)); + }); + + var data = { + AUTOUPDATE_ENABLED: $('#AUTOUPDATE_ENABLED').is(':checked') ? 'true' : 'false', + default_schedule: $('#default-schedule').val() || '', + default_time: $('#default-time').val() || '', + rows: rows + }; + + return safeStringify(data); + } + + function captureImageCheckSnapshot() { + return safeStringify({ + AUTO_CHECK_UPDATES: $('#AUTO_CHECK_UPDATES').is(':checked') ? 'true' : 'false', + AUTO_CHECK_UPDATES_DAYS: $('#AUTO_CHECK_UPDATES_DAYS').val() || '' + }); + } + + function getCurrentSettingsTabFromDom() { + var tabId = $('.compose-tab.active').data('tab'); + return tabId || settingsUnifiedState.activeTab || 'settings'; + } + + function parseSnapshot(snapshot) { + if (!snapshot) { + return null; + } + + try { + return JSON.parse(snapshot); + } catch (e) { + return null; + } + } + + function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]'; + } + + function countLeafValues(value) { + if (value === undefined || value === null) { + return 1; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return 1; + } + + var totalArrayLeaves = 0; + for (var i = 0; i < value.length; i++) { + totalArrayLeaves += countLeafValues(value[i]); + } + + return totalArrayLeaves; + } + + if (isPlainObject(value)) { + var keys = Object.keys(value); + if (keys.length === 0) { + return 1; + } + + var totalObjectLeaves = 0; + for (var j = 0; j < keys.length; j++) { + totalObjectLeaves += countLeafValues(value[keys[j]]); + } + + return totalObjectLeaves; + } + + return 1; + } + + function countChangedSettingsValues(currentValue, baselineValue) { + if (currentValue === baselineValue) { + return 0; + } + + if (currentValue === undefined || baselineValue === undefined) { + return countLeafValues(currentValue === undefined ? baselineValue : currentValue); + } + + if (Array.isArray(currentValue) && Array.isArray(baselineValue)) { + var arrayDiffCount = 0; + var maxLength = Math.max(currentValue.length, baselineValue.length); + + for (var i = 0; i < maxLength; i++) { + arrayDiffCount += countChangedSettingsValues(currentValue[i], baselineValue[i]); + } + + return arrayDiffCount; + } + + if (isPlainObject(currentValue) && isPlainObject(baselineValue)) { + var objectDiffCount = 0; + var seenKeys = {}; + var currentKeys = Object.keys(currentValue); + var baselineKeys = Object.keys(baselineValue); + var k; + + for (k = 0; k < currentKeys.length; k++) { + seenKeys[currentKeys[k]] = true; + } + for (k = 0; k < baselineKeys.length; k++) { + seenKeys[baselineKeys[k]] = true; + } + + var keys = Object.keys(seenKeys); + for (k = 0; k < keys.length; k++) { + var key = keys[k]; + objectDiffCount += countChangedSettingsValues(currentValue[key], baselineValue[key]); + } + + return objectDiffCount; + } + + return 1; + } + + function countChangedSettings(snapshotCurrent, snapshotBaseline) { + if (snapshotCurrent === snapshotBaseline) { + return 0; + } + + var currentData = parseSnapshot(snapshotCurrent); + var baselineData = parseSnapshot(snapshotBaseline); + + if (currentData === null || baselineData === null) { + return snapshotCurrent === snapshotBaseline ? 0 : 1; + } + + return countChangedSettingsValues(currentData, baselineData); + } + + function computeChangedKeys(snapshotCurrent, snapshotBaseline) { + var currentData = parseSnapshot(snapshotCurrent); + var baselineData = parseSnapshot(snapshotBaseline); + + if (currentData === null || baselineData === null || !isPlainObject(currentData) || !isPlainObject(baselineData)) { + return []; + } + + var keys = {}; + Object.keys(currentData).forEach(function(key) { + keys[key] = true; + }); + Object.keys(baselineData).forEach(function(key) { + keys[key] = true; + }); + + return Object.keys(keys).filter(function(key) { + return countChangedSettingsValues(currentData[key], baselineData[key]) > 0; + }); + } + + function saveImageUpdateCheckSettings() { + var deferred = $.Deferred(); + var $settingsForm = $('form[name="compose_manager_settings"]'); + var filePath = $settingsForm.find('input[name="#file"]').val(); + var section = $settingsForm.find('input[name="#section"]').val() || ''; + + $.post('/update.php', { + '#file': filePath, + '#section': section, + '#apply': 'Apply', + AUTO_CHECK_UPDATES: $('#AUTO_CHECK_UPDATES').is(':checked') ? 'true' : 'false', + AUTO_CHECK_UPDATES_DAYS: $('#AUTO_CHECK_UPDATES_DAYS').val() || '1' + }).done(function() { + if (window.composeSettingsUnifiedState && typeof window.composeSettingsUnifiedState.markImageCheckSaved === 'function') { + window.composeSettingsUnifiedState.markImageCheckSaved(); + } + deferred.resolve(true); + }).fail(function() { + showUpdatesStatus('Failed to save image update check settings.', 'error'); + deferred.resolve(false); + }); + + return deferred.promise(); + } + + function updateUnifiedActionState() { + settingsUnifiedState.activeTab = getCurrentSettingsTabFromDom(); + var settingsSnapshot = captureSettingsSnapshot(); + var backupSnapshot = captureBackupSnapshot(); + var updatesSnapshot = captureUpdatesSnapshot(); + var imageCheckSnapshot = captureImageCheckSnapshot(); + var updatesBaselineReady = settingsUnifiedState.baseline.updates !== ''; + + settingsUnifiedState.dirty.settings = settingsSnapshot !== settingsUnifiedState.baseline.settings; + settingsUnifiedState.dirty.backup = backupSnapshot !== settingsUnifiedState.baseline.backup; + settingsUnifiedState.dirty.updates = settingsUnifiedState.updatesInitialized && updatesBaselineReady && updatesSnapshot !== settingsUnifiedState.baseline.updates; + settingsUnifiedState.dirty.imageCheck = imageCheckSnapshot !== settingsUnifiedState.baseline.imageCheck; + + var updatesDirty = settingsUnifiedState.dirty.updates || settingsUnifiedState.dirty.imageCheck; + + var changedTabs = 0; + if (settingsUnifiedState.dirty.settings) changedTabs++; + if (updatesDirty) changedTabs++; + if (settingsUnifiedState.dirty.backup) changedTabs++; + + var changedSettings = 0; + if (settingsUnifiedState.dirty.settings) { + changedSettings += countChangedSettings(settingsSnapshot, settingsUnifiedState.baseline.settings); + } + if (settingsUnifiedState.dirty.backup) { + changedSettings += countChangedSettings(backupSnapshot, settingsUnifiedState.baseline.backup); + } + if (settingsUnifiedState.dirty.updates) { + changedSettings += countChangedSettings(updatesSnapshot, settingsUnifiedState.baseline.updates); + } + if (settingsUnifiedState.dirty.imageCheck) { + changedSettings += countChangedSettings(imageCheckSnapshot, settingsUnifiedState.baseline.imageCheck); + } + + var hasChanges = changedTabs > 0; + $('#global-settings-apply').prop('disabled', !hasChanges); + $('#global-settings-reset').prop('disabled', !hasChanges); + $('#global-settings-default').prop('disabled', settingsUnifiedState.activeTab !== 'settings'); + var changedTabsText = changedTabs + (changedTabs === 1 ? ' tab changed' : ' tabs changed'); + var changedSettingsText = changedSettings + (changedSettings === 1 ? ' setting changed' : ' settings changed'); + $('#global-settings-change-count').text(changedTabsText + ' | ' + changedSettingsText); + + var debugSignature = [ + settingsUnifiedState.activeTab, + changedTabs, + changedSettings, + settingsUnifiedState.dirty.settings ? '1' : '0', + settingsUnifiedState.dirty.updates ? '1' : '0', + settingsUnifiedState.dirty.imageCheck ? '1' : '0', + settingsUnifiedState.dirty.backup ? '1' : '0' + ].join('|'); + if (settingsUnifiedState.debugReady && settingsUnifiedState.lastDebugSignature !== debugSignature) { + settingsUnifiedState.lastDebugSignature = debugSignature; + composeLogger('Dirty state recalculated', { + activeTab: settingsUnifiedState.activeTab, + changedTabs: changedTabs, + changedSettings: changedSettings, + dirty: { + settings: settingsUnifiedState.dirty.settings, + updates: settingsUnifiedState.dirty.updates, + imageCheck: settingsUnifiedState.dirty.imageCheck, + backup: settingsUnifiedState.dirty.backup + }, + changedKeys: { + settings: computeChangedKeys(settingsSnapshot, settingsUnifiedState.baseline.settings), + updates: settingsUnifiedState.updatesInitialized ? computeChangedKeys(updatesSnapshot, settingsUnifiedState.baseline.updates) : [], + imageCheck: computeChangedKeys(imageCheckSnapshot, settingsUnifiedState.baseline.imageCheck), + backup: computeChangedKeys(backupSnapshot, settingsUnifiedState.baseline.backup) + } + }, 'user', 'debug', 'settings'); + } + + if (settingsUnifiedState.activeTab === 'credits' || settingsUnifiedState.activeTab === 'log') { + $('#global-settings-actions').addClass('hidden'); + } else { + $('#global-settings-actions').removeClass('hidden'); + } + } + + function setActiveSettingsTab(tabId) { + settingsUnifiedState.activeTab = tabId; + updateUnifiedActionState(); + } + + function applySettingsDefault() { + var $defaultBtn = $('#settings-submit-default'); + if (!$defaultBtn.length) return; + $defaultBtn.prop('disabled', false).trigger('click'); + window.setTimeout(function() { + settingsUnifiedState.baseline.settings = captureSettingsSnapshot(); + updateUnifiedActionState(); + }, 500); + } + + function applySettingsChanges() { + var $applyBtn = $('#settings-submit-apply'); + if (!$applyBtn.length) return; + $applyBtn.prop('disabled', false).trigger('click'); + window.setTimeout(function() { + settingsUnifiedState.baseline.settings = captureSettingsSnapshot(); + updateUnifiedActionState(); + }, 500); + } + + function resetSettingsFromBaseline() { + var snapshot = settingsUnifiedState.baseline.settings; + if (!snapshot) return; + var data; + try { + data = JSON.parse(snapshot); + } catch (e) { + return; + } + + $('#compose-tab-settings').find('input, select, textarea').each(function() { + var $el = $(this); + var type = ($el.attr('type') || '').toLowerCase(); + if (type === 'hidden' || type === 'submit' || type === 'button') return; + var key = $el.attr('id') || $el.attr('name'); + if (!key) return; + if (!Object.prototype.hasOwnProperty.call(data, key)) return; + + if (type === 'checkbox' || type === 'radio') { + $el.prop('checked', data[key] === 'true').trigger('change'); + } else { + $el.val(data[key]).trigger('change'); + } + }); + } + + function clearDependentToggleSavedStateCache() { + $('#SHOW_COMPOSE_ON_TOP').removeData('saved-state'); + $('#HIDE_COMPOSE_FROM_DOCKER').removeData('saved-state'); + $('#HIDE_DOCKER_COMPOSE_CONTAINERS').removeData('saved-state'); + $('#ONLY_EXPAND_RUNNING_STACKS').removeData('saved-state'); + } + + function resetBackupFromBaseline() { + var snapshot = settingsUnifiedState.baseline.backup; + if (!snapshot) return; + var data; + try { + data = JSON.parse(snapshot); + } catch (e) { + return; + } + + $('#BACKUP_DESTINATION').val(data.BACKUP_DESTINATION || '').trigger('change'); + $('#BACKUP_RETENTION').val(data.BACKUP_RETENTION || '').trigger('change'); + $('#BACKUP_SCHEDULE_ENABLED').prop('checked', data.BACKUP_SCHEDULE_ENABLED === 'true').trigger('change'); + $('#BACKUP_SCHEDULE_FREQUENCY').val(data.BACKUP_SCHEDULE_FREQUENCY || 'daily').trigger('change'); + $('#BACKUP_SCHEDULE_DAY').val(data.BACKUP_SCHEDULE_DAY || '1').trigger('change'); + $('#BACKUP_SCHEDULE_TIME').val(data.BACKUP_SCHEDULE_TIME || '03:00').trigger('change'); + toggleBackupSchedule(); + } + + function resetImageCheckFromBaseline() { + var snapshot = settingsUnifiedState.baseline.imageCheck; + if (!snapshot) return; + + var data; + try { + data = JSON.parse(snapshot); + } catch (e) { + return; + } + + $('#AUTO_CHECK_UPDATES').prop('checked', data.AUTO_CHECK_UPDATES === 'true').trigger('change'); + setAutoCheckIntervalUIFromDays(data.AUTO_CHECK_UPDATES_DAYS || '1'); + $('#AUTO_CHECK_UPDATES_DAYS').trigger('change'); + toggleAutoCheckInterval(); + } + + $('#global-settings-default').on('click', function() { + if (getCurrentSettingsTabFromDom() !== 'settings') return; + applySettingsDefault(); + }); + + $('#global-settings-apply').on('click', function() { + if (settingsUnifiedState.dirty.settings) { + applySettingsChanges(); + } + if (settingsUnifiedState.dirty.updates) { + $('#save-updates').trigger('click'); + } + if (settingsUnifiedState.dirty.imageCheck) { + saveImageUpdateCheckSettings(); + } + if (settingsUnifiedState.dirty.backup) { + saveBackupSettings(); + } + }); + + $('#global-settings-reset').on('click', function() { + clearDependentToggleSavedStateCache(); + resetSettingsFromBaseline(); + resetBackupFromBaseline(); + resetImageCheckFromBaseline(); + + if (settingsUnifiedState.updatesInitialized) { + var pendingResetOps = 2; + var finalizeResetState = function() { + pendingResetOps--; + if (pendingResetOps > 0) return; + initSwitchToggles($('#compose-tab-settings, #compose-tab-updates, #compose-tab-backup')); + updateUnifiedActionState(); + }; + + loadUpdatesUI({ + updateBaseline: false, + onComplete: finalizeResetState + }); + refreshCronStatus(finalizeResetState); + return; + } + + window.setTimeout(function() { + initSwitchToggles($('#compose-tab-settings, #compose-tab-updates, #compose-tab-backup')); + updateUnifiedActionState(); + }, 200); + }); + + $('#compose-tab-settings').on('input change', 'input, select, textarea', function() { + updateUnifiedActionState(); + }); + + $('#compose-tab-backup').on('input change', '#BACKUP_DESTINATION, #BACKUP_RETENTION, #BACKUP_SCHEDULE_ENABLED, #BACKUP_SCHEDULE_FREQUENCY, #BACKUP_SCHEDULE_DAY, #BACKUP_SCHEDULE_TIME', function() { + updateUnifiedActionState(); + }); + + $('#compose-tab-updates').on('input change', '#AUTOUPDATE_ENABLED, #default-schedule, #default-time, #updates-tbody input, #updates-tbody select', function() { + settingsUnifiedState.updatesInitialized = true; + updateUnifiedActionState(); + }); + + $('#compose-tab-updates').on('input change', '#AUTO_CHECK_UPDATES, #AUTO_CHECK_UPDATES_MODE, #AUTO_CHECK_UPDATES_DAYS, #AUTO_CHECK_UPDATES_DAYS_CUSTOM', function() { + updateUnifiedActionState(); + }); + + window.composeSettingsUnifiedState = { + markUpdatesSaved: function() { + settingsUnifiedState.updatesInitialized = true; + settingsUnifiedState.baseline.updates = captureUpdatesSnapshot(); + updateUnifiedActionState(); + }, + markImageCheckSaved: function() { + settingsUnifiedState.baseline.imageCheck = captureImageCheckSnapshot(); + updateUnifiedActionState(); + }, + markBackupSaved: function() { + settingsUnifiedState.baseline.backup = captureBackupSnapshot(); + updateUnifiedActionState(); + }, + markUpdatesLoaded: function() { + settingsUnifiedState.updatesInitialized = true; + settingsUnifiedState.baseline.updates = captureUpdatesSnapshot(); + settingsUnifiedState.baseline.imageCheck = captureImageCheckSnapshot(); + updateUnifiedActionState(); + }, + refreshDirtyState: function() { + settingsUnifiedState.updatesInitialized = true; + updateUnifiedActionState(); + } + }; + // Tab switching $('.compose-tab').on('click', function() { var tabId = $(this).data('tab'); @@ -953,6 +1603,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' $(this).addClass('active'); // Save tab preference localStorage.setItem('compose-settings-tab', tabId); + setActiveSettingsTab(tabId); // Start/stop log refresh based on tab if (tabId === 'log') { loadLogs(); @@ -966,8 +1617,22 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' } // Always load updates UI when switching to updates tab if (tabId === 'updates') { - loadUpdatesUI(); - refreshCronStatus(); + if (!settingsUnifiedState.dirty.updates) { + var pendingUpdatesInitOps = 2; + var finalizeUpdatesInit = function() { + pendingUpdatesInitOps--; + if (pendingUpdatesInitOps > 0) return; + if (window.composeSettingsUnifiedState && typeof window.composeSettingsUnifiedState.markUpdatesLoaded === 'function') { + window.composeSettingsUnifiedState.markUpdatesLoaded(); + } + }; + + loadUpdatesUI({ + updateBaseline: false, + onComplete: finalizeUpdatesInit + }); + refreshCronStatus(finalizeUpdatesInit); + } } }); @@ -978,9 +1643,40 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' $('#DISABLE_ACTION_WARNINGS, #RUN_IN_BACKGROUND_DEFAULT').change(updateAggressiveModeWarning); updateAggressiveModeWarning(); - // Apply hide containers on load and on change - toggleHideContainers(); - $('#SHOW_DASHBOARD_TILE').change(toggleHideContainers); + // Apply dashboard hide from docker on load and on change + toggleHideFromDashboard(); + $('#SHOW_DASHBOARD_TILE').change(toggleHideFromDashboard); + + // Handle dashboard tile toggle -> disable/enable hide containers option + function toggleExpandByDefault() { + var stacksDefaultExpanded = $('#STACKS_DEFAULT_EXPANDED').is(':checked'); + var $wrapper = $('#ONLY_EXPAND_RUNNING_STACKS').closest('dl'); + if (!stacksDefaultExpanded) { + var $expand = $('#ONLY_EXPAND_RUNNING_STACKS'); + if (typeof $expand.data('saved-state') === 'undefined') { + $expand.data('saved-state', $expand.is(':checked')); + } + if ($expand.is(':checked')) { + $expand.prop('checked', false).change(); + } + $wrapper.css({ + opacity: 0.4, + pointerEvents: 'none' + }); + } else { + $wrapper.css({ + opacity: 1, + pointerEvents: '' + }); + // Restore previously saved state + var $expand = $('#ONLY_EXPAND_RUNNING_STACKS'); + var saved = $expand.data('saved-state'); + if (typeof saved !== 'undefined') { + $expand.prop('checked', saved).change(); + $expand.removeData('saved-state'); + } + } + } // Apply expand stacks by default on load and on change toggleExpandByDefault(); @@ -995,8 +1691,11 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' $('#SHOW_COMPOSE_IN_HEADER_MENU').change(toggleComposeOnTop); // Handle auto-check toggle state change - toggleAutoCheckInterval(); + setAutoCheckIntervalUIFromDays($('#AUTO_CHECK_UPDATES_DAYS').val() || '1'); $('#AUTO_CHECK_UPDATES').change(toggleAutoCheckInterval); + $('#AUTO_CHECK_UPDATES_MODE').on('change', onAutoCheckModeChanged); + $('#AUTO_CHECK_UPDATES_DAYS_CUSTOM').on('input', onAutoCheckCustomDaysInput); + $('#AUTO_CHECK_UPDATES_DAYS_CUSTOM').on('change', onAutoCheckCustomDaysChanged); // allow patch UI change handler to run now that switches are initialized _ignorePatchUIChange = false; @@ -1006,6 +1705,13 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' // Set only the correct tab and content as active $('.compose-tab[data-tab="' + savedTab + '"]').addClass('active'); $('#compose-tab-' + savedTab).addClass('active'); + settingsUnifiedState.activeTab = savedTab; + + settingsUnifiedState.baseline.settings = captureSettingsSnapshot(); + settingsUnifiedState.baseline.backup = captureBackupSnapshot(); + settingsUnifiedState.baseline.imageCheck = captureImageCheckSnapshot(); + settingsUnifiedState.debugReady = true; + updateUnifiedActionState(); // If the default tab is backup, load the backup list immediately if (savedTab === 'backup') { @@ -1020,8 +1726,20 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' // If the default tab is updates, load updates UI immediately if (savedTab === 'updates') { - loadUpdatesUI(); - refreshCronStatus(); + var pendingInitialUpdatesOps = 2; + var finalizeInitialUpdates = function() { + pendingInitialUpdatesOps--; + if (pendingInitialUpdatesOps > 0) return; + if (window.composeSettingsUnifiedState && typeof window.composeSettingsUnifiedState.markUpdatesLoaded === 'function') { + window.composeSettingsUnifiedState.markUpdatesLoaded(); + } + }; + + loadUpdatesUI({ + updateBaseline: false, + onComplete: finalizeInitialUpdates + }); + refreshCronStatus(finalizeInitialUpdates); } }); @@ -1438,6 +2156,9 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' if (result.result === 'success') { showBackupStatus('#backup-create-status', 'Backup settings saved.', 'success'); + if (window.composeSettingsUnifiedState && typeof window.composeSettingsUnifiedState.markBackupSaved === 'function') { + window.composeSettingsUnifiedState.markBackupSaved(); + } } else { showBackupStatus('#backup-create-status', result.message || 'Failed to save settings', 'error'); } @@ -1596,7 +2317,11 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' initUpdatesSortable(); } - function loadUpdatesUI() { + function loadUpdatesUI(options) { + options = options || {}; + var updateBaseline = options.updateBaseline !== false; + var onComplete = typeof options.onComplete === 'function' ? options.onComplete : null; + $('#updates-tbody').html('_(Loading stacks...)_'); $.post('/plugins/compose.manager/include/Exec.php', { action: 'listProjects' @@ -1654,15 +2379,29 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' initUpdatesSortable(); // Sync toggle-all state updateToggleAllState(); + if (updateBaseline && window.composeSettingsUnifiedState && typeof window.composeSettingsUnifiedState.markUpdatesLoaded === 'function') { + window.composeSettingsUnifiedState.markUpdatesLoaded(); + } else if (!updateBaseline && window.composeSettingsUnifiedState && typeof window.composeSettingsUnifiedState.refreshDirtyState === 'function') { + window.composeSettingsUnifiedState.refreshDirtyState(); + } + if (onComplete) { + onComplete(); + } }, 'json').fail(function() { $('#updates-tbody').html('_(Failed to load auto-update settings.)_'); + if (onComplete) { + onComplete(); + } }); }, 'json').fail(function() { $('#updates-tbody').html('_(Failed to load stacks.)_'); + if (onComplete) { + onComplete(); + } }); } - function refreshCronStatus() { + function refreshCronStatus(onComplete) { $.post('/plugins/compose.manager/include/AutoUpdate.php', { action: 'getCronStatus' }, function(r) { @@ -1670,6 +2409,11 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' var installed = r && r.installed; var $cb = $('#AUTOUPDATE_ENABLED'); var currentState = $cb.is(':checked'); + composeLogger('refreshCronStatus result', { + installed: !!installed, + currentState: currentState, + changed: currentState !== installed + }, 'user', 'debug', 'settings'); // Only update if state changed to avoid flicker if (currentState !== installed) { // Click the switch-button-background to toggle visual state @@ -1681,6 +2425,9 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' $cb.prop('checked', installed); } } + if (typeof onComplete === 'function') { + onComplete(); + } }, 'json').fail(function() { // On failure, leave checkbox unchecked var $cb = $('#AUTOUPDATE_ENABLED'); @@ -1692,6 +2439,9 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' $cb.prop('checked', false); } } + if (typeof onComplete === 'function') { + onComplete(); + } }); } @@ -1814,6 +2564,9 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' setUpdatesSpinner(false); refreshCronStatus(); showUpdatesStatus('Auto-update settings saved.', 'success'); + if (window.composeSettingsUnifiedState && typeof window.composeSettingsUnifiedState.markUpdatesSaved === 'function') { + window.composeSettingsUnifiedState.markUpdatesSaved(); + } }, 'json').fail(function() { setUpdatesSpinner(false); showUpdatesStatus('Settings saved but scheduler update failed.', 'error'); @@ -1857,7 +2610,9 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' completed++; runNext(); }, 'json').fail(function() { - console.log('runNow failed for path', p); + if (typeof composeLogger === 'function') { + composeLogger('runNow failed for path', { path: p }, 'user', 'warn', 'settings'); + } runNext(); }); } @@ -1883,7 +2638,12 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' +
+ Settings Tab — Configure general plugin behavior including where stacks are stored, how they start at boot, and how they appear in the webUI. Most settings take effect immediately. +
+
_(General Settings)_
+

Core plugin configuration and project storage location.

_(Compose Project Directory)_:
@@ -1900,16 +2660,8 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
-
-
_(Rich Terminal Output)_:
-
- > -
- Display compose commands in a rich terminal with color output and live progress.
- When disabled, uses simple text output. -
-
-
+
_(Stack Actions & Behavior)_
+

Control how stack operations execute and display results.

@@ -1941,30 +2693,33 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
-
_(New Stack Defaults)_
+
_(New Stack Configuration)_
+

Default settings applied automatically when creating new compose stacks.

-
_(Default Compose File Selection)_:
+
_(Compose File Discovery)_:
- > + >
- Default for new stacks: use Docker Compose default file discovery (no explicit -f flags).
- Useful for projects that rely on auto-loaded compose.override.* and/or COMPOSE_FILE from .env. + Auto: Use Docker Compose default file discovery (no explicit -f flags). Useful for projects that rely on auto-loaded compose.override.* and/or COMPOSE_FILE from .env.
+ Manual: Explicitly specify which compose files to use.
-
_(Default Override File Management)_:
+
_(Override File Management)_:
- > + >
- Default for new stacks: Automatic when enabled, Manual when disabled.
- Automatic lets the plugin manage the project override file; Manual blocks automated edits and uses raw override editing. + Auto: Plugin automatically manages the project override file for automated edits.
+ Manual: Blocks automated edits and uses raw override editing mode.
+
_(Boot Autostart)_
+

Control how Compose stacks automatically start when Unraid boots up.

_(Recreate During Autostart)_:
@@ -1990,7 +2745,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
-
_(Docker Wait Timeout)_:
+
_(Docker Autostart Wait Timeout)_:
_(seconds)_
@@ -2011,37 +2766,39 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
-
_(Display Options)_
+
_(WebUI Display)_
+

Configure where and how Compose stacks appear throughout the Unraid webUI.

_(Show Compose in Header Menu)_:
>
- Add a Compose tab to the main Unraid header navigation bar for quick access. + Add a Compose tab to the main Unraid header navigation bar for quick access.
+ When disabled: Compose stacks appear on the Docker page (see options below).
-
-
_(Show Compose Stacks Above Docker)_:
+
+
_(Show Compose Stacks Above Docker Containers)_:
>
When the Docker page is displayed without tabs, move the Compose Stacks section above the built-in Docker Containers section.
- This option is only available when "Show Compose in Header Menu" is set to No (non-tabbed mode). + Only available when "Show Compose in Header Menu" is disabled (inline mode).
-
-
_(Hide Compose Containers from Docker)_:
+
+
_(Hide Compose Containers from Docker Page)_:
>
- When enabled, containers managed by Compose stacks will be hidden from the Docker Containers table on the Docker tab.
- This avoids duplicate display when Compose stacks are shown on the same page.
- This option is only available when "Show Compose in Header Menu" is set to No (non-tabbed mode). + When enabled, containers managed by Compose stacks will be hidden from the Docker Containers table on the Docker page.
+ This avoids duplicate display when Compose stacks are shown on the same page (inline mode).
+ Only available when "Show Compose in Header Menu" is disabled (inline mode).
@@ -2056,18 +2813,21 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
-
-
_(Hide Compose Containers from Docker Tile on Dashboard)_:
+
+
_(Hide Compose Containers from Docker Dashboard Tile)_:
>
Hide containers managed by Compose stacks from Unraid's Docker Containers dashboard tile.
This avoids duplicate entries when both the Compose Stacks tile and Docker Containers tile are visible on the Dashboard.
- This option requires "Show Dashboard Tile" to be enabled. + Only available when "Show Dashboard Tile" is enabled.
+
_(Compose Page Display)_
+

Customize how stack details are displayed on the Compose page.

+
_(Expand Stacks by Default)_:
@@ -2083,52 +2843,22 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
>
- When enabled, only running stacks will be expanded by default. -
-
-
- -
_(Update Checking)_
- -
-
_(Auto Check for Updates)_:
-
- > -
- Automatically check for container image updates when the Compose page loads. -
-
-
- -
-
_(Auto Check Interval (days))_:
-
- > -
- How often to check for updates.
- Examples: 0.04 (hourly), 1 (daily), 7 (weekly). -
-
-
- -
-
_(Clear Update Cache)_:
-
- -
- Clear cached update status. Use if update checks show incorrect results. + When enabled, only running stacks will be expanded by default. Stopped/paused stacks remain collapsed.
+ Only applies when "Expand Stacks by Default" is enabled.
-
_(Advanced)_
+
_(Advanced Options)_
+

Performance tuning and troubleshooting options for advanced users.

_(Debug Logging)_:
>
- Log detailed compose command information to syslog. View logs in the Log tab. + Log detailed compose command information to syslog. View logs in the Log tab.
+ Useful for troubleshooting and understanding what commands are being executed.
@@ -2150,8 +2880,12 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' _(ms)_
- Controls how often Compose live metrics are published.
- Live publishes each stream update, Medium uses ~2 seconds, Slow uses ~5 seconds, and Custom uses the interval in milliseconds. + Controls how often Compose live metrics (CPU, memory, etc.) are updated on the UI.
+ Metrics are only collected for compose-managed containers, and only one update is + published per interval regardless of container count.
+ Live: ~250ms updates (most responsive, still rate-limited).
+ Medium/Slow: Batches updates (~2s or ~5s intervals, lower CPU).
+ Custom: Use the interval in milliseconds.
@@ -2170,115 +2904,25 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
-
-
+
+
+ + +
- -
+
+ Updates Tab — Manage automatic stack updates and container image checking. Define global auto-update schedules and per-stack update policies. Changes save independently from Settings. +
-
_(Auto-Update Settings)_
+
_(Global Auto-Update Settings)_
+

Enable scheduled updates and set default schedules for all stacks.

_(Enable Auto-Updates)_:
@@ -2318,7 +2962,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
 
- +
@@ -2327,9 +2971,58 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
+ +
+
_(Check for Image Updates)_
+

Configure how often to scan for available container image updates.

+ +
+
_(Auto Check for Updates)_:
+
+ > +
+ Automatically check for container image updates when the Compose page loads. +
+
+
+ +
+
_(Check Interval)_:
+
+ + + +
+ How often to check for updates. Only used if "Auto Check for Updates" is enabled.
+ Use presets for common intervals, or choose Custom to enter days manually. +
+
+
+ +
+
_(Clear Update Cache)_:
+
+ +
+ Clear cached update status. Use if update checks show incorrect results. +
+
+
+
+
-
_(Stack Configuration)_
+
_(Per-Stack Auto-Update Settings)_
+

Enable or disable auto-updates for individual stacks and set their specific schedules.

_(Stacks)_:
@@ -2357,10 +3050,14 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
+
+ Backup Tab — Create, schedule, and restore backups of your Compose stack configurations and override files. Backups are independent from docker container data and volumes. +
-
_(Backup Settings)_
+
_(Backup Configuration)_
+

Configure backup storage location, retention policy, and create scheduled backups.

_(Backup Destination)_:
@@ -2430,7 +3127,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
 
- +
@@ -2496,8 +3193,21 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
+
+
+ + + + 0 tabs changed | 0 settings changed +
+
+
+
+ Log Tab — View system log entries generated by Compose Manager. Enable "Debug Logging" in the Settings tab to capture detailed command information. Logs help troubleshoot issues and verify operations. +
+