ee21f7a373
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 15s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 42s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 25s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3m18s
Add three new dashboard widgets: - Snapshot widget: latest snapshot info, type badges, item counts, link to snapshots view, total count - Backup trend: CSS bar chart showing daily backup sizes over 30 days, red bars for days with failures, tooltips with details - Storage breakdown: horizontal bars showing space used per profile with color coding and backup counts Closes #61
303 lines
8.6 KiB
PHP
303 lines
8.6 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
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
|
|
|
class DashboardModel extends BaseDatabaseModel
|
|
{
|
|
/**
|
|
* Get the most recent completed backup record.
|
|
*
|
|
* @return object|null
|
|
*/
|
|
public function getLastBackup(): ?object
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select('r.*, p.title AS profile_title')
|
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
|
|
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
|
|
->order($db->quoteName('r.backupend') . ' DESC');
|
|
$db->setQuery($query, 0, 1);
|
|
|
|
return $db->loadObject() ?: null;
|
|
}
|
|
|
|
/**
|
|
* Query com_scheduler for the next scheduled MokoSuiteBackup task.
|
|
*
|
|
* @return object|null Object with next_execution and title, or null
|
|
*/
|
|
public function getNextScheduled(): ?object
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
try {
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['t.next_execution', 't.title']))
|
|
->from($db->quoteName('#__scheduler_tasks', 't'))
|
|
->where($db->quoteName('t.type') . ' = ' . $db->quote('mokosuitebackup.run_profile'))
|
|
->where($db->quoteName('t.state') . ' = 1')
|
|
->order($db->quoteName('t.next_execution') . ' ASC');
|
|
$db->setQuery($query, 0, 1);
|
|
|
|
return $db->loadObject() ?: null;
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get backup statistics.
|
|
*
|
|
* @return object Object with total_count, total_size, fail_count_7d
|
|
*/
|
|
public function getStats(): object
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
// Total completed backups and storage
|
|
$query = $db->getQuery(true)
|
|
->select('COUNT(*) AS total_count')
|
|
->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size')
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
|
$db->setQuery($query);
|
|
$stats = $db->loadObject();
|
|
|
|
// Failures in last 7 days
|
|
$cutoff = date('Y-m-d H:i:s', strtotime('-7 days'));
|
|
$query = $db->getQuery(true)
|
|
->select('COUNT(*) AS fail_count')
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
|
|
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff));
|
|
$db->setQuery($query);
|
|
$stats->fail_count_7d = (int) $db->loadResult();
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* Check system health for backup readiness.
|
|
*
|
|
* @return array Array of check results [{label, status, detail}]
|
|
*/
|
|
public function getSystemHealth(): array
|
|
{
|
|
$checks = [];
|
|
|
|
// PHP version
|
|
$checks[] = (object) [
|
|
'label' => 'PHP Version',
|
|
'status' => version_compare(PHP_VERSION, '8.1.0', '>='),
|
|
'detail' => PHP_VERSION,
|
|
];
|
|
|
|
// ZipArchive extension
|
|
$checks[] = (object) [
|
|
'label' => 'ZipArchive',
|
|
'status' => extension_loaded('zip'),
|
|
'detail' => extension_loaded('zip') ? 'Loaded' : 'Not loaded',
|
|
];
|
|
|
|
// AES-256 encryption support
|
|
$aesSupport = defined('ZipArchive::EM_AES_256');
|
|
$checks[] = (object) [
|
|
'label' => 'AES-256 Encryption',
|
|
'status' => $aesSupport,
|
|
'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
|
|
];
|
|
|
|
// Backup directory writable — check the first published profile's dir
|
|
$backupDir = BackupDirectory::getDefaultAbsolute();
|
|
|
|
$db2 = $this->getDatabase();
|
|
$qDir = $db2->getQuery(true)
|
|
->select($db2->quoteName('backup_dir'))
|
|
->from($db2->quoteName('#__mokosuitebackup_profiles'))
|
|
->where($db2->quoteName('published') . ' = 1')
|
|
->where($db2->quoteName('backup_dir') . ' != ' . $db2->quote(''))
|
|
->where($db2->quoteName('backup_dir') . ' IS NOT NULL');
|
|
$db2->setQuery($qDir, 0, 1);
|
|
$profileDir = $db2->loadResult();
|
|
|
|
if ($profileDir) {
|
|
$backupDir = BackupDirectory::resolve($profileDir);
|
|
}
|
|
|
|
if (BackupDirectory::hasPlaceholders($backupDir)) {
|
|
$checks[] = (object) [
|
|
'label' => 'Backup Directory',
|
|
'status' => true,
|
|
'detail' => 'Uses placeholders (resolved at backup time) — ' . $backupDir,
|
|
];
|
|
} else {
|
|
$writable = is_dir($backupDir) && is_writable($backupDir);
|
|
$checks[] = (object) [
|
|
'label' => 'Backup Directory',
|
|
'status' => $writable,
|
|
'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir,
|
|
];
|
|
}
|
|
|
|
// Disk space
|
|
$freeSpace = @disk_free_space($backupDir ?: JPATH_ROOT);
|
|
$freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0;
|
|
$checks[] = (object) [
|
|
'label' => 'Free Disk Space',
|
|
'status' => $freeGB >= 1.0,
|
|
'detail' => $freeGB . ' GB free',
|
|
];
|
|
|
|
return $checks;
|
|
}
|
|
|
|
/**
|
|
* Check if any profiles use the default (web-root) backup directory.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isUsingDefaultBackupDir(): bool
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('backup_dir'))
|
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
|
->where($db->quoteName('published') . ' = 1');
|
|
$db->setQuery($query);
|
|
$dirs = $db->loadColumn();
|
|
|
|
// Warn only if any profile's resolved path is inside the web root
|
|
foreach ($dirs as $dir) {
|
|
$resolved = BackupDirectory::resolve($dir ?: BackupDirectory::DEFAULT_RELATIVE);
|
|
|
|
if (BackupDirectory::hasPlaceholders($resolved)) {
|
|
continue;
|
|
}
|
|
|
|
if (BackupDirectory::isWebAccessible($resolved)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get latest snapshot info for the dashboard widget.
|
|
*/
|
|
public function getLatestSnapshot(): ?object
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
try {
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
|
->order($db->quoteName('created') . ' DESC');
|
|
$db->setQuery($query, 0, 1);
|
|
|
|
return $db->loadObject() ?: null;
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get snapshot count.
|
|
*/
|
|
public function getSnapshotCount(): int
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
try {
|
|
$query = $db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__mokosuitebackup_snapshots'));
|
|
$db->setQuery($query);
|
|
|
|
return (int) $db->loadResult();
|
|
} catch (\Throwable $e) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get backup size trend data for the last 30 days.
|
|
* Returns array of {date, total_size, count, status} grouped by day.
|
|
*/
|
|
public function getBackupTrend(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$cutoff = date('Y-m-d', strtotime('-30 days'));
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('DATE(' . $db->quoteName('backupstart') . ') AS backup_date')
|
|
->select('SUM(' . $db->quoteName('total_size') . ') AS day_size')
|
|
->select('COUNT(*) AS day_count')
|
|
->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS fail_count')
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where('DATE(' . $db->quoteName('backupstart') . ') >= ' . $db->quote($cutoff))
|
|
->group('DATE(' . $db->quoteName('backupstart') . ')')
|
|
->order('backup_date ASC');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get storage breakdown by profile.
|
|
*/
|
|
public function getStorageByProfile(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('p.title AS profile_title')
|
|
->select('COUNT(*) AS backup_count')
|
|
->select('COALESCE(SUM(r.total_size), 0) AS total_size')
|
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
|
|
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
|
|
->group($db->quoteName('r.profile_id'))
|
|
->order('total_size DESC');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get published backup profiles for the quick-action selector.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getProfiles(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['id', 'title', 'backup_type']))
|
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
}
|