feat: backup bridge plugin to detect MokoSuiteBackup (#208) #209

Merged
jmiller merged 5 commits from feature/208-backup-bridge into dev 2026-06-18 19:54:35 +00:00
8 changed files with 376 additions and 14 deletions
@@ -0,0 +1,13 @@
; MokoSuiteClient Backup Bridge Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP="System - MokoSuiteClient Backup"
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC="Detects MokoSuiteBackup and includes backup status in heartbeat payloads sent to MokoSuiteHQ."
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC="Backup Monitoring"
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC_DESC="Configure backup status collection for heartbeat reporting."
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_LABEL="Include in Heartbeat"
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_DESC="Include MokoSuiteBackup status data in heartbeat payloads sent to MokoSuiteHQ."
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_LABEL="Stale Backup Threshold (days)"
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_DESC="Number of days without a backup before status is marked as degraded. Default: 7."
@@ -0,0 +1,3 @@
; MokoSuiteClient Backup Bridge Plugin - System strings
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP="System - MokoSuiteClient Backup"
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC="MokoSuiteBackup detection and heartbeat integration."
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteClient Backup</name>
<element>mokosuiteclient_backup</element>
<author>Moko Consulting</author>
<creationDate>2026-06-18</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.84-dev</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_backup.ini</language>
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_backup.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC_DESC">
<field name="heartbeat_enabled" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_LABEL"
description="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="stale_days" type="number" default="7"
label="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_LABEL"
description="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_DESC"
min="1" max="90" step="1" />
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,34 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_system_mokosuiteclient_backup
* @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\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoSuiteClientBackup\Extension\Backup;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Backup($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteclient_backup'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,263 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_system_mokosuiteclient_backup
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoSuiteClientBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Database\DatabaseInterface;
use Joomla\Event\SubscriberInterface;
/**
* MokoSuiteClient Backup Bridge Plugin
*
* Detects whether MokoSuiteBackup is installed and collects backup
* status data for inclusion in heartbeat payloads to MokoSuiteHQ.
*
* Prefers MokoSuiteBackup's own BackupStatusHelper when available,
* falling back to a direct table query if the helper class is missing
* (e.g. older versions of MokoSuiteBackup).
*
* @since 02.34.84
*/
class Backup extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onMokoSuiteClientCollectHeartbeat' => 'onCollectHeartbeat',
];
}
/**
* Collect backup status data for the heartbeat payload.
*
* Triggered by the monitor plugin before sending a heartbeat.
* Appends a 'backup' key to the heartbeat data array.
*/
public function onCollectHeartbeat($event): void
{
if (!$this->params->get('heartbeat_enabled', 1))
{
return;
}
try
{
$data = $this->getBackupStatus();
$event->addResult('backup', $data);
}
catch (\Throwable $e)
{
Log::add('Backup bridge: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
// Send explicit error so HQ knows collection failed,
// rather than interpreting absence as "not installed"
$event->addResult('backup', [
'installed' => true,
'status' => 'error',
'message' => 'Failed to collect backup status',
]);
}
}
/**
* Check if MokoSuiteBackup is installed.
*
* Queries the extensions table for the component, which is more
* reliable than checking for database tables alone.
*/
public function isBackupInstalled(): bool
{
try
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
return (int) $db->loadResult() > 0;
}
catch (\Throwable $e)
{
return false;
}
}
/**
* Get backup status summary from MokoSuiteBackup.
*
* Prefers the BackupStatusHelper API when available. Falls back
* to a direct database query for compatibility with older versions.
*
* @return array Backup status data for heartbeat inclusion.
*/
public function getBackupStatus(): array
{
if (!$this->isBackupInstalled())
{
return [
'installed' => false,
'status' => 'ok',
];
}
// Prefer MokoSuiteBackup's own helper (clean public API)
$helperClass = 'Joomla\\Component\\MokoSuiteBackup\\Administrator\\Utility\\BackupStatusHelper';
if (class_exists($helperClass))
{
$staleDays = (int) $this->params->get('stale_days', 7);
return $helperClass::getStatus($staleDays);
}
// Fallback: direct table query for older MokoSuiteBackup versions
$db = Factory::getContainer()->get(DatabaseInterface::class);
$tables = $db->getTableList();
$prefix = $db->getPrefix();
if (!in_array($prefix . 'mokosuitebackup_records', $tables, true))
{
return [
'installed' => true,
'status' => 'degraded',
'message' => 'Backup tables not found',
];
}
return $this->queryBackupRecords($db);
}
/**
* Query MokoSuiteBackup records for the latest backup summary.
*
* Column names match the MokoSuiteBackup schema:
* - backupstart/backupend (not created/modified)
* - status: pending, running, complete, fail
* - total_size in bytes
*
* @param DatabaseInterface $db Database driver.
*
* @return array Backup status array.
*/
private function queryBackupRecords(DatabaseInterface $db): array
{
$staleDays = (int) $this->params->get('stale_days', 7);
// Most recent backup record
$query = $db->getQuery(true)
->select([
$db->quoteName('id'),
$db->quoteName('description'),
$db->quoteName('status'),
$db->quoteName('backup_type'),
$db->quoteName('total_size'),
$db->quoteName('backupstart'),
$db->quoteName('backupend'),
$db->quoteName('origin'),
$db->quoteName('filesexist'),
])
->from($db->quoteName('#__mokosuitebackup_records'))
->order($db->quoteName('id') . ' DESC');
$db->setQuery($query, 0, 1);
$latest = $db->loadObject();
if (!$latest)
{
return [
'installed' => true,
'status' => 'degraded',
'message' => 'No backups found',
];
}
// Count completed backups
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
);
$totalBackups = (int) $db->loadResult();
$cutoff = date('Y-m-d H:i:s', strtotime("-{$staleDays} days"));
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
);
$recentBackups = (int) $db->loadResult();
// Failures in last 7 days
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
);
$failCount7d = (int) $db->loadResult();
// Determine status
$daysSince = 999;
if (!empty($latest->backupstart) && $latest->backupstart !== '0000-00-00 00:00:00')
{
$daysSince = (int) ((time() - strtotime($latest->backupstart)) / 86400);
}
$status = 'ok';
if ($latest->status === 'fail')
{
$status = 'degraded';
}
elseif ($latest->status !== 'complete')
{
$status = ($latest->status === 'running') ? 'ok' : 'degraded';
}
elseif ($daysSince > $staleDays)
{
$status = 'degraded';
}
$sizeMb = $latest->total_size
? round($latest->total_size / 1048576)
: null;
return [
'installed' => true,
'status' => $status,
'last_backup' => $latest->backupstart,
'last_status' => $latest->status,
'last_size_mb' => $sizeMb,
'days_since' => $daysSince,
'backup_type' => $latest->backup_type,
'origin' => $latest->origin,
'total_backups' => $totalBackups,
'recent_7d' => $recentBackups,
'fail_count_7d' => $failCount7d,
'files_exist' => (bool) $latest->filesexist,
'description' => $latest->description,
];
}
}
@@ -4,8 +4,8 @@
SPDX-License-Identifier: GPL-3.0-or-later
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteClient Content Sync</name>
<element>mokosuiteclientsync</element>
<name>Task - MokoSuiteClient Demo Reset</name>
<element>mokosuiteclientdemo</element>
<author>Moko Consulting</author>
<creationDate>2026-05-30</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
@@ -13,11 +13,11 @@
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.84-dev</version>
<description>PLG_TASK_MOKOSUITESYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
<description>PLG_TASK_MOKOSUITEDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
<files>
<filename plugin="mokosuiteclientsync">mokosuiteclientsync.xml</filename>
<filename plugin="mokosuiteclientdemo">mokosuiteclientdemo.xml</filename>
<folder>src</folder>
<folder>services</folder>
<folder>forms</folder>
@@ -25,7 +25,7 @@
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.sys.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.sys.ini</language>
</languages>
</extension>
@@ -4,8 +4,8 @@
SPDX-License-Identifier: GPL-3.0-or-later
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteClient Demo Reset</name>
<element>mokosuiteclientdemo</element>
<name>Task - MokoSuiteClient Content Sync</name>
<element>mokosuiteclientsync</element>
<author>Moko Consulting</author>
<creationDate>2026-05-30</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
@@ -13,11 +13,11 @@
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.84-dev</version>
<description>PLG_TASK_MOKOSUITEDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
<description>PLG_TASK_MOKOSUITESYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
<files>
<filename plugin="mokosuiteclientdemo">mokosuiteclientdemo.xml</filename>
<filename plugin="mokosuiteclientsync">mokosuiteclientsync.xml</filename>
<folder>src</folder>
<folder>services</folder>
<folder>forms</folder>
@@ -25,7 +25,7 @@
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.sys.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.sys.ini</language>
</languages>
</extension>
+1
View File
@@ -25,6 +25,7 @@
<file type="module" id="mod_mokosuiteclient_menu" client="administrator">mod_mokosuiteclient_menu.zip</file>
<file type="module" id="mod_mokosuiteclient_cache" client="administrator">mod_mokosuiteclient_cache.zip</file>
<file type="module" id="mod_mokosuiteclient_categories" client="administrator">mod_mokosuiteclient_categories.zip</file>
<file type="plugin" id="plg_system_mokosuiteclient_backup" group="system">plg_system_mokosuiteclient_backup.zip</file>
<file type="plugin" id="plg_webservices_mokosuiteclient" group="webservices">plg_webservices_mokosuiteclient.zip</file>
<file type="plugin" id="plg_task_mokosuiteclientdemo" group="task">plg_task_mokosuiteclientdemo.zip</file>
<file type="plugin" id="plg_task_mokosuiteclientsync" group="task">plg_task_mokosuiteclientsync.zip</file>