10248b284a
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 12s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 30s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 4m0s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Two related backup-management fixes.
Retention (records/days to keep):
- The retention fieldset was defined in profile.xml but never rendered
in the profile editor, so retention_days/retention_count were invisible.
Render the retention fieldset on the Archive tab.
- retention_days/retention_count were read by nothing, so they pruned no
backups. Add RetentionManager::prune(), called from completeRecord() in
both BackupEngine and SteppedBackupEngine after a backup finishes.
Policy: delete a completed/warning backup when EITHER it is older than
retention_days OR it falls outside the newest retention_count copies
(0 = unlimited for that rule). Deleting a record also removes its
archive and log file.
- Correct misleading language/schema text ("use global default" — no such
global backup-retention setting exists) to "0 = unlimited".
Purge Old Backups button:
- The modal only opened via a fragile selector match on the toolbar
button's inline onclick, which Joomla 6's Atum toolbar does not render,
so the button did nothing. Wrap Joomla.submitbutton to open the modal
for the backups.purgeModal task, keeping the selector as a fallback.
803 lines
29 KiB
PHP
803 lines
29 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\HTML\HTMLHelper;
|
|
use Joomla\CMS\Language\Text;
|
|
use Joomla\CMS\Layout\LayoutHelper;
|
|
use Joomla\CMS\Router\Route;
|
|
use Joomla\CMS\Session\Session;
|
|
|
|
HTMLHelper::_('behavior.multiselect');
|
|
$user = Factory::getApplication()->getIdentity();
|
|
$canDownload = $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup');
|
|
|
|
$ajaxToken = Session::getFormToken();
|
|
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
|
|
|
|
$listOrder = $this->escape($this->state->get('list.ordering'));
|
|
$listDirn = $this->escape($this->state->get('list.direction'));
|
|
?>
|
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups'); ?>" method="post" name="adminForm" id="adminForm">
|
|
<div class="row">
|
|
<div class="col-md-12">
|
|
<div id="j-main-container" class="j-main-container">
|
|
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
|
|
|
|
<?php if (empty($this->items)) : ?>
|
|
<div class="alert alert-info">
|
|
<span class="icon-info-circle" aria-hidden="true"></span>
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_NO_BACKUPS'); ?>
|
|
</div>
|
|
<?php else : ?>
|
|
<table class="table" id="backupList">
|
|
<caption class="visually-hidden"><?php echo Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TABLE_CAPTION'); ?></caption>
|
|
<thead>
|
|
<tr>
|
|
<td class="w-1 text-center">
|
|
<?php echo HTMLHelper::_('grid.checkall'); ?>
|
|
</td>
|
|
<th scope="col">
|
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', 'a.description', $listDirn, $listOrder); ?>
|
|
</th>
|
|
<th scope="col" class="w-10">
|
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_PROFILE', 'a.profile_id', $listDirn, $listOrder); ?>
|
|
</th>
|
|
<th scope="col" class="w-10">
|
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_STATUS', 'a.status', $listDirn, $listOrder); ?>
|
|
</th>
|
|
<th scope="col" class="w-10">
|
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
|
</th>
|
|
<th scope="col" class="w-10">
|
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_SIZE', 'a.total_size', $listDirn, $listOrder); ?>
|
|
</th>
|
|
<th scope="col" class="w-10">
|
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.backupstart', $listDirn, $listOrder); ?>
|
|
</th>
|
|
<th scope="col" class="w-5">
|
|
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($this->items as $i => $item) : ?>
|
|
<tr>
|
|
<td class="text-center">
|
|
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
|
|
</td>
|
|
<td>
|
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backup&id=' . $item->id); ?>">
|
|
<?php echo $this->escape($item->description); ?>
|
|
</a>
|
|
<?php if (!empty($item->checksum)) : ?>
|
|
<br><small class="text-muted font-monospace"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_CHECKSUM'); ?>: <?php echo substr($item->checksum, 0, 16); ?>...</small>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td>
|
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=profile.edit&id=' . (int) $item->profile_id); ?>">
|
|
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
|
|
</a>
|
|
</td>
|
|
<td>
|
|
<?php
|
|
$statusClass = match ($item->status) {
|
|
'complete' => 'badge bg-success',
|
|
'warning' => 'badge bg-warning text-dark',
|
|
'running' => 'badge bg-info',
|
|
'fail' => 'badge bg-danger',
|
|
default => 'badge bg-secondary',
|
|
};
|
|
?>
|
|
<span class="<?php echo $statusClass; ?>"><?php echo $this->escape($item->status); ?></span>
|
|
</td>
|
|
<td>
|
|
<?php echo $this->escape($item->backup_type); ?>
|
|
</td>
|
|
<td>
|
|
<?php
|
|
if ($item->total_size > 0) {
|
|
echo HTMLHelper::_('number.bytes', $item->total_size);
|
|
} else {
|
|
echo '—';
|
|
}
|
|
?>
|
|
</td>
|
|
<td>
|
|
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
|
|
</td>
|
|
<td>
|
|
<?php echo (int) $item->id; ?>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
|
|
<?php echo $this->pagination->getListFooter(); ?>
|
|
<?php endif; ?>
|
|
|
|
<input type="hidden" name="task" value="">
|
|
<input type="hidden" name="boxchecked" value="0">
|
|
<?php echo HTMLHelper::_('form.token'); ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Stepped Backup Modal (for shared hosting) -->
|
|
<div class="modal fade" id="mokosuitebackup-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="mb-modal-title">Backup in Progress</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
|
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
|
</div>
|
|
<div class="progress mb-2" style="height:24px;">
|
|
<div id="mb-progress-bar" class="progress-bar" role="progressbar" style="width:0%;">0%</div>
|
|
</div>
|
|
<p id="mb-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
|
|
<p id="mb-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
|
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
|
|
|
|
|
|
var backupRunning = false;
|
|
|
|
function warnBeforeClose(e) {
|
|
if (backupRunning) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
}
|
|
|
|
window.addEventListener('beforeunload', warnBeforeClose);
|
|
|
|
function showModal() {
|
|
backupRunning = true;
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mokosuitebackup-modal')).show();
|
|
}
|
|
|
|
function hideModal() {
|
|
backupRunning = false;
|
|
bootstrap.Modal.getInstance(document.getElementById('mokosuitebackup-modal'))?.hide();
|
|
}
|
|
|
|
function updateProgress(progress, message, phase) {
|
|
const bar = document.getElementById('mb-progress-bar');
|
|
bar.style.width = progress + '%';
|
|
bar.textContent = progress + '%';
|
|
document.getElementById('mb-status').textContent = message;
|
|
document.getElementById('mb-phase').textContent = 'Phase: ' + phase;
|
|
}
|
|
|
|
async function postAjax(params) {
|
|
const form = new URLSearchParams();
|
|
form.append(TOKEN_NAME, '1');
|
|
for (const [k, v] of Object.entries(params)) {
|
|
form.append(k, v);
|
|
}
|
|
const res = await fetch(AJAX_URL, {
|
|
method: 'POST',
|
|
body: form,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function startSteppedBackup() {
|
|
const profileSelect = document.getElementById('mb-profile-select');
|
|
const profileId = profileSelect ? profileSelect.value : '1';
|
|
|
|
showModal();
|
|
updateProgress(0, 'Initializing backup...', 'init');
|
|
|
|
try {
|
|
// Init
|
|
const initResult = await postAjax({
|
|
task: 'ajax.init',
|
|
profile_id: profileId
|
|
});
|
|
|
|
if (initResult.error) {
|
|
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
|
setTimeout(hideModal, 5000);
|
|
return;
|
|
}
|
|
|
|
// Show preflight warnings if any
|
|
if (initResult.warnings && initResult.warnings.length > 0) {
|
|
var warningEl = document.getElementById('mb-phase');
|
|
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
|
|
warningEl.style.color = '#856404';
|
|
}
|
|
|
|
const sessionId = initResult.session_id;
|
|
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
|
|
|
// Run steps until done
|
|
let done = false;
|
|
while (!done) {
|
|
const stepResult = await postAjax({
|
|
task: 'ajax.step',
|
|
session_id: sessionId
|
|
});
|
|
|
|
if (stepResult.error) {
|
|
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
|
|
setTimeout(hideModal, 5000);
|
|
return;
|
|
}
|
|
|
|
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
|
|
done = stepResult.done || false;
|
|
}
|
|
|
|
// Complete
|
|
document.getElementById('mb-modal-title').textContent = 'Backup Complete';
|
|
setTimeout(function() {
|
|
hideModal();
|
|
location.reload();
|
|
}, 2000);
|
|
|
|
} catch (err) {
|
|
updateProgress(0, 'ERROR: ' + err.message, 'failed');
|
|
setTimeout(hideModal, 5000);
|
|
}
|
|
}
|
|
|
|
// Expose for toolbar button
|
|
window.mokosuitebackupStart = startSteppedBackup;
|
|
|
|
// Intercept Restore toolbar button to show the modal
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var restoreBtn = document.querySelector('[onclick*="backups.restore"], .button-upload');
|
|
if (restoreBtn) {
|
|
restoreBtn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Get selected record from checkboxes
|
|
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
|
|
if (checked.length === 0) {
|
|
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', true); ?>');
|
|
return false;
|
|
}
|
|
document.getElementById('mb-restore-record-id').value = checked[0].value;
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-restore-modal')).show();
|
|
return false;
|
|
}, true);
|
|
}
|
|
});
|
|
|
|
// Close restore modal handled by Bootstrap data-bs-dismiss
|
|
|
|
// AJAX stepped restore
|
|
var restoreRunning = false;
|
|
|
|
function showRestoreProgress() {
|
|
restoreRunning = true;
|
|
bootstrap.Modal.getInstance(document.getElementById('mb-restore-modal'))?.hide();
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-restore-progress-modal')).show();
|
|
}
|
|
|
|
function hideRestoreProgress() {
|
|
restoreRunning = false;
|
|
bootstrap.Modal.getInstance(document.getElementById('mb-restore-progress-modal'))?.hide();
|
|
}
|
|
|
|
function updateRestoreProgress(progress, message, phase) {
|
|
var bar = document.getElementById('mb-restore-progress-bar');
|
|
bar.style.width = progress + '%';
|
|
bar.textContent = progress + '%';
|
|
document.getElementById('mb-restore-status').textContent = message;
|
|
document.getElementById('mb-restore-phase').textContent = 'Phase: ' + phase;
|
|
}
|
|
|
|
window.addEventListener('beforeunload', function(e) {
|
|
if (restoreRunning) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
});
|
|
|
|
async function startSteppedRestore(e) {
|
|
e.preventDefault();
|
|
|
|
var recordId = document.getElementById('mb-restore-record-id').value;
|
|
var restoreFiles = document.getElementById('mb-restore-files').checked ? 1 : 0;
|
|
var restoreDb = document.getElementById('mb-restore-db').checked ? 1 : 0;
|
|
var preserveConfig = document.getElementById('mb-restore-config').checked ? 1 : 0;
|
|
var password = document.getElementById('mb-restore-password').value;
|
|
|
|
showRestoreProgress();
|
|
updateRestoreProgress(0, 'Initializing restore...', 'init');
|
|
|
|
try {
|
|
var initResult = await postAjax({
|
|
task: 'ajax.restoreInit',
|
|
id: recordId,
|
|
restore_files: restoreFiles,
|
|
restore_db: restoreDb,
|
|
preserve_config: preserveConfig,
|
|
encryption_password: password
|
|
});
|
|
|
|
if (initResult.error) {
|
|
updateRestoreProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
|
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
|
|
setTimeout(hideRestoreProgress, 5000);
|
|
return;
|
|
}
|
|
|
|
var sessionId = initResult.session_id;
|
|
updateRestoreProgress(initResult.progress, initResult.message, initResult.phase);
|
|
|
|
var done = false;
|
|
while (!done) {
|
|
var stepResult = await postAjax({
|
|
task: 'ajax.restoreStep',
|
|
session_id: sessionId
|
|
});
|
|
|
|
if (stepResult.error) {
|
|
updateRestoreProgress(0, 'ERROR: ' + stepResult.message, 'failed');
|
|
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
|
|
setTimeout(hideRestoreProgress, 5000);
|
|
return;
|
|
}
|
|
|
|
updateRestoreProgress(stepResult.progress, stepResult.message, stepResult.phase);
|
|
done = stepResult.done || false;
|
|
}
|
|
|
|
document.getElementById('mb-restore-title').textContent = 'Restore Complete';
|
|
setTimeout(function() {
|
|
hideRestoreProgress();
|
|
location.reload();
|
|
}, 2000);
|
|
|
|
} catch (err) {
|
|
updateRestoreProgress(0, 'ERROR: ' + err.message, 'failed');
|
|
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
|
|
setTimeout(hideRestoreProgress, 5000);
|
|
}
|
|
}
|
|
|
|
// Attach the AJAX restore handler to the restore form
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var restoreForm = document.getElementById('mb-restore-form');
|
|
if (restoreForm) {
|
|
restoreForm.addEventListener('submit', startSteppedRestore);
|
|
}
|
|
});
|
|
|
|
})();
|
|
</script>
|
|
|
|
<!-- Restore Confirmation Modal -->
|
|
<div class="modal fade" id="mb-restore-modal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
|
|
<input type="hidden" name="id" id="mb-restore-record-id" value="">
|
|
<div class="modal-body">
|
|
<div class="alert alert-danger">
|
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
|
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="restore_files" value="1" id="mb-restore-files" checked>
|
|
<label class="form-check-label" for="mb-restore-files">
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_FILES'); ?>
|
|
</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="restore_db" value="1" id="mb-restore-db" checked>
|
|
<label class="form-check-label" for="mb-restore-db">
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_DATABASE'); ?>
|
|
</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="preserve_config" value="1" id="mb-restore-config" checked>
|
|
<label class="form-check-label" for="mb-restore-config">
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG'); ?>
|
|
<small class="text-muted d-block"><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC'); ?></small>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="mb-restore-password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD'); ?></label>
|
|
<input type="password" class="form-control" id="mb-restore-password" name="encryption_password"
|
|
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
|
<button type="submit" class="btn btn-danger">
|
|
<span class="icon-upload" aria-hidden="true"></span>
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
|
|
</button>
|
|
</div>
|
|
<?php echo HTMLHelper::_('form.token'); ?>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Restore Progress Modal -->
|
|
<div class="modal fade" id="mb-restore-progress-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="mb-restore-title">Restore in Progress</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="progress mb-2" style="height:24px;">
|
|
<div id="mb-restore-progress-bar" class="progress-bar bg-danger" role="progressbar" style="width:0%;">0%</div>
|
|
</div>
|
|
<p id="mb-restore-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
|
|
<p id="mb-restore-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Purge Backups Modal -->
|
|
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
|
|
<?php if ($canDelete) : ?>
|
|
<div class="modal fade" id="mb-purge-modal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<span class="icon-trash" aria-hidden="true"></span>
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
|
|
<div class="modal-body">
|
|
<p><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DESC'); ?></p>
|
|
<div class="mb-3">
|
|
<label for="mb-purge-date" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL'); ?></label>
|
|
<input type="date" class="form-control" id="mb-purge-date" name="purge_date" required>
|
|
</div>
|
|
<div id="mb-purge-count-wrapper" style="display:none;">
|
|
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
|
|
</div>
|
|
<div id="mb-purge-none-wrapper" style="display:none;">
|
|
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
|
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
|
|
<span class="icon-trash" aria-hidden="true"></span>
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
|
|
</button>
|
|
</div>
|
|
<?php echo HTMLHelper::_('form.token'); ?>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<!-- Backup Comparison Modal -->
|
|
<div class="modal fade" id="mb-compare-modal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<span class="icon-copy" aria-hidden="true"></span>
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" style="max-height:65vh; overflow-y:auto;">
|
|
<div id="mb-compare-loading" class="text-center py-4">
|
|
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
|
|
</div>
|
|
<div id="mb-compare-error" style="display:none;" class="alert alert-danger"></div>
|
|
<table id="mb-compare-table" class="table table-striped" style="display:none;">
|
|
<thead>
|
|
<tr>
|
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FIELD'); ?></th>
|
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 1</th>
|
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 2</th>
|
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DELTA'); ?></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="mb-compare-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
var COMPARE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
|
var COMPARE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
|
|
|
function mbCmpFormatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
var units = ['B', 'KB', 'MB', 'GB'];
|
|
var i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
|
|
if (i >= units.length) i = units.length - 1;
|
|
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
|
}
|
|
|
|
function mbCmpFormatDuration(seconds) {
|
|
if (seconds <= 0) return '0s';
|
|
var m = Math.floor(seconds / 60);
|
|
var s = seconds % 60;
|
|
return m > 0 ? m + 'm ' + s + 's' : s + 's';
|
|
}
|
|
|
|
function mbCmpDeltaCell(value, unit) {
|
|
if (value === 0) return '<span class="text-muted">—</span>';
|
|
var isPositive = value > 0;
|
|
var colorClass = isPositive ? 'text-danger' : 'text-success';
|
|
var display;
|
|
if (unit === 'bytes') {
|
|
display = (isPositive ? '+' : '') + mbCmpFormatBytes(value);
|
|
} else if (unit === 'duration') {
|
|
display = (isPositive ? '+' : '-') + mbCmpFormatDuration(Math.abs(value));
|
|
} else {
|
|
display = (isPositive ? '+' : '') + value.toLocaleString();
|
|
}
|
|
return '<span class="fw-bold ' + colorClass + '">' + display + '</span>';
|
|
}
|
|
|
|
function mbShowCompareModal(id1, id2) {
|
|
var modal = document.getElementById('mb-compare-modal');
|
|
var loading = document.getElementById('mb-compare-loading');
|
|
var errorEl = document.getElementById('mb-compare-error');
|
|
var table = document.getElementById('mb-compare-table');
|
|
var body = document.getElementById('mb-compare-body');
|
|
|
|
bootstrap.Modal.getOrCreateInstance(modal).show();
|
|
loading.style.display = 'block';
|
|
errorEl.style.display = 'none';
|
|
table.style.display = 'none';
|
|
body.innerHTML = '';
|
|
|
|
var form = new URLSearchParams();
|
|
form.append('task', 'ajax.compareBackups');
|
|
form.append('id1', id1);
|
|
form.append('id2', id2);
|
|
form.append(COMPARE_TOKEN, '1');
|
|
|
|
fetch(COMPARE_AJAX_URL, {
|
|
method: 'POST',
|
|
body: form,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
loading.style.display = 'none';
|
|
|
|
if (data.error) {
|
|
errorEl.textContent = data.message || 'Error loading comparison';
|
|
errorEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
var b1 = data.backup1;
|
|
var b2 = data.backup2;
|
|
var d = data.delta;
|
|
|
|
var dur1 = 0, dur2 = 0;
|
|
if (b1.backupstart !== '0000-00-00 00:00:00' && b1.backupend !== '0000-00-00 00:00:00') {
|
|
dur1 = (new Date(b1.backupend).getTime() - new Date(b1.backupstart).getTime()) / 1000;
|
|
}
|
|
if (b2.backupstart !== '0000-00-00 00:00:00' && b2.backupend !== '0000-00-00 00:00:00') {
|
|
dur2 = (new Date(b2.backupend).getTime() - new Date(b2.backupstart).getTime()) / 1000;
|
|
}
|
|
|
|
var rows = [
|
|
['<?php echo Text::_('JGRID_HEADING_ID', true); ?>', '#' + b1.id, '#' + b2.id, ''],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', true); ?>', b1.description || '—', b2.description || '—', ''],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_PROFILE', true); ?>', b1.profile_title || '—', b2.profile_title || '—', ''],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS', true); ?>', b1.status, b2.status, ''],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE', true); ?>', b1.backup_type, b2.backup_type, ''],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_SIZE', true); ?>', mbCmpFormatBytes(b1.total_size), mbCmpFormatBytes(b2.total_size), mbCmpDeltaCell(d.size_diff, 'bytes')],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE', true); ?>', mbCmpFormatBytes(b1.db_size), mbCmpFormatBytes(b2.db_size), mbCmpDeltaCell(b2.db_size - b1.db_size, 'bytes')],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT', true); ?>', b1.files_count.toLocaleString(), b2.files_count.toLocaleString(), mbCmpDeltaCell(d.files_diff, 'number')],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT', true); ?>', b1.tables_count.toLocaleString(), b2.tables_count.toLocaleString(), mbCmpDeltaCell(d.tables_diff, 'number')],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE', true); ?>', b1.backupstart, b2.backupstart, ''],
|
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DURATION', true); ?>', mbCmpFormatDuration(dur1), mbCmpFormatDuration(dur2), mbCmpDeltaCell(d.duration_diff_seconds, 'duration')],
|
|
];
|
|
|
|
var html = '';
|
|
for (var i = 0; i < rows.length; i++) {
|
|
html += '<tr><td class="fw-bold">' + rows[i][0] + '</td><td>' + rows[i][1] + '</td><td>' + rows[i][2] + '</td><td>' + rows[i][3] + '</td></tr>';
|
|
}
|
|
body.innerHTML = html;
|
|
table.style.display = 'table';
|
|
})
|
|
.catch(function(err) {
|
|
loading.style.display = 'none';
|
|
errorEl.textContent = 'Error: ' + err.message;
|
|
errorEl.style.display = 'block';
|
|
});
|
|
}
|
|
|
|
// Compare modal close handled by Bootstrap data-bs-dismiss
|
|
|
|
// Intercept Compare toolbar button
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var compareBtn = document.querySelector('[onclick*="backups.compare"], .button-copy');
|
|
if (compareBtn) {
|
|
compareBtn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
|
|
if (checked.length !== 2) {
|
|
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO', true); ?>');
|
|
return false;
|
|
}
|
|
|
|
mbShowCompareModal(checked[0].value, checked[1].value);
|
|
return false;
|
|
}, true);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<?php if ($canDelete) : ?>
|
|
<script>
|
|
(function() {
|
|
var PURGE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
|
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
|
var purgeCountTimer = null;
|
|
|
|
// Reset modal state and show it.
|
|
function openPurgeModal() {
|
|
document.getElementById('mb-purge-date').value = '';
|
|
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
|
|
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
|
|
document.getElementById('mb-purge-submit').disabled = true;
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show();
|
|
}
|
|
|
|
// Primary: wrap Joomla.submitbutton so the Purge toolbar button opens the
|
|
// modal instead of submitting the no-op backups.purgeModal task. This is
|
|
// resilient to how the Atum toolbar renders the button markup.
|
|
if (window.Joomla && typeof Joomla.submitbutton === 'function') {
|
|
var origSubmitbutton = Joomla.submitbutton;
|
|
Joomla.submitbutton = function(task) {
|
|
if (task === 'backups.purgeModal') {
|
|
openPurgeModal();
|
|
return false;
|
|
}
|
|
return origSubmitbutton.apply(this, arguments);
|
|
};
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Fallback: if the button still exposes an inline onclick, bind directly.
|
|
var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash');
|
|
if (purgeBtn) {
|
|
purgeBtn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openPurgeModal();
|
|
return false;
|
|
}, true);
|
|
}
|
|
|
|
// Date change triggers count lookup with debounce
|
|
var dateInput = document.getElementById('mb-purge-date');
|
|
if (dateInput) {
|
|
dateInput.addEventListener('change', function() {
|
|
if (purgeCountTimer) clearTimeout(purgeCountTimer);
|
|
purgeCountTimer = setTimeout(fetchPurgeCount, 300);
|
|
});
|
|
}
|
|
|
|
// Purge modal close handled by Bootstrap data-bs-dismiss
|
|
|
|
// Confirm on submit
|
|
var purgeForm = document.getElementById('mb-purge-form');
|
|
if (purgeForm) {
|
|
purgeForm.addEventListener('submit', function(e) {
|
|
var msg = document.getElementById('mb-purge-count-msg').textContent;
|
|
if (!confirm(msg + '\n\n<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_CONFIRM', true); ?>')) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
function fetchPurgeCount() {
|
|
var dateVal = document.getElementById('mb-purge-date').value;
|
|
var countWrapper = document.getElementById('mb-purge-count-wrapper');
|
|
var noneWrapper = document.getElementById('mb-purge-none-wrapper');
|
|
var countMsg = document.getElementById('mb-purge-count-msg');
|
|
var submitBtn = document.getElementById('mb-purge-submit');
|
|
|
|
if (!dateVal) {
|
|
countWrapper.style.display = 'none';
|
|
noneWrapper.style.display = 'none';
|
|
submitBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
countMsg.textContent = '<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING', true); ?>';
|
|
countWrapper.style.display = 'block';
|
|
noneWrapper.style.display = 'none';
|
|
submitBtn.disabled = true;
|
|
|
|
var form = new URLSearchParams();
|
|
form.append('task', 'ajax.countPurge');
|
|
form.append('date', dateVal);
|
|
form.append(PURGE_TOKEN, '1');
|
|
|
|
fetch(PURGE_AJAX_URL, {
|
|
method: 'POST',
|
|
body: form,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.error) {
|
|
countMsg.textContent = data.message || 'Error';
|
|
countWrapper.style.display = 'block';
|
|
noneWrapper.style.display = 'none';
|
|
submitBtn.disabled = true;
|
|
} else if (data.count === 0) {
|
|
countWrapper.style.display = 'none';
|
|
noneWrapper.style.display = 'block';
|
|
submitBtn.disabled = true;
|
|
} else {
|
|
var text = '<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG', true); ?>';
|
|
countMsg.textContent = text.replace('%d', data.count);
|
|
countWrapper.style.display = 'block';
|
|
noneWrapper.style.display = 'none';
|
|
submitBtn.disabled = false;
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
countMsg.textContent = 'Error: ' + err.message;
|
|
countWrapper.style.display = 'block';
|
|
noneWrapper.style.display = 'none';
|
|
submitBtn.disabled = true;
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|