diff --git a/.github/workflows/sync-plugin-url.yml b/.github/workflows/sync-plugin-url.yml index 2b6075a3..d2b38d9b 100644 --- a/.github/workflows/sync-plugin-url.yml +++ b/.github/workflows/sync-plugin-url.yml @@ -4,15 +4,17 @@ # This prevents issues when merging between dev and main where the # pluginURL would reference the wrong branch for update checks. # -# Triggers only on PR merges to main or dev (the only time the -# pluginURL branch reference can be wrong is after a cross-branch merge) +# Triggers on push and merged PRs to main/dev to self-heal branch links. name: Sync Plugin URL on: + push: + branches: [main, dev] pull_request: types: [closed] branches: [main, dev] + workflow_dispatch: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' @@ -26,32 +28,66 @@ jobs: permissions: contents: write runs-on: ubuntu-latest - # Only run if the PR was actually merged (not just closed) - if: github.event.pull_request.merged == true + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) steps: + - name: Resolve target branch + id: target + run: | + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + TARGET_BRANCH="${GITHUB_REF_NAME}" + elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" + else + TARGET_BRANCH="${GITHUB_REF_NAME}" + fi + + case "$TARGET_BRANCH" in + main|dev) + echo "target_branch=$TARGET_BRANCH" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Unsupported branch '$TARGET_BRANCH'; skipping." + echo "target_branch=" >> "$GITHUB_OUTPUT" + ;; + esac + - name: Checkout repository + if: steps.target.outputs.target_branch != '' uses: actions/checkout@v6 with: + ref: ${{ steps.target.outputs.target_branch }} token: ${{ secrets.GITHUB_TOKEN }} - name: Check and fix pluginURL branch + README badge + if: steps.target.outputs.target_branch != '' id: check run: | - BRANCH="${GITHUB_REF_NAME}" + BRANCH="${{ steps.target.outputs.target_branch }}" PLG_FILE="compose.manager.plg" README_FILE="source/compose.manager/README.md" NEEDS_COMMIT=false # Ensure pluginURL uses the current branch - CURRENT_URL_BRANCH=$(grep -oP '$#\1#p' "$PLG_FILE") + if [[ -z "$CURRENT_URL_BRANCH" ]]; then + echo "Failed to parse pluginURL entity in $PLG_FILE" + exit 1 + fi + echo "Current branch: $BRANCH" echo "PLG pluginURL branch: $CURRENT_URL_BRANCH" if [[ "$CURRENT_URL_BRANCH" != "$BRANCH" ]]; then echo "Branch mismatch - fixing pluginURL..." - # Scope to pluginURL line only — avoids corrupting packageURL - sed -i "/pluginURL/s|/&github;/[^/]*/|/\&github;/${BRANCH}/|" "$PLG_FILE" + sed -i -E "s#^##" "$PLG_FILE" + + EXPECTED_LINE="" + if ! grep -Fxq "$EXPECTED_LINE" "$PLG_FILE"; then + echo "pluginURL rewrite verification failed" + exit 1 + fi + NEEDS_COMMIT=true else echo "pluginURL already correct" @@ -81,12 +117,12 @@ jobs: echo "needs_commit=$NEEDS_COMMIT" >> $GITHUB_OUTPUT - name: Commit fix - if: steps.check.outputs.needs_commit == 'true' + if: steps.target.outputs.target_branch != '' && steps.check.outputs.needs_commit == 'true' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add compose.manager.plg source/compose.manager/README.md - git commit -m "chore: sync pluginURL+README for ${GITHUB_REF_NAME} branch [skip ci]" - git pull --rebase origin "${GITHUB_REF_NAME}" + git commit -m "chore: sync pluginURL+README for ${{ steps.target.outputs.target_branch }} branch [skip ci]" + git pull --rebase origin "${{ steps.target.outputs.target_branch }}" git push diff --git a/compose.manager.plg b/compose.manager.plg index a5bfefc3..ab9ed38e 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,15 +2,15 @@ - + - - + + - + @@ -36,9 +36,18 @@ > -###2026.06.06 -- Bug Fixes: update container cache loading to use PHP endpoint and improve data parsing; [Bug]: [error] 12159#12159: *38373 open() "/usr/local/emhttp/plugins/compose.manager/containers.cache.json" failed (2: No such file or directory) while sending to client Fixes mstrhakr/compose_plugin#117 -- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.05.31...v2026.06.06) +###2026.06.18.1637 +- 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 +- Bug Fixes (composeStats): enhance memory value parsing and sanitize docker stats fields +- 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 +- [PR #120](https://github.com/mstrhakr/compose_plugin/pull/120) +- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.06.06...v2026.06.18.1637) diff --git a/deploy.sh b/deploy.sh old mode 100644 new mode 100755 diff --git a/docs/user-guide.md b/docs/user-guide.md index 7c67cd5e..a670c1b8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -41,6 +41,16 @@ Each stack supports the following actions: | **Edit Stack** | Open the stack editor | | **Remove Stack** | Delete the stack configuration | +### Checking for Updates + +Use **Check Updates** on a stack to query the registry for newer image versions. Results are cached until the next manual or scheduled check (see [Update Checking settings](configuration.md#update-checking)). + +When updates are available, clicking **Update Stack** opens a confirmation dialog listing each container alongside its current and incoming image digest. Containers that are already up to date are dimmed. + +#### Changelogs + +If the [docker.versions](https://github.com/phyzical/docker.versions) Unraid plugin is installed, a **Changelog** link appears below the digest for each container with a pending update. Clicking it opens the release notes for that image in a modal. No configuration is required — Compose Manager detects docker.versions automatically. + ## Autostart Enable autostart to have stacks start automatically when the Unraid array starts. diff --git a/source/compose.manager/Compose.page b/source/compose.manager/Compose.page index a37346f8..668a7c66 100644 --- a/source/compose.manager/Compose.page +++ b/source/compose.manager/Compose.page @@ -4,7 +4,7 @@ Title="Docker Compose" Tag="fa-cubes" Code="f1b3" Lock="true" -Nchan="docker_load" +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'\")" --- 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 63f3ae14..f3b0c80f 100644 --- a/source/compose.manager/compose.manager.page +++ b/source/compose.manager/compose.manager.page @@ -3,7 +3,7 @@ Title="Compose" Type="php" Menu="Docker:2" Lock="true" -Nchan="docker_load" +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'\"))" --- +
+
_(Live Stats Update Rate)_:
+
+ + +
+ 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. +
+
+
+
_(Patch Docker Page)_:
diff --git a/source/compose.manager/default.cfg b/source/compose.manager/default.cfg index 3ad39f8c..20b3810b 100755 --- a/source/compose.manager/default.cfg +++ b/source/compose.manager/default.cfg @@ -14,6 +14,7 @@ AUTOSTART_TIMEOUT="300" AUTOSTART_WAIT_FOR_DOCKER="false" AUTOSTART_DOCKER_WAIT_TIMEOUT="120" RUN_IN_BACKGROUND_DEFAULT="false" +REMOVE_ORPHANS_DEFAULT="false" DISABLE_ACTION_WARNINGS="false" NEW_STACK_USE_DEFAULT_COMPOSE_FILES="false" NEW_STACK_OVERRIDE_MANAGEMENT_AUTOMATIC="true" @@ -26,3 +27,5 @@ BACKUP_SCHEDULE_TIME="03:00" BACKUP_SCHEDULE_DAY="1" STACKS_DEFAULT_EXPANDED="false" ONLY_EXPAND_RUNNING_STACKS="false" +COMPOSE_STATS_RATE_MODE="live" +COMPOSE_STATS_CUSTOM_INTERVAL_MS="1000" diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index 2f7c3503..2d372f7b 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -10,10 +10,37 @@ $cfg = parse_plugin_cfg($sName); +$mode = isset($_GET['mode']) ? trim((string)$_GET['mode']) : 'html'; +if ($mode === 'list') { + $projects = StackInfo::listProjectFolders($compose_root); + echo json_encode([ + 'result' => 'success', + 'projects' => array_values($projects), + ]); + exit; +} + $o = ""; $stackCount = 0; -foreach (StackInfo::allFromRoot($compose_root) as $stackInfo) { +$stackInfos = []; +if ($mode === 'row') { + $project = isset($_GET['project']) ? basename(trim((string)$_GET['project'])) : ''; + if ($project === '') { + echo json_encode(['result' => 'error', 'message' => 'Project not specified.']); + exit; + } + try { + $stackInfos = [StackInfo::fromProject($compose_root, $project)]; + } catch (\Throwable $e) { + echo json_encode(['result' => 'error', 'message' => 'Project not found.']); + exit; + } +} else { + $stackInfos = StackInfo::allFromRoot($compose_root); +} + +foreach ($stackInfos as $stackInfo) { $stackCount++; $projectName = $stackInfo->getName(); @@ -74,6 +101,16 @@ $profiles = $stackInfo->getProfiles(); $profilesJson = htmlspecialchars(json_encode($profiles ?: []), ENT_QUOTES, 'UTF-8'); + // Get default profiles for actions like force update + $defaultProfiles = $stackInfo->getDefaultProfiles(); + $defaultProfilesStr = implode(',', $defaultProfiles); + $defaultProfilesHtml = htmlspecialchars($defaultProfilesStr, ENT_QUOTES, 'UTF-8'); + + // Get running profiles so UI can prioritize current runtime selection + $runningProfiles = $stackInfo->getRunningProfiles(); + $runningProfilesStr = implode(',', $runningProfiles); + $runningProfilesHtml = htmlspecialchars($runningProfilesStr, ENT_QUOTES, 'UTF-8'); + // Determine status text and class for badge $statusText = "Stopped"; $statusClass = "status-stopped"; @@ -160,7 +197,7 @@ $hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0'; // Main row - Docker tab structure with expand arrow on left - $o .= ""; + $o .= ""; // Arrow column $o .= ""; @@ -183,6 +220,13 @@ $o .= "$projectNameHtml
"; $o .= "$statusLabel"; if ($hasInvalidIndirect) { + composeLogger('Rendering invalid indirect warning in stack list', [ + 'project' => $stackInfo->projectFolder, + 'projectPath' => $stackInfo->path, + 'invalidIndirectPath' => $invalidIndirectPath, + 'isIndirect' => $stackInfo->isIndirect, + 'composeSource' => $stackInfo->composeSource, + ], 'user', 'debug', 'stack-list'); $o .= " "; } $o .= "
"; @@ -210,18 +254,25 @@ $uptimeClass = $isrunning ? 'green-text' : 'grey-text'; $o .= "$uptimeDisplay"; - // CPU & Memory column (advanced only) — populated in real-time via dockerload WebSocket - $o .= ""; - if ($isrunning) { - $o .= "0%"; - $o .= "
"; - $o .= "0B / 0B"; - } else { - $o .= "-"; - $o .= ""; - } + // Health column (updated from detailed inspect data by frontend; initial fallback here) + $healthDisplay = $isrunning ? 'n/a' : 'stopped'; + $healthClass = $isrunning ? 'compose-text-muted' : 'grey-text'; + $o .= "$healthDisplay"; + + // Metric columns (advanced only) + $o .= ""; + $o .= "-"; + $o .= "
"; $o .= ""; + $o .= ""; + $o .= "-"; + $o .= "
"; + $o .= ""; + + $o .= "-"; + $o .= "-"; + // Description column (advanced only) $o .= ""; if ($hasInvalidIndirect) { @@ -241,7 +292,7 @@ // Expandable details row $o .= ""; - $o .= ""; + $o .= ""; $o .= "
"; $o .= " Loading containers..."; $o .= "
"; @@ -250,9 +301,16 @@ } // If no stacks found, show a message -if ($stackCount === 0) { - $o = "No Docker Compose stacks found. Click 'Add New Stack' to create one."; +if ($mode !== 'row' && $stackCount === 0) { + $o = "No Docker Compose stacks found. Click 'Add New Stack' to create one."; } // Output the HTML -echo $o; +if ($mode === 'row') { + echo json_encode([ + 'result' => 'success', + 'html' => $o, + ]); +} else { + echo $o; +} diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 2e0258d2..9359d202 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -73,7 +73,23 @@ function compose_manager_cpu_spec_count($cpuSpec) /* Table structure — always fixed layout */ #compose_stacks { width: 100%; - table-layout: fixed + table-layout: fixed; + /* Single source of truth for stack-table column widths. */ + --cm-col-arrow-px: 24px; + --cm-col-icon-px: 48px; + --cm-col-fixed-px: calc(var(--cm-col-arrow-px) + var(--cm-col-icon-px)); + --cm-col-name-frac: 0.188524590; + --cm-col-update-frac: 0.131147541; + --cm-col-containers-frac: 0.065573770; + --cm-col-uptime-frac: 0.073770492; + --cm-col-health-frac: 0.073770492; + --cm-col-cpu-frac: 0.081967213; + --cm-col-memory-frac: 0.106557377; + --cm-col-net-io-frac: 0.000000000; + --cm-col-block-io-frac: 0.000000000; + --cm-col-description-frac: 0.114754098; + --cm-col-path-frac: 0.098360656; + --cm-col-autostart-frac: 0.065573770; } /* Stabilize header row height across basic/advanced toggle transitions */ @@ -89,9 +105,13 @@ function compose_manager_cpu_spec_count($cpuSpec) text-align: left; } - /* Clip overflowing content in fixed-layout cells */ + /* Clip overflowing content in fixed-layout cells. + border-box is REQUIRED so the percentage column widths below include + their own padding; otherwise padding is added on top of the computed + width and the columns overflow/shrink, leaving a gap at the right. */ #compose_stacks th, #compose_stacks td { + box-sizing: border-box; overflow: hidden; text-overflow: ellipsis } @@ -99,10 +119,54 @@ function compose_manager_cpu_spec_count($cpuSpec) /* Basic-view column widths (7 visible columns) Arrow + Icon are fixed px (small fixed content); rest are % of table. */ #compose_stacks thead th.col-arrow { - width: 15px; + width: var(--cm-col-arrow-px); padding: 0; } + #compose_stacks thead th.col-arrow .compose-stack-toggle-all { + width: 24px; + height: 24px; + margin: 0 auto; + padding: 0; + border: 0; + background: none; + appearance: none; + -webkit-appearance: none; + border-radius: 4px; + color: var(--dynamix-tablesorter-thead-th-text-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + opacity: 0.95; + } + + #compose_stacks thead th.col-arrow .compose-stack-toggle-all i { + transition: transform 0.2s ease; + transform: rotate(0deg); + } + + #compose_stacks thead th.col-arrow .compose-stack-toggle-all.is-expanded i { + transform: rotate(180deg); + } + + #compose_stacks thead th.col-arrow .compose-stack-toggle-all:hover { + color: var(--orange-text, var(--brand-orange)); + opacity: 1; + } + + #compose_stacks thead th.col-arrow .compose-stack-toggle-all:focus-visible { + outline: 1px solid var(--brand-orange); + outline-offset: 2px; + } + + #compose_stacks thead th.col-arrow .compose-stack-toggle-all:disabled { + cursor: default; + opacity: 0.45; + } + #compose_stacks td.col-arrow { text-align: center; white-space: nowrap; @@ -117,70 +181,56 @@ function compose_manager_cpu_spec_count($cpuSpec) } #compose_stacks thead th.col-icon { - width: 30px; + width: var(--cm-col-icon-px); padding: 0; } #compose_stacks thead th.col-name { - width: 12%; + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-name-frac)); } #compose_stacks thead th.col-update { - width: 25%; + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-update-frac)); } #compose_stacks thead th.col-containers { - width: 25%; + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-containers-frac)); } #compose_stacks thead th.col-uptime { - width: 25%; - } - - #compose_stacks thead th.col-autostart { - width: 15%; - } - - /* Advanced-view column widths (10 visible columns) - Arrow + Icon stay fixed px; Description + Path get the most %. */ - #compose_stacks.cm-advanced-view thead th.col-arrow { - width: 15px; - } - - #compose_stacks.cm-advanced-view thead th.col-icon { - width: 30px; + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-uptime-frac)); } - #compose_stacks.cm-advanced-view thead th.col-name { - width: 12%; + #compose_stacks thead th.col-health { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-health-frac)); } - #compose_stacks.cm-advanced-view thead th.col-update { - width: 10% + #compose_stacks thead th.col-autostart { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-autostart-frac)); } - #compose_stacks.cm-advanced-view thead th.col-containers { - width: 5% + #compose_stacks thead th.col-cpu { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-cpu-frac)) } - #compose_stacks.cm-advanced-view thead th.col-uptime { - width: 6% + #compose_stacks thead th.col-memory { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-memory-frac)) } - #compose_stacks.cm-advanced-view thead th.col-load { - width: 12% + #compose_stacks thead th.col-net_io { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-net-io-frac)) } - #compose_stacks.cm-advanced-view thead th.col-description { - width: 22% + #compose_stacks thead th.col-block_io { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-block-io-frac)) } - #compose_stacks.cm-advanced-view thead th.col-path { - width: 22% + #compose_stacks thead th.col-description { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-description-frac)) } - #compose_stacks.cm-advanced-view thead th.col-autostart { - width: 8% + #compose_stacks thead th.col-path { + width: calc((100% - var(--cm-col-fixed-px)) * var(--cm-col-path-frac)) } /* Center the Containers column */ @@ -204,24 +254,16 @@ function compose_manager_cpu_spec_count($cpuSpec) vertical-align: middle } - /* Advanced/basic visibility — CSS-only so no flash of hidden content */ - #compose_stacks .cm-advanced { - display: none - } - - #compose_stacks.cm-advanced-view .cm-advanced { - display: table-cell - } - - #compose_stacks.cm-advanced-view div.cm-advanced { - display: block - } - /* Detail row */ #compose_stacks .stack-details-cell { width: auto !important } + #compose_stacks tbody tr.compose-sortable.compose-stack-row-alt, + #compose_stacks tbody tr.compose-sortable.compose-stack-row-alt td { + background-color: var(--dynamix-tablesorter-tbody-row-alt-bg-color) + } + #compose_stacks tbody tr.stack-details-row { background-color: var(--dynamix-sb-body-bg-color) !important } @@ -236,10 +278,11 @@ function compose_manager_cpu_spec_count($cpuSpec) z-index: 100 !important; } - /* Keep long context menus visible above fixed bottom UI bars */ + /* Keep context menus above the fixed footer (z-index must out-rank the + .dropdown-menu !important rule above); the scroll spacer appended in + fixContextDropdownOverflow() keeps them reachable (unraid/webgui#2639) */ .dropdown-context:not(.dropdown-context-sub) { - max-height: calc(100vh - 72px); - overflow-y: auto; + z-index: 10001 !important; } /* CPU & Memory load display (matches Docker manager usage-disk style) */ @@ -289,6 +332,12 @@ function compose_manager_cpu_spec_count($cpuSpec) + + + + + + + @@ -352,13 +402,21 @@ function compose_manager_cpu_spec_count($cpuSpec) - + - + + + + + @@ -366,7 +424,7 @@ function compose_manager_cpu_spec_count($cpuSpec) - +
+
+ +
+
Stack Update Containers UptimeCPU & Memory loadHealthCPUMemoryNet I/ODisk I/O Description Path Autostart
@@ -471,6 +529,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
@@ -610,6 +672,17 @@ function compose_manager_cpu_spec_count($cpuSpec)
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.
+
+ + + +
+ External files (advanced) + +
+
Selected files are appended as additional -f flags after the main compose and override files (e.g., GPU or environment overrides). Setting this disables default file discovery.
+
+
@@ -629,6 +702,15 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
+ + +
When enabled, the Remove orphans option is pre-checked in Compose Up/Down dialogs and bulk start/stop dialogs. Useful when a stack is edited while containers still exist.
+
+
'; + var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-startall'); getConfig().then(function(pluginCfg) { var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; if (disableWarnings) { - executeStartAllStacks(stacks, bgDefault, bgDefault); + executeStartAllStacks({ + stacks: stacks, + background: bgDefault, + suppressBackgroundNotification: bgDefault, + removeOrphans: removeOrphansDefault + }); return; } swal({ title: title, html: true, - text: '

The following stacks will be started:

' + stackNames + '
' + bgCheckboxHtml + '
', + text: '

The following stacks will be started:

' + stackNames + '
' + removeOrphansHtml + bgCheckboxHtml + '
', type: 'warning', showCancelButton: true, confirmButtonText: confirmText, @@ -3315,18 +4142,31 @@ function startAllStacks() { }, function(confirmed) { if (confirmed) { var runInBackground = $('#swal-run-bg-startall').is(':checked'); - executeStartAllStacks(stacks, runInBackground); + var removeOrphans = $('#swal-remove-orphans-startall').is(':checked'); + executeStartAllStacks({ + stacks: stacks, + background: runInBackground, + suppressBackgroundNotification: false, + removeOrphans: removeOrphans + }); } }); setTimeout(function() { var $cb = $('#swal-run-bg-startall'); if ($cb.length) $cb.prop('checked', bgDefault); + var $removeCb = $('#swal-remove-orphans-startall'); + if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault); }, 50); }); } -function executeStartAllStacks(stacks, background, suppressBackgroundNotification = false) { +function executeStartAllStacks(opts) { + opts = opts || {}; + var stacks = opts.stacks || []; + var background = !!opts.background; + var suppressBackgroundNotification = !!opts.suppressBackgroundNotification; + var removeOrphans = !!opts.removeOrphans; var height = 800; var width = 1200; @@ -3349,7 +4189,8 @@ function executeStartAllStacks(stacks, background, suppressBackgroundNotificatio $.post(compURL, { action: 'composeUpMultiple', paths: JSON.stringify(paths), - background: background ? 1 : 0 + background: background ? 1 : 0, + removeOrphans: removeOrphans ? 1 : 0 }, function(data) { var parsed = tryParseJson(data); if (parsed && parsed.background) { @@ -3411,20 +4252,27 @@ function stopAllStacks() { '' + '' + '
'; + var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-stopall'); getConfig().then(function(pluginCfg) { var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; + var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; if (disableWarnings) { - executeStopAllStacks(stacks, bgDefault, bgDefault); + executeStopAllStacks({ + stacks: stacks, + background: bgDefault, + suppressBackgroundNotification: bgDefault, + removeOrphans: removeOrphansDefault + }); 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 + '
', + text: '

The following stacks will be stopped:

' + stackNames + '

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

' + removeOrphansHtml + bgCheckboxHtml + '
', type: 'warning', showCancelButton: true, confirmButtonText: confirmText, @@ -3432,18 +4280,31 @@ function stopAllStacks() { }, function(confirmed) { if (confirmed) { var runInBackground = $('#swal-run-bg-stopall').is(':checked'); - executeStopAllStacks(stacks, runInBackground); + var removeOrphans = $('#swal-remove-orphans-stopall').is(':checked'); + executeStopAllStacks({ + stacks: stacks, + background: runInBackground, + suppressBackgroundNotification: false, + removeOrphans: removeOrphans + }); } }); setTimeout(function() { var $cb = $('#swal-run-bg-stopall'); if ($cb.length) $cb.prop('checked', bgDefault); + var $removeCb = $('#swal-remove-orphans-stopall'); + if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault); }, 50); }); } -function executeStopAllStacks(stacks, background, suppressBackgroundNotification = false) { +function executeStopAllStacks(opts) { + opts = opts || {}; + var stacks = opts.stacks || []; + var background = !!opts.background; + var suppressBackgroundNotification = !!opts.suppressBackgroundNotification; + var removeOrphans = !!opts.removeOrphans; var height = 800; var width = 1200; @@ -3466,7 +4327,8 @@ function executeStopAllStacks(stacks, background, suppressBackgroundNotification $.post(compURL, { action: 'composeDownMultiple', paths: JSON.stringify(paths), - background: background ? 1 : 0 + background: background ? 1 : 0, + removeOrphans: removeOrphans ? 1 : 0 }, function(data) { var parsed = tryParseJson(data); if (parsed && parsed.background) { @@ -3490,17 +4352,58 @@ function mergeUpdateStatus(containers, project) { var cInfo = createContainerInfo(container); stackUpdateStatus[project].containers.forEach(function(update) { var uInfo = createContainerInfo(update); - if (cInfo.name === uInfo.name) { + var sameName = cInfo.name && uInfo.name && cInfo.name === uInfo.name; + var sameService = cInfo.service && uInfo.service && cInfo.service === uInfo.service; + if (sameName || sameService) { container.hasUpdate = uInfo.hasUpdate; container.updateStatus = uInfo.updateStatus; container.localSha = uInfo.localSha; container.remoteSha = uInfo.remoteSha; + if ((!container.icon || !isValidIconSrc(container.icon)) && uInfo.icon) { + container.icon = uInfo.icon; + } } }); }); return containers; } +// Show docker.versions changelog for a single container. +// Delegates entirely to docker.versions' own showChangeLog() so that display +// logic, Nchan subscription management, and message routing stay in one place. +// +// Coupling point: showChangeLog(containerName) — global function from +// docker.versions/scripts/changelog.js. If that function is renamed or +// removed, this feature silently does nothing. +function showComposeChangelog(containerName, path, profile) { + if (typeof showChangeLog !== 'function') return; + showChangeLog(containerName); + // docker.versions' OK button calls updateContainer(), which bypasses + // compose_plugin's update mechanism. Hide it so the only exit is Cancel. + setTimeout(function() { $('.sweet-alert .confirm').hide(); }, 0); + if (!path) return; + // Reopen the Update Stack dialog when the changelog dialog closes. + // SweetAlert 1.x has no close event; poll the showSweetAlert class instead. + // Cap at 300 ticks (30 s) so the interval self-cleans if the dialog never opens. + var appeared = false; + var ticks = 0; + var poll = setInterval(function() { + if (++ticks > 300) { clearInterval(poll); return; } + var open = $('.sweet-alert').hasClass('showSweetAlert'); + if (!appeared) { if (open) appeared = true; return; } + if (!open) { + clearInterval(poll); + // Skip reopening when DISABLE_ACTION_WARNINGS is true: renderStackActionDialog + // has a fast-path that calls UpdateStackConfirmed directly in that mode, + // so reopening would trigger an immediate update rather than a dialog. + getConfig().then(function(cfg) { + if (cfg && cfg.DISABLE_ACTION_WARNINGS === 'true') return; + showStackActionDialog('update', path, profile || ''); + }); + } + }, 100); +} + // Unified stack action dialog - handles up, down, and update actions function showStackActionDialog(action, path, profile) { var stackName = basename(path); @@ -3579,13 +4482,16 @@ function showStackActionDialog(action, path, profile) { containers.push(container); }); + var detectedMismatch = cachedContainers.length !== profileServices.length; + composeLogger('profile/unified AJAX done', { profileServices, containers, - cachedContainers + cachedContainers, + detectedMismatch }, 'user', 'debug', 'showStackActionDialog'); containers = mergeUpdateStatus(containers, project); - renderStackActionDialog(action, displayName, path, profile, containers, hasBuild); + renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, detectedMismatch); }).fail(function(xhr, status, error) { // Fallback: if profile resolution fails, show cached container metadata composeLogger('getProfileServices AJAX fail', { @@ -3603,12 +4509,12 @@ function showStackActionDialog(action, path, profile) { }); containers = mergeUpdateStatus(containers, project); } - renderStackActionDialog(action, displayName, path, profile, containers, hasBuild); + renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, cachedContainers.length > 0); }); return; } -function renderStackActionDialog(action, displayName, path, profile, containers, hasBuild) { +function renderStackActionDialog(action, displayName, path, profile, containers, hasBuild, showRemoveOrphans) { hasBuild = hasBuild || false; // Action-specific configuration var pullLabel = hasBuild ? 'Build' : 'Pull'; @@ -3622,6 +4528,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers, warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-ui-dropdownchecklist-color'), confirmText: 'Compose Up', showVersionArrow: false, + showRemoveOrphans: true, confirmedFn: ComposeUpConfirmed }, 'down': { @@ -3633,6 +4540,7 @@ function renderStackActionDialog(action, displayName, path, profile, containers, warningColor: window.getComputedStyle(document.documentElement).getPropertyValue('--dynamix-sb-message-link-color'), confirmText: 'Compose Down', showVersionArrow: false, + showRemoveOrphans: true, confirmedFn: ComposeDownConfirmed }, 'stop': { @@ -3753,6 +4661,9 @@ function renderStackActionDialog(action, displayName, path, profile, containers, html += ' '; html += '' + composeEscapeHtml(remoteSha.substring(0, 8)) + ''; html += ''; + if (dockerVersionsInstalled) { + html += '
Changelog
'; + } } else if (localSha) { // No update - just show current SHA (greyed) html += '
' + composeEscapeHtml(localSha.substring(0, 8)) + '
'; @@ -3767,6 +4678,15 @@ function renderStackActionDialog(action, displayName, path, profile, containers, // Warning/info text html += '
' + cfg.warning + '
'; + var removeOrphansDefault = false; + + if (cfg.showRemoveOrphans) { + html += ''; + } + // Run-in-background checkbox (appended after config is fetched below) var bgCheckboxHtml = '
' + '' + @@ -3778,14 +4698,23 @@ function renderStackActionDialog(action, displayName, path, profile, containers, // 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'; + removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_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); + cfg.confirmedFn(path, { + profile: profile, + background: bgDefault, + suppressBackgroundNotification: bgDefault, + removeOrphans: removeOrphansDefault + }); return; } + var removeOrphansChecked = removeOrphansDefault || showRemoveOrphans; + var showRemoveOrphansOption = cfg.showRemoveOrphans && (removeOrphansChecked || showRemoveOrphans); + // Use native swal (SweetAlert 1.x) with callback style swal({ title: cfg.title, @@ -3799,8 +4728,14 @@ function renderStackActionDialog(action, displayName, path, profile, containers, if (confirmed) { // Capture checkbox state before swal destroys the DOM var runInBackground = $('#swal-run-bg-checkbox').is(':checked'); + var removeOrphans = $('#swal-remove-orphans-checkbox').is(':checked'); // when running in background, suppress the extra notifyBackgroundStarted popup - cfg.confirmedFn(path, profile, runInBackground, runInBackground); + cfg.confirmedFn(path, { + profile: profile, + background: runInBackground, + suppressBackgroundNotification: runInBackground, + removeOrphans: removeOrphans + }); } }); @@ -3810,6 +4745,11 @@ function renderStackActionDialog(action, displayName, path, profile, containers, if ($cb.length) { $cb.prop('checked', bgDefault); } + var $orphanCb = $('#swal-remove-orphans-checkbox'); + if ($orphanCb.length) { + $orphanCb.prop('checked', removeOrphansChecked); + $('#swal-remove-orphans-wrap').toggle(showRemoveOrphansOption); + } }, 50); }); } @@ -3900,11 +4840,67 @@ 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 = {}; +var stackDetailsLoadPromises = {}; +var stackDetailsPrefetchPromises = {}; +var stackDetailsPrefetchCache = {}; // Suppress immediate refresh after a render to avoid loops var stackDetailsJustRendered = {}; +// Track user intent during async loads so late responses do not reopen collapsed rows. +var stackDetailsDesiredExpanded = {}; +// Batch/safeguard toggle-all updates under rapid clicking. +var stackToggleAllBatching = false; +var stackToggleAllBusy = false; +var stackToggleAllQueuedForceExpand = null; +var stackToggleAllQueuedToggle = false; + +function prefetchStackDetailsInBackground(projects) { + if (!Array.isArray(projects) || projects.length === 0) return; + + var maxParallel = 4; + var next = 0; + var active = 0; + + function schedule() { + while (active < maxParallel && next < projects.length) { + (function(project) { + next++; + + if (!project || stackDetailsPrefetchPromises[project] || stackDetailsPrefetchCache[project]) { + schedule(); + return; + } -function openStackActionsMenu(event, stackId) { - event.stopPropagation(); + active++; + stackDetailsPrefetchPromises[project] = new Promise(function(resolve) { + $.post(caURL, { + action: 'getStackContainers', + script: project + }, function(data) { + var parsed = tryParseJson(data); + if (parsed && parsed.result === 'success') { + stackDetailsPrefetchCache[project] = parsed; + // If the row already exists, bubble prefetched summary + // data into parent columns during initial progressive load. + applyStackSummaryFromResponse(project, parsed); + } + resolve(); + }).fail(function() { + resolve(); + }).always(function() { + active--; + delete stackDetailsPrefetchPromises[project]; + schedule(); + }); + }); + })(projects[next]); + } + } + + schedule(); +} + +function openStackActionsMenu(event, stackId) { + event.stopPropagation(); currentStackId = stackId; var $row = $('#stack-row-' + stackId); @@ -3956,6 +4952,8 @@ function executeStackAction(action) { var projectName = $row.data('projectname'); var path = $row.data('path'); var profiles = $row.data('profiles') || []; + var runningProfile = $row.data('running-profile') || ''; + var defaultProfile = $row.data('default-profile') || ''; var isUp = $row.data('isup') == "1"; closeStackActionsMenu(); @@ -3963,7 +4961,7 @@ function executeStackAction(action) { // 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); + showProfileSelector(action, path, profiles, runningProfile, defaultProfile); return; } @@ -3997,7 +4995,13 @@ function executeStackAction(action) { } } -function showProfileSelector(action, path, profiles) { +function showProfileSelector(action, path, profiles, runningProfile, defaultProfile) { + if (typeof runningProfile === 'undefined') { + runningProfile = ''; + } + if (typeof defaultProfile === 'undefined') { + defaultProfile = ''; + } var actionNames = { 'up': 'Compose Up', 'down': 'Compose Down', @@ -4017,12 +5021,42 @@ function showProfileSelector(action, path, profiles) { profileHtml += '
'; profileHtml += ''; profileHtml += '
'; + + function parseProfileList(rawValue) { + if (!rawValue) return []; + return rawValue.split(',').map(function(p) { + return p.trim(); + }).filter(function(p) { + return p !== ''; + }); + } + + // Selection priority: running profiles -> default profiles -> all (*) + var runningProfileSet = parseProfileList(runningProfile); + var defaultProfileSet = parseProfileList(defaultProfile); + var preselectedProfiles = []; + var showAllProfilesChecked = false; + + if (runningProfileSet.indexOf('*') !== -1) { + showAllProfilesChecked = true; + } else if (runningProfileSet.length > 0) { + preselectedProfiles = runningProfileSet; + } else if (defaultProfileSet.indexOf('*') !== -1) { + showAllProfilesChecked = true; + } else if (defaultProfileSet.length > 0) { + preselectedProfiles = defaultProfileSet; + } else { + showAllProfilesChecked = true; + } + var profileCheckboxDisabled = showAllProfilesChecked ? 'disabled' : ''; + profileHtml += '
'; - profileHtml += ''; + profileHtml += ''; profileHtml += '
'; profileHtml += '
'; profiles.forEach(function(profile) { - profileHtml += ''; + var isChecked = !showAllProfilesChecked && preselectedProfiles.indexOf(profile) >= 0; + profileHtml += ''; }); profileHtml += '
'; profileHtml += '
Default services are always included. Select multiple profiles to include profile-based services.
'; @@ -4082,6 +5116,14 @@ function toggleAllProfiles(checkbox) { $('.profile_checkbox').prop('disabled', disabled).prop('checked', false); } +function updateProfileCheckboxes() { + // If any individual profile is checked, uncheck "all profiles" + var anyIndividualChecked = $('.profile_checkbox:checked').length > 0; + if (anyIndividualChecked) { + $('#profile_all_profiles').prop('checked', false); + } +} + function openEditorModalByProject(project, projectName, initialTab) { if (typeof ace === 'undefined') { swal({ @@ -4216,6 +5258,79 @@ function loadEditorFiles(project) { }); } +// ---- Compose file switcher (main + additional compose files) ---- + +// Populate the Compose tab file selector; hidden unless the stack has +// more than one editable compose file. +function populateComposeFileSelector(files) { + var $wrap = $('#compose-file-selector-wrap'); + var $sel = $('#compose-file-selector'); + editorModal.composeFiles = files || []; + + $sel.empty(); + if (!files || files.length < 2) { + $wrap.hide(); + return; + } + files.forEach(function(path) { + var base = path.split('/').pop(); + $sel.append($('