Merge pull request 'feat: Dashboard snapshot widget, backup trend, storage breakdown (#61)' (#93) from feat/61-dashboard-widgets into main

This commit was merged in pull request #93.
This commit is contained in:
2026-06-23 12:22:45 +00:00
5 changed files with 222 additions and 5 deletions
+3
View File
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
### Added
- Dashboard: snapshot widget, backup trend chart (30 days), and storage breakdown by profile (#61)
## [01.33.00] --- 2026-06-23
## [01.33.00] --- 2026-06-23
@@ -33,6 +33,12 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS="Content Snapshots"
COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL="View All"
COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT="Latest"
COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS="No snapshots yet. Create one from the Content Snapshots view."
COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile"
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
; Backups view
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
@@ -198,6 +198,90 @@ class DashboardModel extends BaseDatabaseModel
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.
*
@@ -24,18 +24,26 @@ class HtmlView extends BaseHtmlView
public array $systemHealth = [];
public array $profiles = [];
public bool $defaultDirWarning = false;
public ?object $latestSnapshot = null;
public int $snapshotCount = 0;
public array $backupTrend = [];
public array $storageByProfile = [];
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoSuiteBackup\Administrator\Model\DashboardModel $model */
$model = $this->getModel();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
$this->latestSnapshot = $model->getLatestSnapshot();
$this->snapshotCount = $model->getSnapshotCount();
$this->backupTrend = $model->getBackupTrend();
$this->storageByProfile = $model->getStorageByProfile();
$this->addToolbar();
@@ -109,6 +109,122 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
});
</script>
<!-- Row 1b: Snapshot Widget -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<span class="icon-camera" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS'); ?>
</h5>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" class="btn btn-sm btn-outline-secondary">
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL'); ?>
</a>
</div>
<div class="card-body">
<?php if ($this->latestSnapshot) : ?>
<?php $types = json_decode($this->latestSnapshot->content_types, true) ?: []; ?>
<p class="mb-1">
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT'); ?>:</strong>
<?php echo $this->escape($this->latestSnapshot->description); ?>
</p>
<p class="mb-1 text-muted">
<?php echo HTMLHelper::_('date', $this->latestSnapshot->created, Text::_('DATE_FORMAT_LC4')); ?>
&mdash;
<?php foreach ($types as $type) : ?>
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
<?php endforeach; ?>
</p>
<p class="mb-0">
<small class="text-muted">
<?php echo (int) $this->latestSnapshot->articles_count; ?> articles,
<?php echo (int) $this->latestSnapshot->categories_count; ?> categories,
<?php echo (int) $this->latestSnapshot->modules_count; ?> modules
&mdash; <?php echo $this->snapshotCount; ?> total snapshots
</small>
</p>
<?php else : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
<!-- Storage Breakdown by Profile -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">
<span class="icon-folder-open" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN'); ?>
</h5>
</div>
<div class="card-body">
<?php if (!empty($this->storageByProfile)) : ?>
<?php
$maxSize = max(array_column($this->storageByProfile, 'total_size')) ?: 1;
$colors = ['#0d6efd', '#198754', '#ffc107', '#dc3545', '#6f42c1', '#0dcaf0'];
?>
<?php foreach ($this->storageByProfile as $i => $profile) : ?>
<?php $pct = round(($profile->total_size / $maxSize) * 100); ?>
<div class="mb-2">
<div class="d-flex justify-content-between small">
<span><?php echo $this->escape($profile->profile_title ?: 'Unknown'); ?> (<?php echo (int) $profile->backup_count; ?>)</span>
<span><?php echo HTMLHelper::_('number.bytes', $profile->total_size); ?></span>
</div>
<div style="background:#e9ecef; border-radius:3px; height:8px; overflow:hidden;">
<div style="width:<?php echo $pct; ?>%; height:100%; background:<?php echo $colors[$i % count($colors)]; ?>; border-radius:3px;"></div>
</div>
</div>
<?php endforeach; ?>
<?php else : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Backup Trend (30 days) -->
<?php if (!empty($this->backupTrend)) : ?>
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<span class="icon-chart" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND'); ?>
</h5>
</div>
<div class="card-body">
<?php
$maxDaySize = max(array_column($this->backupTrend, 'day_size')) ?: 1;
?>
<div style="display:flex; align-items:flex-end; gap:2px; height:120px; overflow-x:auto;">
<?php foreach ($this->backupTrend as $day) : ?>
<?php
$barHeight = max(4, round(($day->day_size / $maxDaySize) * 100));
$barColor = $day->fail_count > 0 ? '#dc3545' : '#198754';
$tooltip = date('M j', strtotime($day->backup_date))
. ' — ' . $day->day_count . ' backup(s), '
. number_format($day->day_size / 1048576, 1) . ' MB'
. ($day->fail_count > 0 ? ', ' . $day->fail_count . ' failed' : '');
?>
<div style="flex:1; min-width:8px; max-width:24px; height:<?php echo $barHeight; ?>%; background:<?php echo $barColor; ?>; border-radius:2px 2px 0 0; cursor:default;"
title="<?php echo htmlspecialchars($tooltip); ?>"></div>
<?php endforeach; ?>
</div>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted"><?php echo date('M j', strtotime('-30 days')); ?></small>
<small class="text-muted"><?php echo date('M j'); ?></small>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Row 2: Quick Actions -->
<div class="row mb-3">
<div class="col-md-6">