Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Model/DashboardModel.php
T
Jonathan Miller 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
feat: dashboard snapshot widget, backup trend chart, storage breakdown (#61)
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
2026-06-23 07:22:04 -05:00

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() ?: [];
}
}