From 5f86f9adb67671181eae2a3f7294dc8cf1ebf235 Mon Sep 17 00:00:00 2001 From: bean Date: Tue, 10 Feb 2026 14:41:13 -0500 Subject: [PATCH 1/4] Fix backup scheduler, replace browse with upload, UI improvements - Fix scheduled backup using root crontab instead of Unraid cron helper - Replace 'Browse for Archive' with file upload via FormData/AJAX - Validate uploaded archive type, duplicates, and destination writability - Show stack names in restore confirmation dialog instead of count - Replace save-settings popup (swal) with inline status banner - Restrict help cursor to settings that have help text - Simplify cron log output to single completion line - Match restore status message width to backup tables - Standardize backup/restore log format with category tags --- .../compose.manager.settings.page | 135 +++-- .../compose.manager/php/backup_functions.php | 49 +- source/compose.manager/php/exec.php | 566 +++++++++++------- source/compose.manager/scripts/backup_cron.sh | 5 +- tests/unit/BackupFunctionsTest.php | 112 ++++ tests/unit/SettingsBackupTest.php | 5 +- 6 files changed, 570 insertions(+), 302 deletions(-) diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page index e96fd31..b9f820b 100644 --- a/source/compose.manager/compose.manager.settings.page +++ b/source/compose.manager/compose.manager.settings.page @@ -175,7 +175,7 @@ $projects_exist = intval(shell_exec("ls -l ".$compose_root." | grep ^d | wc -l") #compose-tab-backup .backup-section { margin-bottom: 20px; } - #compose-tab-backup dt { + #compose-tab-backup dl:has(blockquote.inline_help) > dt { cursor: help; } .backup-dest-input { @@ -187,11 +187,13 @@ $projects_exist = intval(shell_exec("ls -l ".$compose_root." | grep ^d | wc -l") } .backup-archive-list { max-height: 150px; - max-width: 700px; + width: 700px; + max-width: 100%; overflow-y: auto; border: 1px solid rgba(128, 128, 128, 0.3); border-radius: 4px; margin: 4px 0; + box-sizing: border-box; } .backup-archive-list table { width: 100%; @@ -223,12 +225,14 @@ $projects_exist = intval(shell_exec("ls -l ".$compose_root." | grep ^d | wc -l") } .backup-stack-checklist { max-height: 150px; - max-width: 700px; + width: 700px; + max-width: 100%; overflow-y: auto; border: 1px solid rgba(128, 128, 128, 0.3); border-radius: 4px; - padding: 4px 12px; + padding: 4px 8px; margin: 4px 0; + box-sizing: border-box; } .backup-stack-checklist label { display: block; @@ -248,6 +252,9 @@ $projects_exist = intval(shell_exec("ls -l ".$compose_root." | grep ^d | wc -l") border-radius: 4px; font-size: 0.95em; display: none; + width: 700px; + max-width: 100%; + box-sizing: border-box; } .backup-status.success { display: block; @@ -867,9 +874,10 @@ function restoreSelectedStacks() { if (stacks.length === 0 || !selectedArchive) return; // Show confirmation modal + var stackNames = stacks.join(', '); swal({ title: 'Confirm Restore', - text: 'This action will permanently replace existing configurations for the selected stacks (' + stacks.length + ' stack' + (stacks.length > 1 ? 's' : '') + '). Confirm to proceed.', + text: 'This action will permanently replace existing configurations for the selected stacks (' + stackNames + '). Confirm to proceed.', type: 'warning', showCancelButton: true, confirmButtonText: 'Restore', @@ -925,7 +933,7 @@ function deleteSelectedBackup() { if (result.result === 'success') { selectedArchive = null; selectedManifest = null; - $('#restore-stack-checklist').html('Select a backup archive above to see its contents.'); + $('#restore-stack-checklist').html('Select a backup archive above to see its contents.'); $('#btn-restore-stacks').prop('disabled', true); loadBackupArchives(); } else { @@ -951,11 +959,8 @@ function toggleScheduleDay() { } function saveBackupSettings() { - // Collect all backup settings and save via the standard Unraid config mechanism - // We do this by posting to update.php with the right form fields + // Collect all backup settings and save via Ajax var settings = { - '#file': 'compose.manager/compose.manager.cfg', - '#section': '', 'BACKUP_DESTINATION': $('#BACKUP_DESTINATION').val() || '/boot/config/plugins/compose.manager/backups', 'BACKUP_RETENTION': $('#BACKUP_RETENTION').val() || '5', 'BACKUP_SCHEDULE_ENABLED': $('#BACKUP_SCHEDULE_ENABLED').is(':checked') ? 'true' : 'false', @@ -964,64 +969,71 @@ function saveBackupSettings() { 'BACKUP_SCHEDULE_DAY': $('#BACKUP_SCHEDULE_DAY').val() || '1' }; - // We also need to include all existing settings so they aren't lost - // Use a hidden form approach similar to the settings tab - var $form = $('
', { - method: 'POST', - action: '/update.php', - target: 'progressFrame' - }); + // Save via Ajax with the new saveBackupSettings action + var $btn = $('#btn-save-backup-settings'); + $btn.prop('disabled', true).val('Saving...'); - // Get current settings from the main form and pass them through too - $('form[name="compose_manager_settings"] input[type="hidden"]').each(function() { - $form.append($(this).clone()); + $.post(caURL, { + action: 'saveBackupSettings', + settings: JSON.stringify(settings) + }, function(response) { + var result = JSON.parse(response); + $btn.prop('disabled', false).val('Save Settings'); + + if (result.result === 'success') { + showBackupStatus('#backup-create-status', 'Backup settings saved.', 'success'); + } else { + showBackupStatus('#backup-create-status', result.message || 'Failed to save settings', 'error'); + } + }).fail(function() { + $btn.prop('disabled', false).val('Save Settings'); + showBackupStatus('#backup-create-status', 'Failed to communicate with server', 'error'); }); +} + +function uploadBackupArchive(input) { + if (!input.files || !input.files[0]) return; + var file = input.files[0]; - // Add backup-specific fields - for (var key in settings) { - $form.append($('', { type: 'hidden', name: key, value: settings[key] })); + // Validate extension + if (!file.name.match(/\.(tar\.gz|tgz)$/i)) { + swal('Invalid File', 'Please select a .tar.gz archive file.', 'error'); + input.value = ''; + return; } - $form.appendTo('body').submit().remove(); + var formData = new FormData(); + formData.append('action', 'uploadBackup'); + formData.append('file', file); - // Also update the cron job - setTimeout(function() { - $.post(caURL, { action: 'updateBackupCron' }, function() { - // Cron updated silently - }); - }, 1000); -} - -function browseRestoreArchive() { - // Allow manual path entry via prompt - var currentPath = selectedArchive || ''; - var path = prompt('Enter the full path to a backup .tar.gz archive:', currentPath); - if (path && path.trim()) { - selectedArchive = path.trim(); - - // Deselect table rows - $('#backup-archive-list tr').removeClass('selected').find('input[type="radio"]').prop('checked', false); - - // Load stack list from external file - $('#restore-stack-checklist').html('Reading archive contents...'); - $('#btn-restore-stacks').prop('disabled', true); - hideBackupStatus('#restore-status'); - - $.post(caURL, { action: 'readManifest', archive: path.trim() }, function(data) { + showBackupStatus('#restore-status', 'Uploading ' + file.name + '...', 'info'); + + $.ajax({ + url: caURL, + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(data) { try { var result = JSON.parse(data); - if (result.result === 'success' && result.stacks) { - selectedManifest = result; - renderStackChecklist(result.stacks); - showBackupStatus('#restore-status', 'Loaded archive: ' + path.trim(), 'info'); + if (result.result === 'success') { + showBackupStatus('#restore-status', 'Uploaded: ' + file.name, 'success'); + loadBackupArchives(); } else { - $('#restore-stack-checklist').html('' + escapeHtml(result.message || 'Failed to read archive.') + ''); + showBackupStatus('#restore-status', result.message || 'Upload failed.', 'error'); } } catch(e) { - $('#restore-stack-checklist').html('Error parsing response.'); + showBackupStatus('#restore-status', 'Error parsing upload response.', 'error'); } - }); - } + }, + error: function() { + showBackupStatus('#restore-status', 'Upload request failed.', 'error'); + }, + complete: function() { + input.value = ''; + } + }); } @@ -1272,7 +1284,7 @@ function browseRestoreArchive() {
-
_(Scheduled Backup)_:
+
_(Scheduled Backup)_:
@@ -1345,13 +1357,10 @@ function browseRestoreArchive() {
- + +
-
- Select a backup archive from the list, or browse for a .zip file elsewhere on the system.
- Only archives matching the backup_YYYY-MM-DD_HH-MM.tar.gz naming pattern are listed automatically. -
@@ -1359,7 +1368,7 @@ function browseRestoreArchive() {
_(Stacks in Archive)_:
- _(Select a backup archive above to see its contents.)_ + _(Select a backup archive above to see its contents.)_
diff --git a/source/compose.manager/php/backup_functions.php b/source/compose.manager/php/backup_functions.php index 09d2d92..da5a6ca 100644 --- a/source/compose.manager/php/backup_functions.php +++ b/source/compose.manager/php/backup_functions.php @@ -102,7 +102,7 @@ function createBackup() $sizeBytes = filesize($archivePath); $sizeHuman = formatBytes($sizeBytes); - logger("Backup created: {$archiveName} ({$sizeHuman}, " . count($stacks) . " stacks)"); + // Note: detailed logging is handled by the caller (exec.php or backup_cron.sh) return [ 'result' => 'success', @@ -248,7 +248,6 @@ function restoreStacks($archivePath, $stacks) if ($exitCode === 0) { $restored[] = $stack; - logger("Restored stack: {$stack}"); } else { $errors[] = $stack . ' (exit ' . $exitCode . ')'; } @@ -258,7 +257,7 @@ function restoreStacks($archivePath, $stacks) 'result' => empty($errors) ? 'success' : (empty($restored) ? 'error' : 'warning'), 'restored' => $restored, 'errors' => $errors, - 'message' => count($restored) . ' stack(s) restored successfully.' + 'message' => count($restored) . ' stack(s) restored successfully: ' . implode(', ', $restored) ]; if (!empty($errors)) { @@ -270,20 +269,40 @@ function restoreStacks($archivePath, $stacks) /** * Install or remove the backup cron job. + * Uses root's crontab directly for maximum compatibility with Unraid's cron daemon. */ function updateBackupCron() { $cfg = parse_plugin_cfg('compose.manager'); - $cronFile = '/etc/cron.d/compose-manager-backup'; $script = '/usr/local/emhttp/plugins/compose.manager/scripts/backup_cron.sh'; + $marker = '#compose-manager-backup'; + + // Also clean up old /etc/cron.d file if it exists from previous versions + $oldCronFile = '/etc/cron.d/compose-manager-backup'; + if (file_exists($oldCronFile)) { + @unlink($oldCronFile); + } + + // Read current crontab, stripping any existing compose-manager-backup lines + $existing = ''; + exec('crontab -l 2>/dev/null', $lines, $rc); + if ($rc === 0 && !empty($lines)) { + $filtered = array_filter($lines, function($line) use ($marker) { + return strpos($line, $marker) === false; + }); + $existing = implode("\n", $filtered); + // Ensure it ends with a newline + $existing = rtrim($existing) . "\n"; + } $enabled = ($cfg['BACKUP_SCHEDULE_ENABLED'] ?? 'false') === 'true'; if (!$enabled) { - // Remove cron job if it exists - if (file_exists($cronFile)) { - @unlink($cronFile); - } + // Write back crontab without our line + $tmpFile = '/tmp/compose-manager-crontab.' . getmypid(); + file_put_contents($tmpFile, $existing); + exec("crontab {$tmpFile}"); + @unlink($tmpFile); return; } @@ -291,21 +310,23 @@ function updateBackupCron() $time = $cfg['BACKUP_SCHEDULE_TIME'] ?? '03:00'; $dayOfWeek = $cfg['BACKUP_SCHEDULE_DAY'] ?? '1'; // Monday - // Parse time + // Parse time - use directly as cron runs in server's local timezone $parts = explode(':', $time); $hour = isset($parts[0]) ? intval($parts[0]) : 3; $minute = isset($parts[1]) ? intval($parts[1]) : 0; if ($frequency === 'weekly') { - $cronLine = "{$minute} {$hour} * * {$dayOfWeek} root {$script} >/dev/null 2>&1"; + $cronLine = "{$minute} {$hour} * * {$dayOfWeek} {$script} {$marker}"; } else { // daily - $cronLine = "{$minute} {$hour} * * * root {$script} >/dev/null 2>&1"; + $cronLine = "{$minute} {$hour} * * * {$script} {$marker}"; } - file_put_contents($cronFile, $cronLine . "\n"); - // Ensure correct permissions - chmod($cronFile, 0644); + // Write updated crontab + $tmpFile = '/tmp/compose-manager-crontab.' . getmypid(); + file_put_contents($tmpFile, $existing . $cronLine . "\n"); + exec("crontab {$tmpFile}"); + @unlink($tmpFile); } /** diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index 366fc88..562d378 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -8,11 +8,11 @@ case 'addStack': #Create indirect $indirect = isset($_POST['stackPath']) ? urldecode(($_POST['stackPath'])) : ""; - if ( !empty($indirect) ) { - if ( !is_dir($indirect) ) { - exec("mkdir -p ".escapeshellarg($indirect)); - if( !is_dir($indirect) ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Failed to create stack directory.' ] ); + if (!empty($indirect)) { + if (!is_dir($indirect)) { + exec("mkdir -p " . escapeshellarg($indirect)); + if (!is_dir($indirect)) { + echo json_encode(['result' => 'error', 'message' => 'Failed to create stack directory.']); break; } } @@ -22,47 +22,47 @@ #Create stack folder $stackName = isset($_POST['stackName']) ? urldecode(($_POST['stackName'])) : ""; - $folderName = str_replace('"',"",$stackName); - $folderName = str_replace("'","",$folderName); - $folderName = str_replace("&","",$folderName); - $folderName = str_replace("(","",$folderName); - $folderName = str_replace(")","",$folderName); + $folderName = str_replace('"', "", $stackName); + $folderName = str_replace("'", "", $folderName); + $folderName = str_replace("&", "", $folderName); + $folderName = str_replace("(", "", $folderName); + $folderName = str_replace(")", "", $folderName); $folderName = preg_replace("/ {2,}/", " ", $folderName); $folderName = preg_replace("/\s/", "_", $folderName); $folder = "$compose_root/$folderName"; - while ( true ) { - if ( is_dir($folder) ) { - $folder .= mt_rand(); - } else { - break; - } + while (true) { + if (is_dir($folder)) { + $folder .= mt_rand(); + } else { + break; + } } - exec("mkdir -p ".escapeshellarg($folder)); - if( !is_dir($folder) ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Failed to create stack directory.' ] ); + exec("mkdir -p " . escapeshellarg($folder)); + if (!is_dir($folder)) { + echo json_encode(['result' => 'error', 'message' => 'Failed to create stack directory.']); break; } #Create stack files - if ( !empty($indirect) ) { - file_put_contents("$folder/indirect",$indirect); - if ( !is_file("$indirect/docker-compose.yml") ) { - file_put_contents("$indirect/docker-compose.yml","services:\n"); + if (!empty($indirect)) { + file_put_contents("$folder/indirect", $indirect); + if (!is_file("$indirect/docker-compose.yml")) { + file_put_contents("$indirect/docker-compose.yml", "services:\n"); } } else { - file_put_contents("$folder/docker-compose.yml","services:\n"); + file_put_contents("$folder/docker-compose.yml", "services:\n"); } - + // Create initial override file if it doesn't exist (for UI labels) $overrideFile = "$folder/docker-compose.override.yml"; - if ( !is_file($overrideFile) ) { + if (!is_file($overrideFile)) { $overrideContent = "# Override file for UI labels (icon, webui, shell)\n"; $overrideContent .= "# This file is managed by Compose Manager\n"; $overrideContent .= "services: {}\n"; file_put_contents($overrideFile, $overrideContent); } - file_put_contents("$folder/name",$stackName); + file_put_contents("$folder/name", $stackName); // Save description if provided $stackDesc = isset($_POST['stackDesc']) ? urldecode(($_POST['stackDesc'])) : ""; @@ -72,45 +72,45 @@ // Return project info for opening the editor $projectName = basename($folder); - echo json_encode( [ 'result' => 'success', 'message' => '', 'project' => $folder, 'projectName' => $projectName ] ); + echo json_encode(['result' => 'success', 'message' => '', 'project' => $folder, 'projectName' => $projectName]); break; case 'deleteStack': $stackName = isset($_POST['stackName']) ? urldecode(($_POST['stackName'])) : ""; - if ( ! $stackName ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); - break; + if (! $stackName) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); + break; } $folderName = "$compose_root/$stackName"; $filesRemain = is_file("$folderName/indirect") ? file_get_contents("$folderName/indirect") : ""; - exec("rm -rf ".escapeshellarg($folderName)); - if ( !empty($filesRemain) ){ - echo json_encode( [ 'result' => 'warning', 'message' => $filesRemain ] ); + exec("rm -rf " . escapeshellarg($folderName)); + if (!empty($filesRemain)) { + echo json_encode(['result' => 'warning', 'message' => $filesRemain]); } else { - echo json_encode( [ 'result' => 'success', 'message' => '' ] ); + echo json_encode(['result' => 'success', 'message' => '']); } break; case 'changeName': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; $newName = isset($_POST['newName']) ? urldecode(($_POST['newName'])) : ""; - file_put_contents("$compose_root/$script/name",trim($newName)); - echo json_encode( [ 'result' => 'success', 'message' => '' ] ); + file_put_contents("$compose_root/$script/name", trim($newName)); + echo json_encode(['result' => 'success', 'message' => '']); break; case 'changeDesc': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; $newDesc = isset($_POST['newDesc']) ? urldecode(($_POST['newDesc'])) : ""; - file_put_contents("$compose_root/$script/description",trim($newDesc)); - echo json_encode( [ 'result' => 'success', 'message' => '' ] ); + file_put_contents("$compose_root/$script/description", trim($newDesc)); + echo json_encode(['result' => 'success', 'message' => '']); break; case 'getDescription': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } $fileName = "$compose_root/$script/description"; $fileContents = is_file($fileName) ? file_get_contents($fileName) : ""; $fileContents = str_replace("\r", "", $fileContents); - echo json_encode( [ 'result' => 'success', 'content' => $fileContents ] ); + echo json_encode(['result' => 'success', 'content' => $fileContents]); break; case 'getYml': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; @@ -118,27 +118,27 @@ $fileName = "docker-compose.yml"; $scriptContents = file_get_contents("$basePath/$fileName"); - $scriptContents = str_replace("\r","",$scriptContents); - if ( ! $scriptContents ) { + $scriptContents = str_replace("\r", "", $scriptContents); + if (! $scriptContents) { $scriptContents = "services:\n"; } - echo json_encode( [ 'result' => 'success', 'fileName' => "$basePath/$fileName", 'content' => $scriptContents ] ); + echo json_encode(['result' => 'success', 'fileName' => "$basePath/$fileName", 'content' => $scriptContents]); break; case 'getEnv': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; $basePath = getPath("$compose_root/$script"); $fileName = "$basePath/.env"; - if ( is_file("$basePath/envpath") ) { + if (is_file("$basePath/envpath")) { $fileName = file_get_contents("$basePath/envpath"); - $fileName = str_replace("\r","",$fileName); + $fileName = str_replace("\r", "", $fileName); } $scriptContents = is_file("$fileName") ? file_get_contents("$fileName") : ""; - $scriptContents = str_replace("\r","",$scriptContents); - if ( ! $scriptContents ) { + $scriptContents = str_replace("\r", "", $scriptContents); + if (! $scriptContents) { $scriptContents = "\n"; } - echo json_encode( [ 'result' => 'success', 'fileName' => "$fileName", 'content' => $scriptContents ] ); + echo json_encode(['result' => 'success', 'fileName' => "$fileName", 'content' => $scriptContents]); break; case 'getOverride': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; @@ -146,19 +146,19 @@ $fileName = "docker-compose.override.yml"; $scriptContents = is_file("$basePath/$fileName") ? file_get_contents("$basePath/$fileName") : ""; - $scriptContents = str_replace("\r","",$scriptContents); - if ( ! $scriptContents ) { + $scriptContents = str_replace("\r", "", $scriptContents); + if (! $scriptContents) { $scriptContents = ""; } - echo json_encode( [ 'result' => 'success', 'fileName' => "$basePath/$fileName", 'content' => $scriptContents ] ); + echo json_encode(['result' => 'success', 'fileName' => "$basePath/$fileName", 'content' => $scriptContents]); break; case 'saveYml': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; $scriptContents = isset($_POST['scriptContents']) ? $_POST['scriptContents'] : ""; $basePath = getPath("$compose_root/$script"); $fileName = "docker-compose.yml"; - - file_put_contents("$basePath/$fileName",$scriptContents); + + file_put_contents("$basePath/$fileName", $scriptContents); echo "$basePath/$fileName saved"; break; case 'saveEnv': @@ -166,12 +166,12 @@ $scriptContents = isset($_POST['scriptContents']) ? $_POST['scriptContents'] : ""; $basePath = getPath("$compose_root/$script"); $fileName = "$basePath/.env"; - if ( is_file("$basePath/envpath") ) { + if (is_file("$basePath/envpath")) { $fileName = file_get_contents("$basePath/envpath"); - $fileName = str_replace("\r","",$fileName); + $fileName = str_replace("\r", "", $fileName); } - file_put_contents("$fileName",$scriptContents); + file_put_contents("$fileName", $scriptContents); echo "$fileName saved"; break; case 'saveOverride': @@ -180,27 +180,30 @@ $basePath = "$compose_root/$script"; $fileName = "docker-compose.override.yml"; - file_put_contents("$basePath/$fileName",$scriptContents); + file_put_contents("$basePath/$fileName", $scriptContents); echo "$basePath/$fileName saved"; break; case 'updateAutostart': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } $autostart = isset($_POST['autostart']) ? urldecode(($_POST['autostart'])) : "false"; $fileName = "$compose_root/$script/autostart"; - if ( is_file($fileName) ) { - exec("rm ".escapeshellarg($fileName)); + if (is_file($fileName)) { + exec("rm " . escapeshellarg($fileName)); } - file_put_contents($fileName,$autostart); - echo json_encode( [ 'result' => 'success', 'message' => '' ] ); + file_put_contents($fileName, $autostart); + echo json_encode(['result' => 'success', 'message' => '']); break; case 'runPatch': $cmd = isset($_POST['cmd']) ? $_POST['cmd'] : 'apply'; - if (!in_array($cmd, ['apply','remove'])) { echo json_encode(['result'=>'error','message'=>'Invalid command']); break; } + if (!in_array($cmd, ['apply', 'remove'])) { + echo json_encode(['result' => 'error', 'message' => 'Invalid command']); + break; + } $script = "$plugin_root/scripts/patch.sh"; // Quote each argument to preserve spaces and special characters and avoid the fragility of escapeshellcmd() $fullcmd = escapeshellarg($script) . ' ' . escapeshellarg($cmd) . ' ' . escapeshellarg('--verbose') . ' 2>&1'; @@ -208,7 +211,7 @@ // Save a copy to plugin log file $logfile = "/boot/config/plugins/compose.manager/patch_last_run.log"; $ts = date('c'); - $entry = "[{$ts}] runPatch {$cmd} exit={$rc}\n" . implode("\n",$output) . "\n\n"; + $entry = "[{$ts}] runPatch {$cmd} exit={$rc}\n" . implode("\n", $output) . "\n\n"; @file_put_contents($logfile, $entry, FILE_APPEND); // If debug logging enabled, send to syslog $cfg = parse_plugin_cfg($sName); @@ -254,56 +257,56 @@ break; case 'setEnvPath': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } $fileContent = isset($_POST['envPath']) ? urldecode(($_POST['envPath'])) : ""; $fileName = "$compose_root/$script/envpath"; - if ( is_file($fileName) ) { - exec("rm ".escapeshellarg($fileName)); + if (is_file($fileName)) { + exec("rm " . escapeshellarg($fileName)); } - if ( isset($fileContent) && !empty($fileContent) ) { - file_put_contents($fileName,$fileContent); + if (isset($fileContent) && !empty($fileContent)) { + file_put_contents($fileName, $fileContent); } - echo json_encode( [ 'result' => 'success', 'message' => '' ] ); + echo json_encode(['result' => 'success', 'message' => '']); break; case 'getEnvPath': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } $fileName = "$compose_root/$script/envpath"; $fileContents = is_file("$fileName") ? file_get_contents("$fileName") : ""; - $fileContents = str_replace("\r","",$fileContents); - if ( ! $fileContents ) { + $fileContents = str_replace("\r", "", $fileContents); + if (! $fileContents) { $fileContents = ""; } - echo json_encode( [ 'result' => 'success', 'fileName' => "$fileName", 'content' => $fileContents ] ); + echo json_encode(['result' => 'success', 'fileName' => "$fileName", 'content' => $fileContents]); break; case 'getStackSettings': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } // Get env path $envPathFile = "$compose_root/$script/envpath"; $envPath = is_file($envPathFile) ? trim(file_get_contents($envPathFile)) : ""; - + // Get icon URL $iconUrlFile = "$compose_root/$script/icon_url"; $iconUrl = is_file($iconUrlFile) ? trim(file_get_contents($iconUrlFile)) : ""; - + // Get WebUI URL (stack-level) $webuiUrlFile = "$compose_root/$script/webui_url"; $webuiUrl = is_file($webuiUrlFile) ? trim(file_get_contents($webuiUrlFile)) : ""; - + // Get default profile $defaultProfileFile = "$compose_root/$script/default_profile"; $defaultProfile = is_file($defaultProfileFile) ? trim(file_get_contents($defaultProfileFile)) : ""; - + // Get available profiles from the profiles file $profilesFile = "$compose_root/$script/profiles"; $availableProfiles = []; @@ -313,7 +316,7 @@ $availableProfiles = $profilesData; } } - + echo json_encode([ 'result' => 'success', 'envPath' => $envPath, @@ -325,11 +328,11 @@ break; case 'setStackSettings': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - + // Set env path $envPath = isset($_POST['envPath']) ? trim($_POST['envPath']) : ""; $envPathFile = "$compose_root/$script/envpath"; @@ -338,7 +341,7 @@ } else { file_put_contents($envPathFile, $envPath); } - + // Set icon URL $iconUrl = isset($_POST['iconUrl']) ? trim($_POST['iconUrl']) : ""; $iconUrlFile = "$compose_root/$script/icon_url"; @@ -352,7 +355,7 @@ } file_put_contents($iconUrlFile, $iconUrl); } - + // Set WebUI URL (stack-level) $webuiUrl = isset($_POST['webuiUrl']) ? trim($_POST['webuiUrl']) : ""; $webuiUrlFile = "$compose_root/$script/webui_url"; @@ -366,7 +369,7 @@ } file_put_contents($webuiUrlFile, $webuiUrl); } - + // Set default profile $defaultProfile = isset($_POST['defaultProfile']) ? trim($_POST['defaultProfile']) : ""; $defaultProfileFile = "$compose_root/$script/default_profile"; @@ -375,7 +378,7 @@ } else { file_put_contents($defaultProfileFile, $defaultProfile); } - + echo json_encode(['result' => 'success', 'message' => 'Settings saved']); break; case 'saveProfiles': @@ -384,56 +387,56 @@ $basePath = "$compose_root/$script"; $fileName = "$basePath/profiles"; - if( $scriptContents == "[]" ) { - if ( is_file($fileName) ) { - exec("rm ".escapeshellarg($fileName)); - echo json_encode( [ 'result' => 'success', 'message' => "$fileName deleted" ] ); + if ($scriptContents == "[]") { + if (is_file($fileName)) { + exec("rm " . escapeshellarg($fileName)); + echo json_encode(['result' => 'success', 'message' => "$fileName deleted"]); } - echo json_encode( [ 'result' => 'success', 'message' => '' ] ); + echo json_encode(['result' => 'success', 'message' => '']); break; } - file_put_contents("$fileName",$scriptContents); - echo json_encode( [ 'result' => 'success', 'message' => "$fileName saved" ] ); + file_put_contents("$fileName", $scriptContents); + echo json_encode(['result' => 'success', 'message' => "$fileName saved"]); break; case 'getStackContainers': $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - + // Get the project name (sanitized) $projectName = $script; - if ( is_file("$compose_root/$script/name") ) { + if (is_file("$compose_root/$script/name")) { $projectName = trim(file_get_contents("$compose_root/$script/name")); } $projectName = sanitizeStr($projectName); - + // Get containers for this compose project using docker compose ps $basePath = getPath("$compose_root/$script"); $composeFile = "$basePath/docker-compose.yml"; $overrideFile = "$compose_root/$script/docker-compose.override.yml"; - + $files = "-f " . escapeshellarg($composeFile); - if ( is_file($overrideFile) ) { + if (is_file($overrideFile)) { $files .= " -f " . escapeshellarg($overrideFile); } - + $envFile = ""; - if ( is_file("$compose_root/$script/envpath") ) { + if (is_file("$compose_root/$script/envpath")) { $envPath = trim(file_get_contents("$compose_root/$script/envpath")); - if ( is_file($envPath) ) { + if (is_file($envPath)) { $envFile = "--env-file " . escapeshellarg($envPath); } } - + // Get container details in JSON format // Include --all so exited/stopped containers are returned as well $cmd = "docker compose $files $envFile -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; $output = shell_exec($cmd); - + // Cache network drivers for resolving network types (bridge vs macvlan/ipvlan) $networkDrivers = []; $netListOutput = shell_exec("docker network ls --format '{{.Name}}\t{{.Driver}}' 2>/dev/null"); @@ -473,7 +476,7 @@ $container['Image'] = $inspect['Config']['Image'] ?? ''; $container['Created'] = $inspect['Created'] ?? ''; $container['StartedAt'] = $inspect['State']['StartedAt'] ?? ''; - + // Get ports (raw bindings - IP resolved below after network detection) $ports = []; $portBindings = $inspect['HostConfig']['PortBindings'] ?? []; @@ -487,7 +490,7 @@ } } } - + // Get volumes $volumes = []; $mounts = $inspect['Mounts'] ?? []; @@ -500,7 +503,7 @@ } } $container['Volumes'] = $volumes; - + // Get network info (include driver for IP resolution) $networks = []; $networkSettings = $inspect['NetworkSettings']['Networks'] ?? []; @@ -512,13 +515,13 @@ ]; } $container['Networks'] = $networks; - + // Get labels for WebUI $labels = $inspect['Config']['Labels'] ?? []; $webUITemplate = $labels[$docker_label_webui] ?? ''; $container['Icon'] = $labels[$docker_label_icon] ?? ''; $container['Shell'] = $labels[$docker_label_shell] ?? '/bin/sh'; - + // Resolve WebUI URL server-side (matching Unraid's DockerClient logic) // Determine the NetworkMode $networkMode = $inspect['HostConfig']['NetworkMode'] ?? 'bridge'; @@ -526,7 +529,7 @@ if (strpos($networkMode, ':') !== false) { [$networkMode] = explode(':', $networkMode); } - + $container['WebUI'] = ''; // Resolve IP — Unraid logic: // host mode → host IP @@ -535,8 +538,10 @@ $resolvedIP = $hostIP; if ($networkMode === 'host') { $resolvedIP = $hostIP; - } elseif (isset($networkDrivers[$networkMode]) && - in_array($networkDrivers[$networkMode], ['macvlan', 'ipvlan'])) { + } elseif ( + isset($networkDrivers[$networkMode]) && + in_array($networkDrivers[$networkMode], ['macvlan', 'ipvlan']) + ) { // Use container's own routable IP $firstNet = reset($networkSettings); $containerIP = $firstNet['IPAddress'] ?? ''; @@ -554,7 +559,7 @@ if (!empty($webUITemplate) && $hostIP) { $resolvedURL = preg_replace('%\[IP\]%i', $resolvedIP, $webUITemplate); - + // Resolve [PORT:xxxx] — find host-mapped port for the container port if (preg_match('%\[PORT:(\d+)\]%i', $resolvedURL, $portMatch)) { $configPort = $portMatch[1]; @@ -574,7 +579,7 @@ } $container['WebUI'] = $resolvedURL; } - + // Get update status from saved status file $updateStatusFile = "/var/lib/docker/unraid-update-status.json"; $updateStatus = []; @@ -588,11 +593,11 @@ } // Also try without registry prefix $imageNameShort = preg_replace('/^[^\/]+\//', '', $imageName); - + $container['UpdateStatus'] = 'unknown'; $container['LocalSha'] = ''; $container['RemoteSha'] = ''; - + // Check both full name and short name $checkNames = [$imageName, $imageNameShort]; foreach ($updateStatus as $key => $status) { @@ -618,78 +623,78 @@ } } } - - echo json_encode([ 'result' => 'success', 'containers' => $containers, 'projectName' => $projectName ]); + + echo json_encode(['result' => 'success', 'containers' => $containers, 'projectName' => $projectName]); break; case 'containerAction': $containerName = isset($_POST['container']) ? urldecode(($_POST['container'])) : ""; $containerAction = isset($_POST['containerAction']) ? urldecode(($_POST['containerAction'])) : ""; - - if ( ! $containerName || ! $containerAction ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Container or action not specified.' ] ); + + if (! $containerName || ! $containerAction) { + echo json_encode(['result' => 'error', 'message' => 'Container or action not specified.']); break; } - + $allowedActions = ['start', 'stop', 'restart', 'pause', 'unpause']; - if ( !in_array($containerAction, $allowedActions) ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Invalid action.' ] ); + if (!in_array($containerAction, $allowedActions)) { + echo json_encode(['result' => 'error', 'message' => 'Invalid action.']); break; } - + $cmd = "docker " . escapeshellarg($containerAction) . " " . escapeshellarg($containerName) . " 2>&1"; $output = shell_exec($cmd); - - echo json_encode([ 'result' => 'success', 'message' => trim($output) ]); + + echo json_encode(['result' => 'success', 'message' => trim($output)]); break; case 'checkStackUpdates': // Check for updates for all containers in a compose stack $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - + // Include Docker manager classes for update checking require_once("/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php"); - + // Get the project name (sanitized) $projectName = $script; - if ( is_file("$compose_root/$script/name") ) { + if (is_file("$compose_root/$script/name")) { $projectName = trim(file_get_contents("$compose_root/$script/name")); } $projectName = sanitizeStr($projectName); - + // Get containers for this compose project $basePath = getPath("$compose_root/$script"); $composeFile = "$basePath/docker-compose.yml"; $overrideFile = "$compose_root/$script/docker-compose.override.yml"; - + $files = "-f " . escapeshellarg($composeFile); - if ( is_file($overrideFile) ) { + if (is_file($overrideFile)) { $files .= " -f " . escapeshellarg($overrideFile); } - + $envFile = ""; - if ( is_file("$compose_root/$script/envpath") ) { + if (is_file("$compose_root/$script/envpath")) { $envPath = trim(file_get_contents("$compose_root/$script/envpath")); - if ( is_file($envPath) ) { + if (is_file($envPath)) { $envFile = "--env-file " . escapeshellarg($envPath); } } - + // Get container images // Include --all to ensure non-running containers are considered for update checks $cmd = "docker compose $files $envFile -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; $output = shell_exec($cmd); - + $updateResults = []; $DockerUpdate = new DockerUpdate(); - + // Load the update status file to get SHA values $dockerManPaths = [ 'update-status' => "/var/lib/docker/unraid-update-status.json" ]; - + if ($output) { $lines = explode("\n", trim($output)); foreach ($lines as $line) { @@ -698,11 +703,11 @@ if ($container) { $containerName = $container['Name'] ?? ''; $image = $container['Image'] ?? ''; - + if ($containerName && $image) { // Normalize image name (strip docker.io/ prefix, @sha256: digest, add library/ for official images) $image = normalizeImageForUpdateCheck($image); - + // Clear cached local SHA to force re-inspection of the actual image // This is needed because Unraid's reloadUpdateStatus uses cached values // which can be stale after docker compose pull @@ -712,16 +717,16 @@ $updateStatusData[$image]['local'] = null; DockerUtil::saveJSON($dockerManPaths['update-status'], $updateStatusData); } - + // Check update status using Unraid's DockerUpdate class $DockerUpdate->reloadUpdateStatus($image); $updateStatus = $DockerUpdate->getUpdateStatus($image); - + // Get SHA values from the status file $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); $localSha = ''; $remoteSha = ''; - + if (isset($updateStatusData[$image])) { $localSha = $updateStatusData[$image]['local'] ?? ''; $remoteSha = $updateStatusData[$image]['remote'] ?? ''; @@ -733,11 +738,11 @@ $remoteSha = substr($remoteSha, 7, 12); } } - + // null = unknown, true = up to date, false = update available $hasUpdate = ($updateStatus === false); $statusText = ($updateStatus === null) ? 'unknown' : ($updateStatus ? 'up-to-date' : 'update-available'); - + $updateResults[] = [ 'container' => $containerName, 'image' => $image, @@ -751,9 +756,9 @@ } } } - - echo json_encode([ 'result' => 'success', 'updates' => $updateResults, 'projectName' => $projectName ]); - + + echo json_encode(['result' => 'success', 'updates' => $updateResults, 'projectName' => $projectName]); + // Save the update status for this stack $composeUpdateStatusFile = "/boot/config/plugins/compose.manager/update-status.json"; $savedStatus = []; @@ -762,7 +767,9 @@ } $savedStatus[$script] = [ 'projectName' => $projectName, - 'hasUpdate' => count(array_filter($updateResults, function($r) { return $r['hasUpdate']; })) > 0, + 'hasUpdate' => count(array_filter($updateResults, function ($r) { + return $r['hasUpdate']; + })) > 0, 'containers' => $updateResults, 'lastChecked' => time() ]; @@ -771,30 +778,30 @@ case 'checkAllStacksUpdates': // Check for updates for all compose stacks require_once("/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php"); - + $allUpdates = []; $DockerUpdate = new DockerUpdate(); - + // Path to update status file $dockerManPaths = [ 'update-status' => "/var/lib/docker/unraid-update-status.json" ]; - + // Iterate through all stacks $stacks = glob("$compose_root/*/docker-compose.yml", GLOB_NOSORT); $indirectStacks = glob("$compose_root/*/indirect", GLOB_NOSORT); - + foreach ($indirectStacks as $indirect) { $indirectPath = file_get_contents($indirect); if (is_file("$indirectPath/docker-compose.yml")) { $stacks[] = "$indirectPath/docker-compose.yml"; } } - + foreach ($stacks as $composeFile) { $stackDir = dirname($composeFile); $stackName = basename(dirname($composeFile)); - + // For indirect stacks, find the actual stack folder foreach (glob("$compose_root/*/indirect", GLOB_NOSORT) as $indirect) { if (trim(file_get_contents($indirect)) == $stackDir) { @@ -802,29 +809,29 @@ break; } } - + // Get project name $projectName = $stackName; - if ( is_file("$compose_root/$stackName/name") ) { + if (is_file("$compose_root/$stackName/name")) { $projectName = trim(file_get_contents("$compose_root/$stackName/name")); } $projectName = sanitizeStr($projectName); - + // Get containers $files = "-f " . escapeshellarg($composeFile); $overrideFile = "$compose_root/$stackName/docker-compose.override.yml"; - if ( is_file($overrideFile) ) { + if (is_file($overrideFile)) { $files .= " -f " . escapeshellarg($overrideFile); } - + // Include --all so we can detect stacks that have stopped containers $cmd = "docker compose $files -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; $output = shell_exec($cmd); - + $stackUpdates = []; $hasStackUpdate = false; $isRunning = false; - + if ($output) { $lines = explode("\n", trim($output)); foreach ($lines as $line) { @@ -834,17 +841,17 @@ $containerName = $container['Name'] ?? ''; $image = $container['Image'] ?? ''; $state = $container['State'] ?? ''; - + // Check if any container is running if ($state === 'running') { $isRunning = true; } - + // Only check updates for running containers if ($containerName && $image && $state === 'running') { // Normalize image name (strip docker.io/ prefix, @sha256: digest, add library/ for official images) $image = normalizeImageForUpdateCheck($image); - + // Clear cached local SHA to force re-inspection of the actual image // This is needed because Unraid's reloadUpdateStatus uses cached values // which can be stale after docker compose pull @@ -854,15 +861,15 @@ $updateStatusData[$image]['local'] = null; DockerUtil::saveJSON($dockerManPaths['update-status'], $updateStatusData); } - + $DockerUpdate->reloadUpdateStatus($image); $updateStatus = $DockerUpdate->getUpdateStatus($image); - + // Get SHA values from the status file $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); $localSha = ''; $remoteSha = ''; - + if (isset($updateStatusData[$image])) { $localSha = $updateStatusData[$image]['local'] ?? ''; $remoteSha = $updateStatusData[$image]['remote'] ?? ''; @@ -874,10 +881,10 @@ $remoteSha = substr($remoteSha, 7, 12); } } - + $hasUpdate = ($updateStatus === false); if ($hasUpdate) $hasStackUpdate = true; - + $stackUpdates[] = [ 'container' => $containerName, 'image' => $image, @@ -891,7 +898,7 @@ } } } - + $allUpdates[$stackName] = [ 'projectName' => $projectName, 'hasUpdate' => $hasStackUpdate, @@ -899,7 +906,7 @@ 'containers' => $stackUpdates ]; } - + // Save the update status for all stacks $composeUpdateStatusFile = "/boot/config/plugins/compose.manager/update-status.json"; $savedStatus = $allUpdates; @@ -907,8 +914,8 @@ $stackData['lastChecked'] = time(); } file_put_contents($composeUpdateStatusFile, json_encode($savedStatus, JSON_PRETTY_PRINT)); - - echo json_encode([ 'result' => 'success', 'stacks' => $allUpdates ]); + + echo json_encode(['result' => 'success', 'stacks' => $allUpdates]); break; case 'getSavedUpdateStatus': // Load saved update status from file @@ -916,50 +923,50 @@ if (is_file($composeUpdateStatusFile)) { $savedStatus = json_decode(file_get_contents($composeUpdateStatusFile), true); if ($savedStatus) { - echo json_encode([ 'result' => 'success', 'stacks' => $savedStatus ]); + echo json_encode(['result' => 'success', 'stacks' => $savedStatus]); } else { - echo json_encode([ 'result' => 'success', 'stacks' => [] ]); + echo json_encode(['result' => 'success', 'stacks' => []]); } } else { - echo json_encode([ 'result' => 'success', 'stacks' => [] ]); + echo json_encode(['result' => 'success', 'stacks' => []]); } break; case 'getLogs': // Get compose-related log entries from syslog $lines = isset($_POST['lines']) ? intval($_POST['lines']) : 100; $filter = isset($_POST['filter']) ? trim($_POST['filter']) : ''; - + // Sanitize inputs $lines = max(10, min(5000, $lines)); // Limit between 10 and 5000 lines - + // Build grep command to find compose-related entries // Look for: compose, docker compose, compose.manager entries $grepPattern = 'compose\\|docker compose\\|compose.manager\\|compose.sh'; - + // Read from syslog $syslogFile = '/var/log/syslog'; if (!is_file($syslogFile)) { $syslogFile = '/var/log/messages'; } - + if (!is_file($syslogFile)) { - echo json_encode([ 'result' => 'error', 'message' => 'Syslog file not found' ]); + echo json_encode(['result' => 'error', 'message' => 'Syslog file not found']); break; } - + // Use grep to find relevant entries and tail to limit output $cmd = "grep -i " . escapeshellarg($grepPattern) . " " . escapeshellarg($syslogFile); - + // Apply additional filter if provided if (!empty($filter)) { $cmd .= " | grep -i " . escapeshellarg($filter); } - + $cmd .= " | tail -n " . escapeshellarg($lines); - + $output = []; exec($cmd, $output, $returnCode); - + // Parse log entries $logs = []; foreach ($output as $line) { @@ -982,22 +989,22 @@ ]; } } - - echo json_encode([ - 'result' => 'success', + + echo json_encode([ + 'result' => 'success', 'logs' => $logs, 'count' => count($logs) ]); break; - + case 'checkStackLock': // Check if a stack is currently locked (operation in progress) $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - + $lockInfo = isStackLocked($script); if ($lockInfo) { echo json_encode([ @@ -1012,24 +1019,24 @@ ]); } break; - + case 'getStackResult': // Get the last operation result for a stack $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - if ( ! $script ) { - echo json_encode( [ 'result' => 'error', 'message' => 'Stack not specified.' ] ); + if (! $script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - + $stackPath = "$compose_root/$script"; $lastResult = getStackLastResult($stackPath); - + echo json_encode([ 'result' => 'success', 'lastResult' => $lastResult ]); break; - + case 'markStackForRecheck': // Mark one or more stacks for recheck after update // This persists across page reloads so the recheck happens even if page refreshes @@ -1041,22 +1048,22 @@ echo json_encode(['result' => 'error', 'message' => 'No stacks specified.']); break; } - + $pendingRecheckFile = "/boot/config/plugins/compose.manager/pending-recheck.json"; $pending = []; if (is_file($pendingRecheckFile)) { $pending = json_decode(file_get_contents($pendingRecheckFile), true) ?: []; } - + // Add stacks to pending list with timestamp foreach ($stacks as $stackName) { $pending[$stackName] = time(); } - + file_put_contents($pendingRecheckFile, json_encode($pending, JSON_PRETTY_PRINT)); echo json_encode(['result' => 'success', 'pending' => array_keys($pending)]); break; - + case 'getPendingRecheckStacks': // Get list of stacks that need recheck $pendingRecheckFile = "/boot/config/plugins/compose.manager/pending-recheck.json"; @@ -1066,7 +1073,7 @@ } echo json_encode(['result' => 'success', 'pending' => $pending]); break; - + case 'clearStackRecheck': // Clear recheck flag for one or more stacks $stacks = isset($_POST['stacks']) ? $_POST['stacks'] : ""; @@ -1077,25 +1084,31 @@ echo json_encode(['result' => 'error', 'message' => 'No stacks specified.']); break; } - + $pendingRecheckFile = "/boot/config/plugins/compose.manager/pending-recheck.json"; $pending = []; if (is_file($pendingRecheckFile)) { $pending = json_decode(file_get_contents($pendingRecheckFile), true) ?: []; } - + // Remove stacks from pending list foreach ($stacks as $stackName) { unset($pending[$stackName]); } - + file_put_contents($pendingRecheckFile, json_encode($pending, JSON_PRETTY_PRINT)); echo json_encode(['result' => 'success', 'remaining' => array_keys($pending)]); break; case 'createBackup': require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); + exec("logger -t 'compose.manager' " . escapeshellarg("[backup] Manual backup starting...")); $result = createBackup(); + if ($result['result'] === 'success') { + exec("logger -t 'compose.manager' " . escapeshellarg("[backup] Manual backup completed: " . $result['archive'] . " (" . $result['size'] . ", " . $result['stacks'] . " stacks)")); + } else { + exec("logger -t 'compose.manager' " . escapeshellarg("[backup] Manual backup FAILED: " . ($result['message'] ?? 'Unknown error'))); + } echo json_encode($result); break; @@ -1106,6 +1119,53 @@ echo json_encode(['result' => 'success', 'archives' => $archives]); break; + case 'uploadBackup': + require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); + if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { + $errMsg = 'No file uploaded.'; + if (isset($_FILES['file'])) { + $uploadErrors = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit.', + UPLOAD_ERR_FORM_SIZE => 'File exceeds form upload limit.', + UPLOAD_ERR_PARTIAL => 'File only partially uploaded.', + UPLOAD_ERR_NO_FILE => 'No file was uploaded.', + UPLOAD_ERR_NO_TMP_DIR => 'Server missing temp directory.', + UPLOAD_ERR_CANT_WRITE => 'Failed to write to disk.', + ]; + $errMsg = $uploadErrors[$_FILES['file']['error']] ?? 'Upload error code ' . $_FILES['file']['error']; + } + echo json_encode(['result' => 'error', 'message' => $errMsg]); + break; + } + $filename = basename($_FILES['file']['name']); + if (!preg_match('/\.(tar\.gz|tgz)$/i', $filename)) { + echo json_encode(['result' => 'error', 'message' => 'Invalid file type. Only .tar.gz archives are accepted.']); + break; + } + $dest = getBackupDestination(); + if (!is_dir($dest)) { + if (!@mkdir($dest, 0755, true)) { + echo json_encode(['result' => 'error', 'message' => 'Backup destination does not exist and could not be created: ' . $dest]); + break; + } + } + if (!is_writable($dest)) { + echo json_encode(['result' => 'error', 'message' => 'Backup destination is not writable: ' . $dest]); + break; + } + $targetPath = $dest . '/' . $filename; + if (file_exists($targetPath)) { + echo json_encode(['result' => 'error', 'message' => 'Archive "' . $filename . '" already exists in backup destination.']); + break; + } + if (!move_uploaded_file($_FILES['file']['tmp_name'], $targetPath)) { + echo json_encode(['result' => 'error', 'message' => 'Failed to save uploaded file.']); + break; + } + exec("logger -t 'compose.manager' " . escapeshellarg("[backup] Uploaded: $filename")); + echo json_encode(['result' => 'success', 'message' => 'Archive uploaded successfully.', 'archive' => $filename]); + break; + case 'readManifest': require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); $archive = isset($_POST['archive']) ? urldecode($_POST['archive']) : ''; @@ -1134,8 +1194,18 @@ echo json_encode(['result' => 'error', 'message' => 'No stacks selected for restore.']); break; } + exec("logger -t 'compose.manager' " . escapeshellarg("[restore] Restore started from " . basename($archive) . " (" . count($stacks) . " stacks)")); $archivePath = resolveArchivePath($archive); $result = restoreStacks($archivePath, $stacks); + if ($result['result'] === 'error') { + exec("logger -t 'compose.manager' " . escapeshellarg("[restore] Restore FAILED: " . ($result['message'] ?? 'Unknown error'))); + } else { + $restoredList = implode(', ', $result['restored'] ?? []); + exec("logger -t 'compose.manager' " . escapeshellarg("[restore] Restore completed: " . count($result['restored']) . " stacks restored (" . $restoredList . ")")); + if (!empty($result['errors'])) { + exec("logger -t 'compose.manager' " . escapeshellarg("[restore] Restore errors: " . implode(', ', $result['errors']))); + } + } echo json_encode($result); break; @@ -1152,6 +1222,7 @@ break; } @unlink($archivePath); + exec("logger -t 'compose.manager' " . escapeshellarg("[backup] Deleted: " . basename($archive))); echo json_encode(['result' => 'success', 'message' => 'Backup deleted.']); break; @@ -1160,6 +1231,57 @@ updateBackupCron(); echo json_encode(['result' => 'success', 'message' => 'Backup schedule updated.']); break; -} -?> \ No newline at end of file + case 'saveBackupSettings': + // Save backup settings to config file AND update cron + $settings = $_POST['settings'] ?? ''; + if (is_string($settings)) { + $settings = json_decode($settings, true); + } + if (!is_array($settings)) { + echo json_encode(['result' => 'error', 'message' => 'Invalid settings data.']); + break; + } + + // Write settings to config file + $cfgFile = '/boot/config/plugins/compose.manager/compose.manager.cfg'; + $existingCfg = is_file($cfgFile) ? parse_ini_file($cfgFile) : []; + $updatedCfg = array_merge($existingCfg, $settings); + + $lines = []; + foreach ($updatedCfg as $key => $value) { + $lines[] = "$key=\"$value\""; + } + file_put_contents($cfgFile, implode("\n", $lines) . "\n"); + + // Update cron job and log the action + require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); + updateBackupCron(); + + // Log the scheduler status + $enabled = ($settings['BACKUP_SCHEDULE_ENABLED'] ?? 'false') === 'true'; + if ($enabled) { + $freq = $settings['BACKUP_SCHEDULE_FREQUENCY'] ?? 'daily'; + $time = $settings['BACKUP_SCHEDULE_TIME'] ?? '03:00'; + $day = ''; + if ($freq === 'weekly') { + $days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + $dayNum = intval($settings['BACKUP_SCHEDULE_DAY'] ?? '1'); + $day = ' on ' . ($days[$dayNum] ?? 'Monday'); + } + // Convert to 12-hour AM/PM format + $timeParts = explode(':', $time); + $hour = intval($timeParts[0]); + $minute = $timeParts[1]; + $ampm = $hour >= 12 ? 'PM' : 'AM'; + $hour12 = $hour % 12; + if ($hour12 === 0) $hour12 = 12; + $time12 = "{$hour12}:{$minute} {$ampm}"; + exec("logger -t 'compose.manager' " . escapeshellarg("[backup] Scheduler ENABLED: {$freq}{$day} at {$time12}")); + } else { + exec("logger -t 'compose.manager' " . escapeshellarg("[backup] Scheduler DISABLED")); + } + + echo json_encode(['result' => 'success', 'message' => 'Backup settings saved.']); + break; +} diff --git a/source/compose.manager/scripts/backup_cron.sh b/source/compose.manager/scripts/backup_cron.sh index 4584b69..432f7e5 100644 --- a/source/compose.manager/scripts/backup_cron.sh +++ b/source/compose.manager/scripts/backup_cron.sh @@ -19,9 +19,12 @@ result=$(php -r " # Parse result status=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['result'] ?? 'error';") message=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['message'] ?? 'Unknown error';") +archive=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['archive'] ?? '';") +size=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['size'] ?? '';") +stacks=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['stacks'] ?? 0;") if [ "$status" = "success" ]; then - logger -t "$LOG_TAG" "[backup] Scheduled backup completed: $message" + logger -t "$LOG_TAG" "[backup] Scheduled backup completed: $archive ($size, $stacks stacks)" else logger -t "$LOG_TAG" "[backup] Scheduled backup FAILED: $message" fi diff --git a/tests/unit/BackupFunctionsTest.php b/tests/unit/BackupFunctionsTest.php index 066f11b..f16c56b 100644 --- a/tests/unit/BackupFunctionsTest.php +++ b/tests/unit/BackupFunctionsTest.php @@ -522,4 +522,116 @@ public function testRestoreStacksErrorOnEmptyStackList(): void $this->assertEquals('error', $result['result']); $this->assertStringContainsString('No stacks selected', $result['message']); } + + // =========================================== + // updateBackupCron() Tests + // =========================================== + + /** + * @requires OS Linux + */ + public function testUpdateBackupCronCreatesFileWhenEnabled(): void + { + $cronFile = '/tmp/test_compose_cron_' . getmypid(); + $cronDir = dirname($cronFile); + + // Override the cron file location using a mock + $testFunction = function() use ($cronFile, $cronDir) { + $cfg = parse_plugin_cfg('compose.manager'); + $script = '/usr/local/emhttp/plugins/compose.manager/scripts/backup_cron.sh'; + + $enabled = ($cfg['BACKUP_SCHEDULE_ENABLED'] ?? 'false') === 'true'; + + if (!$enabled) { + if (file_exists($cronFile)) { + @unlink($cronFile); + touch($cronDir); + } + return; + } + + $frequency = $cfg['BACKUP_SCHEDULE_FREQUENCY'] ?? 'daily'; + $time = $cfg['BACKUP_SCHEDULE_TIME'] ?? '03:00'; + $dayOfWeek = $cfg['BACKUP_SCHEDULE_DAY'] ?? '1'; + + $parts = explode(':', $time); + $hour = isset($parts[0]) ? intval($parts[0]) : 3; + $minute = isset($parts[1]) ? intval($parts[1]) : 0; + + if ($frequency === 'weekly') { + $cronLine = "{$minute} {$hour} * * {$dayOfWeek} root {$script} >/dev/null 2>&1"; + } else { + $cronLine = "{$minute} {$hour} * * * root {$script} >/dev/null 2>&1"; + } + + file_put_contents($cronFile, $cronLine . "\n"); + chmod($cronFile, 0644); + touch($cronDir); + }; + + // Set backup schedule enabled + FunctionMocks::setPluginConfig('compose.manager', [ + 'BACKUP_SCHEDULE_ENABLED' => 'true', + 'BACKUP_SCHEDULE_FREQUENCY' => 'daily', + 'BACKUP_SCHEDULE_TIME' => '02:30', + ]); + + // Record directory mtime before + $mtimeBefore = filemtime($cronDir); + sleep(1); // Ensure time difference + + // Execute + $testFunction(); + + // Verify cron file created + $this->assertFileExists($cronFile); + $content = file_get_contents($cronFile); + $this->assertStringContainsString('30 2 * * *', $content); + $this->assertStringContainsString('backup_cron.sh', $content); + + // Verify directory was touched (mtime updated) + $mtimeAfter = filemtime($cronDir); + $this->assertGreaterThan($mtimeBefore, $mtimeAfter); + + // Cleanup + @unlink($cronFile); + } + + /** + * @requires OS Linux + */ + public function testUpdateBackupCronRemovesFileWhenDisabled(): void + { + $cronFile = '/tmp/test_compose_cron_disabled_' . getmypid(); + $cronDir = dirname($cronFile); + + // Create a fake cron file + file_put_contents($cronFile, "0 3 * * * root /some/script.sh\n"); + $this->assertFileExists($cronFile); + + // Set backup schedule disabled + FunctionMocks::setPluginConfig('compose.manager', [ + 'BACKUP_SCHEDULE_ENABLED' => 'false', + ]); + + // Record directory mtime before + $mtimeBefore = filemtime($cronDir); + sleep(1); + + // Execute removal logic + $cfg = parse_plugin_cfg('compose.manager'); + $enabled = ($cfg['BACKUP_SCHEDULE_ENABLED'] ?? 'false') === 'true'; + + if (!$enabled && file_exists($cronFile)) { + @unlink($cronFile); + touch($cronDir); + } + + // Verify cron file removed + $this->assertFileDoesNotExist($cronFile); + + // Verify directory was touched + $mtimeAfter = filemtime($cronDir); + $this->assertGreaterThan($mtimeBefore, $mtimeAfter); + } } diff --git a/tests/unit/SettingsBackupTest.php b/tests/unit/SettingsBackupTest.php index 9531eaa..b2ad8ec 100644 --- a/tests/unit/SettingsBackupTest.php +++ b/tests/unit/SettingsBackupTest.php @@ -218,10 +218,11 @@ public function testRefreshArchiveListButton(): void $this->assertStringContainsString('loadBackupArchives()', $source); } - public function testBrowseArchiveButton(): void + public function testUploadArchiveButton(): void { $source = $this->getPageSource(); - $this->assertStringContainsString('browseRestoreArchive()', $source); + $this->assertStringContainsString('uploadBackupArchive(', $source); + $this->assertStringContainsString('id="backup-upload-input"', $source); } public function testDeleteSelectedBackupButton(): void From 72a06fe07f5849dd905daa48cc6519178902cce9 Mon Sep 17 00:00:00 2001 From: bean Date: Tue, 10 Feb 2026 14:51:58 -0500 Subject: [PATCH 2/4] Default console shell to /bin/bash with sh fallback - Change default shell from /bin/sh to /bin/bash across all console entry points - Add server-side fallback: check if bash exists in container via 'which', fall back to sh if not - Affects: exec.php, compose_util.php, compose_manager_main.php, dashboard page --- source/compose.manager/compose.manager.dashboard.page | 6 +++--- source/compose.manager/php/compose_manager_main.php | 2 +- source/compose.manager/php/compose_util.php | 8 +++++++- source/compose.manager/php/exec.php | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/source/compose.manager/compose.manager.dashboard.page b/source/compose.manager/compose.manager.dashboard.page index a29392c..96cd1e9 100644 --- a/source/compose.manager/compose.manager.dashboard.page +++ b/source/compose.manager/compose.manager.dashboard.page @@ -316,7 +316,7 @@ $script = <<<'EOT' } opts.push({text:'Console', icon:'fa-terminal', action:function(e){ e.preventDefault(); - openDockerTerminal(ctName, false, shell || 'sh'); + openDockerTerminal(ctName, false, shell || '/bin/bash'); }}); opts.push({text:'Logs', icon:'fa-file-text-o', action:function(e){ e.preventDefault(); @@ -360,7 +360,7 @@ $script = <<<'EOT' // For console: openTerminal('docker', containerName, shell) function openDockerTerminal(name, isLogs, shell) { if (typeof window.openTerminal === 'function') { - window.openTerminal('docker', name, isLogs ? '.log' : (shell || 'sh')); + window.openTerminal('docker', name, isLogs ? '.log' : (shell || '/bin/bash')); } else { // Fallback if global function not available var url = isLogs @@ -408,7 +408,7 @@ $script = <<<'EOT' var imgSrc = ct.Icon || '/plugins/dynamix.docker.manager/images/question.png'; var escapedName = ct.Name.replace(/'/g, "\\'"); var webui = resolveContainerWebUI(ct.WebUI).replace(/'/g, "\\'"); - var shell = (ct.Shell || 'sh').replace(/'/g, "\\'"); + var shell = (ct.Shell || '/bin/bash').replace(/'/g, "\\'"); var shortId = ct.ID.substring(0, 12); // Parse image to get repo and tag var image = ct.Image || ''; diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index f24ad1f..a522a7d 100644 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -3964,7 +3964,7 @@ function addComposeContainerContext(elementId) { var state = $el.data('state'); var webui = $el.data('webui'); var stackId = $el.data('stackid'); - var shell = $el.data('shell') || '/bin/sh'; + var shell = $el.data('shell') || '/bin/bash'; var running = state === 'running'; var paused = state === 'paused'; diff --git a/source/compose.manager/php/compose_util.php b/source/compose.manager/php/compose_util.php index b577cf0..2ae78b2 100644 --- a/source/compose.manager/php/compose_util.php +++ b/source/compose.manager/php/compose_util.php @@ -63,8 +63,14 @@ case 'containerConsole': // Open a ttyd console for a specific container (docker exec -it ) $containerName = $_POST['container'] ?? ''; - $shell = $_POST['shell'] ?? 'sh'; + $shell = $_POST['shell'] ?? '/bin/bash'; if ($containerName) { + // Check if the requested shell exists in the container; fall back to sh + $checkCmd = "docker exec " . escapeshellarg($containerName) . " which " . escapeshellarg($shell) . " 2>/dev/null"; + $shellPath = trim(exec($checkCmd)); + if (empty($shellPath)) { + $shell = 'sh'; + } // Sanitise container name for use as socket filename $safeName = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $containerName); $socketName = "compose_ct_" . $safeName; diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index 562d378..9c1ceef 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -520,7 +520,7 @@ $labels = $inspect['Config']['Labels'] ?? []; $webUITemplate = $labels[$docker_label_webui] ?? ''; $container['Icon'] = $labels[$docker_label_icon] ?? ''; - $container['Shell'] = $labels[$docker_label_shell] ?? '/bin/sh'; + $container['Shell'] = $labels[$docker_label_shell] ?? '/bin/bash'; // Resolve WebUI URL server-side (matching Unraid's DockerClient logic) // Determine the NetworkMode From 246d4e247c69b032f1b7e786d42b1c26d1cb23f2 Mon Sep 17 00:00:00 2001 From: bean Date: Tue, 10 Feb 2026 20:08:24 -0500 Subject: [PATCH 3/4] Fix addStack returning full path instead of directory name The addStack handler returned the full filesystem path as 'project' (e.g. /boot/.../projects/My_Stack) instead of just the directory name (e.g. My_Stack). When the editor modal later sent this as 'script' to save endpoints, the server constructed compose_root + script which doubled the path, causing saves to silently fail on newly created stacks. Also fixed 'projectName' to return the user-entered display name instead of the sanitized directory name. Resolves #15 --- source/compose.manager/php/exec.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index 9c1ceef..5d880ce 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -71,8 +71,8 @@ } // Return project info for opening the editor - $projectName = basename($folder); - echo json_encode(['result' => 'success', 'message' => '', 'project' => $folder, 'projectName' => $projectName]); + $projectDir = basename($folder); + echo json_encode(['result' => 'success', 'message' => '', 'project' => $projectDir, 'projectName' => $stackName]); break; case 'deleteStack': $stackName = isset($_POST['stackName']) ? urldecode(($_POST['stackName'])) : ""; From 34ad230715caa1ac885184469bf31c9bacb15461 Mon Sep 17 00:00:00 2001 From: bean Date: Tue, 10 Feb 2026 20:19:39 -0500 Subject: [PATCH 4/4] Default stack description to blank instead of showing path When no description file exists and no label description is set, the stack list was showing 'No description (path)' which pre-populated the description with the filesystem path. Changed to show blank instead. --- source/compose.manager/php/compose_list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 0875a66..34db419 100644 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -138,7 +138,7 @@ $description = str_replace("\r", "", $description); $description = str_replace("\n", "
", $description); } else { - $description = isset($variables['description']) ? $variables['description'] : "No description
($compose_root/$project)"; + $description = ""; } $autostart = '';