Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions source/compose.manager/compose.manager.dashboard.page
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 || '';
Expand Down
135 changes: 72 additions & 63 deletions source/compose.manager/compose.manager.settings.page
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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%;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -925,7 +933,7 @@ function deleteSelectedBackup() {
if (result.result === 'success') {
selectedArchive = null;
selectedManifest = null;
$('#restore-stack-checklist').html('<span style="color:#888;">Select a backup archive above to see its contents.</span>');
$('#restore-stack-checklist').html('<span style="text-align:center;display:block;padding:15px;color:#888;font-size:0.85em;">Select a backup archive above to see its contents.</span>');
$('#btn-restore-stacks').prop('disabled', true);
loadBackupArchives();
} else {
Expand All @@ -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',
Expand All @@ -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 = $('<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($('<input>', { 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('<span style="color:#888;font-style:italic;">Reading archive contents...</span>');
$('#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('<span style="color:#f44;">' + escapeHtml(result.message || 'Failed to read archive.') + '</span>');
showBackupStatus('#restore-status', result.message || 'Upload failed.', 'error');
}
} catch(e) {
$('#restore-stack-checklist').html('<span style="color:#f44;">Error parsing response.</span>');
showBackupStatus('#restore-status', 'Error parsing upload response.', 'error');
}
});
}
},
error: function() {
showBackupStatus('#restore-status', 'Upload request failed.', 'error');
},
complete: function() {
input.value = '';
}
});
}

</script>
Expand Down Expand Up @@ -1272,7 +1284,7 @@ function browseRestoreArchive() {
</dl>

<dl>
<dt style="cursor: help;" onclick="toggleHelp(this)">_(Scheduled Backup)_:</dt>
<dt>_(Scheduled Backup)_:</dt>
<dd>
<input type="checkbox" id="BACKUP_SCHEDULE_ENABLED" class="compose-toggle"
<?=($cfg['BACKUP_SCHEDULE_ENABLED'] ?? 'false') == 'true' ? 'checked' : ''?>
Expand Down Expand Up @@ -1345,21 +1357,18 @@ function browseRestoreArchive() {
</div>
<div style="margin-top:5px; display:flex; gap:8px;">
<input type="button" value="_(Refresh)_" onclick="loadBackupArchives()">
<input type="button" value="_(Browse for Archive...)_" onclick="browseRestoreArchive()">
<input type="button" value="_(Upload Archive...)_" onclick="$('#backup-upload-input').click()">
<input type="file" id="backup-upload-input" accept=".tar.gz,.tgz" style="display:none;" onchange="uploadBackupArchive(this)">
<input type="button" value="_(Delete Selected)_" onclick="deleteSelectedBackup()">
</div>
<blockquote class="inline_help">
Select a backup archive from the list, or browse for a .zip file elsewhere on the system.<br>
Only archives matching the <code>backup_YYYY-MM-DD_HH-MM.tar.gz</code> naming pattern are listed automatically.
</blockquote>
</dd>
</dl>

<dl>
<dt>_(Stacks in Archive)_:</dt>
<dd>
<div class="backup-stack-checklist" id="restore-stack-checklist">
<span style="color:#888;">_(Select a backup archive above to see its contents.)_</span>
<span style="text-align:center;display:block;padding:15px;color:#888;font-size:0.85em;">_(Select a backup archive above to see its contents.)_</span>
</div>
</dd>
</dl>
Expand Down
49 changes: 35 additions & 14 deletions source/compose.manager/php/backup_functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -248,7 +248,6 @@ function restoreStacks($archivePath, $stacks)

if ($exitCode === 0) {
$restored[] = $stack;
logger("Restored stack: {$stack}");
} else {
$errors[] = $stack . ' (exit ' . $exitCode . ')';
}
Expand All @@ -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)) {
Expand All @@ -270,42 +269,64 @@ 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;
}

$frequency = $cfg['BACKUP_SCHEDULE_FREQUENCY'] ?? 'daily';
$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);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion source/compose.manager/php/compose_list.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
$description = str_replace("\r", "", $description);
$description = str_replace("\n", "<br>", $description);
} else {
$description = isset($variables['description']) ? $variables['description'] : "No description<br>($compose_root/$project)";
$description = "";
}

$autostart = '';
Expand Down
2 changes: 1 addition & 1 deletion source/compose.manager/php/compose_manager_main.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
8 changes: 7 additions & 1 deletion source/compose.manager/php/compose_util.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,14 @@
case 'containerConsole':
// Open a ttyd console for a specific container (docker exec -it <name> <shell>)
$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;
Expand Down
Loading