diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php
index f56a8fd7..dfaaadc0 100755
--- a/source/compose.manager/include/ComposeManager.php
+++ b/source/compose.manager/include/ComposeManager.php
@@ -477,6 +477,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
+
+
+
@@ -616,6 +620,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.
+
+
+
+
No additional compose files found in the compose source folder (looking for *compose*.yml / *compose*.yaml).
+
+ 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.
+
+
diff --git a/source/compose.manager/include/ContainerCache.php b/source/compose.manager/include/ContainerCache.php
new file mode 100644
index 00000000..0aba7034
--- /dev/null
+++ b/source/compose.manager/include/ContainerCache.php
@@ -0,0 +1,15 @@
+getEditableComposeFiles() as $candidate) {
+ if (Path::refersToSamePath($candidate, $requested)) {
+ return $candidate;
+ }
+ }
+ return false;
+ }
+}
+
if (!function_exists('composeLoadPersistentContainerCache')) {
function composeLoadPersistentContainerCache(): array
{
@@ -344,6 +367,16 @@ function composeResolveContainerIcon(string $containerName, string $service, arr
$stackInfo = StackInfo::fromProject($compose_root, $script);
$composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/compose.yaml');
+ // Optional explicit file selection (e.g. additional compose files)
+ $requestedFile = resolveRequestedComposeFile($stackInfo);
+ if ($requestedFile === false) {
+ echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']);
+ break;
+ }
+ if ($requestedFile !== null) {
+ $composeFilePath = $requestedFile;
+ }
+
// Check file existence consistently regardless of how path was resolved
if (is_file($composeFilePath)) {
$scriptContents = file_get_contents($composeFilePath);
@@ -496,12 +529,24 @@ function composeResolveContainerIcon(string $containerName, string $service, arr
$stackInfo = StackInfo::fromProject($compose_root, $script);
$composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]);
+ // Optional explicit file selection (e.g. additional compose files)
+ $requestedFile = resolveRequestedComposeFile($stackInfo);
+ if ($requestedFile === false) {
+ echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']);
+ break;
+ }
+ $isMainComposeFile = $requestedFile === null || Path::refersToSamePath($requestedFile, $composeFilePath);
+ if ($requestedFile !== null) {
+ $composeFilePath = $requestedFile;
+ }
+
if (rejectStaleClientPath($composeFilePath, 'compose file')) {
break;
}
- // Before saving, detect service renames and migrate override entries in the project override only
- if (is_file($composeFilePath)) {
+ // Before saving, detect service renames and migrate override entries in the project override only.
+ // Rename migration only applies to the main compose file.
+ if ($isMainComposeFile && is_file($composeFilePath)) {
$oldContent = file_get_contents($composeFilePath);
$stackInfo->overrideInfo->migrateOnRename($oldContent, $scriptContents);
}
@@ -766,6 +811,31 @@ function composeResolveContainerIcon(string $containerName, string $service, arr
$defaultProfileFile = "$compose_root/$script/default_profile";
$defaultProfile = is_file($defaultProfileFile) ? trim(file_get_contents($defaultProfileFile)) : "";
+ // Get additional compose files (one path per line)
+ $extraComposeFilesFile = "$compose_root/$script/extra_compose_files";
+ $extraComposeFiles = is_file($extraComposeFilesFile) ? trim(file_get_contents($extraComposeFilesFile)) : "";
+
+ // Candidate compose files in the compose source folder for the
+ // Additional Compose Files selector (*compose*.y(a)ml, excluding the
+ // main compose file and override files)
+ $composeFileCandidates = [];
+ $mainComposeBase = $stackInfo->composeFilePath !== null ? basename($stackInfo->composeFilePath) : '';
+ $candidateGlob = glob($stackInfo->composeSource . '/*.{yml,yaml}', GLOB_BRACE) ?: [];
+ foreach ($candidateGlob as $candidatePath) {
+ $candidateBase = basename($candidatePath);
+ if (stripos($candidateBase, 'compose') === false) {
+ continue;
+ }
+ if ($candidateBase === $mainComposeBase) {
+ continue;
+ }
+ if (stripos($candidateBase, '.override.') !== false) {
+ continue;
+ }
+ $composeFileCandidates[] = $candidateBase;
+ }
+ sort($composeFileCandidates);
+
// Get labels tab view mode (per-stack)
$labelsViewModeFile = "$compose_root/$script/labels_view_mode";
$labelsViewMode = is_file($labelsViewModeFile) ? strtolower(trim(file_get_contents($labelsViewModeFile))) : 'basic';
@@ -828,6 +898,9 @@ function composeResolveContainerIcon(string $containerName, string $service, arr
'iconUrl' => $iconUrl,
'webuiUrl' => $webuiUrl,
'defaultProfile' => $defaultProfile,
+ 'extraComposeFiles' => $extraComposeFiles,
+ 'composeFileCandidates' => $composeFileCandidates,
+ 'editableComposeFiles' => $stackInfo->getEditableComposeFiles(),
'labelsViewMode' => $labelsViewMode,
'useDefaultComposeFiles' => $useDefaultComposeFiles,
'indirectMode' => $stackInfo->indirectMode,
@@ -961,6 +1034,43 @@ function composeResolveContainerIcon(string $containerName, string $service, arr
}
}
+ // Additional compose files (one path per line, absolute or relative to compose source).
+ // Only processed when the client sends the field: a stale UI session
+ // that predates this setting must not silently clear it.
+ $extraComposeFilesProvided = isset($_POST['extraComposeFiles']);
+ $extraComposeFiles = $extraComposeFilesProvided ? trim($_POST['extraComposeFiles']) : "";
+ $extraComposeFilesNormalized = [];
+ $extraComposeFilesError = '';
+ foreach (preg_split('/\R/', $extraComposeFiles) as $extraLine) {
+ $extraLine = trim($extraLine);
+ if ($extraLine === '' || strpos($extraLine, '#') === 0) {
+ continue;
+ }
+ if (preg_match('/\.ya?ml$/i', $extraLine) !== 1) {
+ $extraComposeFilesError = 'Additional compose file must be a .yml or .yaml file: ' . $extraLine;
+ break;
+ }
+ if (Path::isAbsolutePath($extraLine)) {
+ $realExtra = realpath($extraLine);
+ if ($realExtra === false || !is_file($realExtra)) {
+ $extraComposeFilesError = 'Additional compose file does not exist: ' . $extraLine;
+ break;
+ }
+ if (!Path::isAllowedPath($realExtra, ['/mnt', '/boot/config'])) {
+ $extraComposeFilesError = 'Additional compose files must be under /mnt/ or /boot/config/.';
+ break;
+ }
+ } elseif (strpos($extraLine, '..') !== false) {
+ $extraComposeFilesError = 'Relative additional compose file paths must not contain "..": ' . $extraLine;
+ break;
+ }
+ $extraComposeFilesNormalized[] = $extraLine;
+ }
+ if ($extraComposeFilesError !== '') {
+ echo json_encode(['result' => 'error', 'message' => $extraComposeFilesError]);
+ break;
+ }
+
// --- All validation passed, now write everything ---
// Set env path
@@ -999,6 +1109,17 @@ function composeResolveContainerIcon(string $containerName, string $service, arr
file_put_contents($defaultProfileFile, $defaultProfile);
}
+ // Set additional compose files (skipped entirely when the field was not sent)
+ if ($extraComposeFilesProvided) {
+ $extraComposeFilesFile = "$compose_root/$script/extra_compose_files";
+ if (empty($extraComposeFilesNormalized)) {
+ if (is_file($extraComposeFilesFile))
+ @unlink($extraComposeFilesFile);
+ } else {
+ file_put_contents($extraComposeFilesFile, implode("\n", $extraComposeFilesNormalized) . "\n");
+ }
+ }
+
// Set compose file discovery mode
$useDefaultComposeFilesFile = "$compose_root/$script/use_default_compose_files";
if ($useDefaultComposeFiles) {
diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php
index 0a7e2006..b2649d6d 100644
--- a/source/compose.manager/include/Util.php
+++ b/source/compose.manager/include/Util.php
@@ -1908,6 +1908,40 @@ private function getAdditionalComposeFilesFromEnv(): array
return array_values(array_unique($files));
}
+ /**
+ * Parse additional compose file paths from the `extra_compose_files`
+ * metadata file (one path per line; `#` comments allowed).
+ *
+ * Paths may be absolute or relative to the compose source folder.
+ * Missing files are skipped with a warning so a stale entry cannot
+ * break stack operations.
+ *
+ * @return string[]
+ */
+ private function getExtraComposeFiles(): array
+ {
+ $raw = $this->readMetadata('extra_compose_files');
+ if ($raw === null || $raw === '') {
+ return [];
+ }
+
+ $files = [];
+ foreach (preg_split('/\R/', $raw) as $line) {
+ $line = trim($line);
+ if ($line === '' || str_starts_with($line, '#')) {
+ continue;
+ }
+ $path = Path::isAbsolutePath($line) ? $line : $this->composeSource . '/' . $line;
+ if (!is_file($path)) {
+ composeLogger("Ignoring missing extra compose file for stack $this->projectFolder", ['path' => $path], 'user', 'warning', 'stack');
+ continue;
+ }
+ $files[] = $path;
+ }
+
+ return $files;
+ }
+
/**
* Normalize compose file paths for deduplication.
*
@@ -1964,6 +1998,26 @@ private static function splitComposeFileValue(string $value): array
return $entries;
}
+ /**
+ * Compose files that are directly editable in the stack editor: every
+ * existing file that contributes to the compose command, in `-f` order
+ * (main compose file, override file, COMPOSE_FILE env entries, and
+ * `extra_compose_files` metadata).
+ *
+ * @return string[]
+ */
+ public function getEditableComposeFiles(): array
+ {
+ $files = [];
+ foreach ($this->getComposeFilePaths() as $path) {
+ if (!is_file($path)) {
+ continue;
+ }
+ $files[] = $path;
+ }
+ return $files;
+ }
+
private function getComposeFilePaths(): array
{
$paths = [];
@@ -1979,6 +2033,10 @@ private function getComposeFilePaths(): array
$paths[] = $extraFile;
}
+ foreach ($this->getExtraComposeFiles() as $extraFile) {
+ $paths[] = $extraFile;
+ }
+
$normalized = [];
$unique = [];
foreach ($paths as $path) {
@@ -2019,8 +2077,8 @@ public function useDefaultComposeFileDiscovery(): bool
/**
* Determine whether this stack has manual settings that require explicit mode.
*
- * Explicit env-path and indirect-file selections are treated as explicit-mode
- * signals, so default file discovery is bypassed.
+ * Explicit env-path, extra compose files, and indirect-file selections are
+ * treated as explicit-mode signals, so default file discovery is bypassed.
*
* @return bool
*/
@@ -2031,6 +2089,11 @@ private function hasManualComposePathOverrides(): bool
return true;
}
+ $extraComposeFiles = $this->readMetadata('extra_compose_files');
+ if ($extraComposeFiles !== null && $extraComposeFiles !== '') {
+ return true;
+ }
+
return $this->indirectMode === 'file';
}
diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js
index 43170d31..2a7cfbd9 100644
--- a/source/compose.manager/javascript/composeManagerMain.js
+++ b/source/compose.manager/javascript/composeManagerMain.js
@@ -91,15 +91,14 @@ function parseMemUsagePair(memStr) {
}
}
- var hasEnvPath = ($('#settings-env-path').val() || '').trim() !== '';
- var hasExternalComposeFilePath = ($('#settings-external-compose-file').val() || '').trim() !== '';
+ var manualOverrideReasons = [];
+ if (($('#settings-env-path').val() || '').trim() !== '') manualOverrideReasons.push('Env File Path');
+ if (($('#settings-external-compose-file').val() || '').trim() !== '') manualOverrideReasons.push('External Compose File');
+ if (getExtraComposeFilesValue() !== '') manualOverrideReasons.push('Additional Compose Files');
- if (hasEnvPath || hasExternalComposeFilePath) {
- var reason = hasEnvPath && hasExternalComposeFilePath
- ? 'Default discovery disabled because Env File Path and External Compose File are set.'
- : (hasEnvPath
- ? 'Default discovery disabled because Env File Path is set.'
- : 'Default discovery disabled because External Compose File is set.');
+ if (manualOverrideReasons.length > 0) {
+ var reason = 'Default discovery disabled because ' + manualOverrideReasons.join(' and ')
+ + (manualOverrideReasons.length > 1 ? ' are set.' : ' is set.');
if ($checkbox.is(':checked')) {
$checkbox.prop('checked', false);
@@ -115,6 +114,76 @@ function parseMemUsagePair(memStr) {
}
}
+// ---- Additional Compose Files settings UI ----
+
+// Combine checked folder candidates and external lines into the
+// newline-separated value stored in the extra_compose_files metadata file.
+function getExtraComposeFilesValue() {
+ var lines = [];
+ $('#settings-extra-compose-candidates input[type="checkbox"]:checked').each(function() {
+ lines.push($(this).val());
+ });
+ ($('#settings-extra-compose-external').val() || '').split('\n').forEach(function(line) {
+ line = line.trim();
+ if (line) lines.push(line);
+ });
+ return lines.join('\n');
+}
+
+// Render the candidate checkbox list and route non-candidate entries
+// (external/absolute paths) into the advanced textarea.
+function renderExtraComposeFiles(candidates, rawValue) {
+ var $list = $('#settings-extra-compose-candidates');
+ var $none = $('#settings-extra-compose-none');
+ var $external = $('#settings-extra-compose-external');
+
+ var selected = [];
+ (rawValue || '').split('\n').forEach(function(line) {
+ line = line.trim();
+ if (line && line.indexOf('#') !== 0) selected.push(line);
+ });
+
+ candidates = candidates || [];
+ var externalLines = selected.filter(function(entry) {
+ return candidates.indexOf(entry) === -1;
+ });
+
+ $list.empty();
+ candidates.forEach(function(name) {
+ var $label = $('