diff --git a/administrator/language/en-GB/mod_healthcheck.ini b/administrator/language/en-GB/mod_healthcheck.ini new file mode 100644 index 00000000000..0ed07857a48 --- /dev/null +++ b/administrator/language/en-GB/mod_healthcheck.ini @@ -0,0 +1,67 @@ +; Joomla! Project +; (C) 2026 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +MOD_HEALTHCHECK="Health Check" +MOD_HEALTHCHECK_XML_DESCRIPTION="This module shows health check information from installed plugins." + +MOD_HEALTHCHECK_GROUP_FIELD_LABEL="Health Check Group" +MOD_HEALTHCHECK_GROUP_FIELD_DESC="The group (or context) of this module (this value is compared with the group value used in Health Check plugins to inject data)." + +MOD_HEALTHCHECK_HEADER_ICON_FIELD_LABEL="Header Icon" + +; Item types +MOD_HEALTHCHECK_BUTTONS="Action Buttons" +MOD_HEALTHCHECK_GAUGES="Health Gauges" +MOD_HEALTHCHECK_LISTS="Health Lists" +MOD_HEALTHCHECK_REPORTS="Health Reports" +MOD_HEALTHCHECK_TABLES="Data Tables" + +; Status messages +MOD_HEALTHCHECK_STATUS_ERROR="Error" +MOD_HEALTHCHECK_STATUS_INFO="Information" +MOD_HEALTHCHECK_STATUS_OK="OK" +MOD_HEALTHCHECK_STATUS_WARNING="Warning" + +; Default messages +MOD_HEALTHCHECK_ERROR_LOADING="Error loading health check data" +MOD_HEALTHCHECK_LOADING="Loading health check data..." +MOD_HEALTHCHECK_NO_DATA="No health check data available" +MOD_HEALTHCHECK_NO_MATCHING_RESULTS="All good! Nothing to report here." + +; Accessibility +MOD_HEALTHCHECK_ARIA_BUTTON="Health check action button" +MOD_HEALTHCHECK_ARIA_GAUGE="Health gauge showing %s" +MOD_HEALTHCHECK_ARIA_LIST="Health check list" +MOD_HEALTHCHECK_ARIA_REPORT="Health check report" +MOD_HEALTHCHECK_ARIA_TABLE="Health check data table" + +; Common actions +MOD_HEALTHCHECK_CONFIGURE="Configure" +MOD_HEALTHCHECK_EXPORT="Export" +MOD_HEALTHCHECK_REFRESH="Refresh" +MOD_HEALTHCHECK_VIEW_DETAILS="View Details" + +; Filter buttons +MOD_HEALTHCHECK_FILTER_ALL="All" +MOD_HEALTHCHECK_FILTER_CRITICAL="Needs Attention" +MOD_HEALTHCHECK_FILTER_HEALTHY="Healthy" +MOD_HEALTHCHECK_FILTER_LABEL="Filter health checks by status" +MOD_HEALTHCHECK_FILTER_WARNING="Review" + +; Gauge layout +MOD_HEALTHCHECK_GAUGE_DEBUG_RANGE="Range: %1$s-%2$s" +MOD_HEALTHCHECK_GAUGE_DEBUG_THRESHOLDS="Thresholds: %1$s/%2$s/%3$s" +MOD_HEALTHCHECK_GAUGE_ITEM_ARIA_LABEL="%1$s gauge showing %2$s %3$s out of %4$s." +MOD_HEALTHCHECK_GAUGE_ITEM_ARIA_LABEL_LINK="%1$s gauge showing %2$s %3$s out of %4$s. Click to view details." +MOD_HEALTHCHECK_GAUGE_LINK_ARIA_LABEL="%1$s - %2$s %3$s. Click for details." +MOD_HEALTHCHECK_GAUGE_LINK_TITLE="%1$s - %2$s %3$s" +MOD_HEALTHCHECK_GAUGE_PERCENT_OF_RANGE="%s%% of range" +MOD_HEALTHCHECK_GAUGE_SVG_TITLE="%1$s gauge: %2$s %3$s" +MOD_HEALTHCHECK_GAUGE_SVG_DESC="A circular progress indicator showing %1$s %2$s out of a maximum of %3$s %2$s. This represents %4$s%% of the total range." +MOD_HEALTHCHECK_GAUGE_SR_SCORE="Score: %1$s %2$s out of %3$s %2$s." +MOD_HEALTHCHECK_GAUGE_SR_RANGE="This represents %1$s%% of the range from %2$s to %3$s." +MOD_HEALTHCHECK_GAUGE_STATUS_ATTENTION="Status: Performance needs attention." +MOD_HEALTHCHECK_GAUGE_STATUS_EXCELLENT="Status: Excellent performance." +MOD_HEALTHCHECK_GAUGE_STATUS_GOOD="Status: Good performance with room for improvement." diff --git a/administrator/language/en-GB/mod_healthcheck.sys.ini b/administrator/language/en-GB/mod_healthcheck.sys.ini new file mode 100644 index 00000000000..518faf70d9f --- /dev/null +++ b/administrator/language/en-GB/mod_healthcheck.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2026 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +MOD_HEALTHCHECK="Health Check" +MOD_HEALTHCHECK_XML_DESCRIPTION="This module shows health check information from installed plugins." \ No newline at end of file diff --git a/administrator/modules/mod_healthcheck/mod_healthcheck.xml b/administrator/modules/mod_healthcheck/mod_healthcheck.xml new file mode 100644 index 00000000000..348f8ab79da --- /dev/null +++ b/administrator/modules/mod_healthcheck/mod_healthcheck.xml @@ -0,0 +1,82 @@ + + + mod_healthcheck + Joomla! Project + 2026-04 + (C) 2026 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 6.2.0 + MOD_HEALTHCHECK_XML_DESCRIPTION + Joomla\Module\Healthcheck + + services + src + tmpl + + + language/en-GB/mod_healthcheck.ini + language/en-GB/mod_healthcheck.sys.ini + + + + +
+ + +
+
+ + + + + + + + + + +
+
+
+
diff --git a/administrator/modules/mod_healthcheck/services/provider.php b/administrator/modules/mod_healthcheck/services/provider.php new file mode 100644 index 00000000000..779ac68ad06 --- /dev/null +++ b/administrator/modules/mod_healthcheck/services/provider.php @@ -0,0 +1,41 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +\defined('_JEXEC') or die; + +use Joomla\CMS\Extension\Service\Provider\HelperFactory; +use Joomla\CMS\Extension\Service\Provider\Module; +use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; + +/** + * The healthcheck module service provider. + * + * @since __DEPLOY_VERSION__ + */ +return new class () implements ServiceProviderInterface { + /** + * Registers the service provider with a DI container. + * + * @param Container $container The DI container. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function register(Container $container) + { + $container->registerServiceProvider(new ModuleDispatcherFactory('\\Joomla\\Module\\Healthcheck')); + $container->registerServiceProvider(new HelperFactory('\\Joomla\\Module\\Healthcheck\\Administrator\\Helper')); + + $container->registerServiceProvider(new Module()); + } +}; diff --git a/administrator/modules/mod_healthcheck/src/Dispatcher/Dispatcher.php b/administrator/modules/mod_healthcheck/src/Dispatcher/Dispatcher.php new file mode 100644 index 00000000000..2ebd634bc72 --- /dev/null +++ b/administrator/modules/mod_healthcheck/src/Dispatcher/Dispatcher.php @@ -0,0 +1,57 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Module\Healthcheck\Administrator\Dispatcher; + +use Joomla\CMS\Dispatcher\AbstractModuleDispatcher; +use Joomla\CMS\Helper\HelperFactoryAwareInterface; +use Joomla\CMS\Helper\HelperFactoryAwareTrait; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Dispatcher class for mod_healthcheck + * + * @since __DEPLOY_VERSION__ + */ +class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface +{ + use HelperFactoryAwareTrait; + + /** + * Returns the layout data. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + protected function getLayoutData() + { + $data = parent::getLayoutData(); + + $helper = $this->getHelperFactory()->getHelper('HealthCheckHelper'); + + $data['gauges'] = $helper->getGauges($data['params'], $this->getApplication()); + $data['buttons'] = $helper->getButtons($data['params'], $this->getApplication()); + $data['lists'] = $helper->getLists($data['params'], $this->getApplication()); + $data['tables'] = $helper->getTables($data['params'], $this->getApplication()); + $data['reports'] = $helper->getReports($data['params'], $this->getApplication()); + + $data['leading'] = $helper->getLeading($data['params'], $this->getApplication()); + $data['footer'] = $helper->getFooter($data['params'], $this->getApplication()); + + // Make helper available to layouts + $data['helper'] = $helper; + + return $data; + } +} diff --git a/administrator/modules/mod_healthcheck/src/Event/HealthChecksEvent.php b/administrator/modules/mod_healthcheck/src/Event/HealthChecksEvent.php new file mode 100644 index 00000000000..441417c9a16 --- /dev/null +++ b/administrator/modules/mod_healthcheck/src/Event/HealthChecksEvent.php @@ -0,0 +1,61 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Module\Healthcheck\Administrator\Event; + +use Joomla\CMS\Event\AbstractEvent; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Event object for retrieving pluggable health checks + * + * @since __DEPLOY_VERSION__ + */ +class HealthChecksEvent extends AbstractEvent +{ + /** + * The event context + * + * @var string + * @since __DEPLOY_VERSION__ + */ + private $context; + + /** + * Get the event context + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getContext() + { + return $this->context; + } + + /** + * Set the event context + * + * @param string $context The event context + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function setContext($context) + { + $this->context = $context; + + return $context; + } +} diff --git a/administrator/modules/mod_healthcheck/src/Helper/HealthCheckHelper.php b/administrator/modules/mod_healthcheck/src/Helper/HealthCheckHelper.php new file mode 100644 index 00000000000..ae43429f082 --- /dev/null +++ b/administrator/modules/mod_healthcheck/src/Helper/HealthCheckHelper.php @@ -0,0 +1,526 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Module\Healthcheck\Administrator\Helper; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Factory; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\Module\Healthcheck\Administrator\Event\HealthChecksEvent; +use Joomla\Registry\Registry; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Helper for mod_healthcheck + * + * @since __DEPLOY_VERSION__ + */ +class HealthCheckHelper +{ + /** + * Stack to hold gauges + * + * @var array[] + * @since __DEPLOY_VERSION__ + */ + protected $gauges = []; + + /** + * Stack to hold buttons + * + * @var array[] + * @since __DEPLOY_VERSION__ + */ + protected $buttons = []; + + /** + * Stack to hold lists + * + * @var array[] + * @since __DEPLOY_VERSION__ + */ + protected $lists = []; + + /** + * Stack to hold tables + * + * @var array[] + * @since __DEPLOY_VERSION__ + */ + protected $tables = []; + + /** + * Stack to hold reports + * + * @var array[] + * @since __DEPLOY_VERSION__ + */ + protected $reports = []; + + /** + * Stack to hold leading information + * + * @var array[] + * @since __DEPLOY_VERSION__ + */ + protected $leading = []; + + /** + * Stack to hold footer information + * + * @var array[] + * @since __DEPLOY_VERSION__ + */ + protected $footer = []; + + /** + * Generic helper method to get health check data from plugins + * + * @param Registry $params The module parameters + * @param string $type The type of data (gauges, buttons, lists, etc.) + * @param string $eventName The event name to trigger + * @param array $defaults Default values for the data items + * @param array $requiredFields Required fields for validation + * @param CMSApplication|null $application The application + * + * @return array An array of health check data items + * + * @since __DEPLOY_VERSION__ + */ + protected function getHealthCheckData( + Registry $params, + string $type, + string $eventName, + array $defaults, + array $requiredFields, + ?CMSApplication $application = null + ) { + if ($application == null) { + $application = Factory::getApplication(); + } + + $key = (string) $params; + $context = (string) $params->get('context', 'general'); + $property = $type; // gauges, buttons, lists, etc. + + if (!isset($this->{$property}[$key])) { + // Load mod_healthcheck language file in case this method is called before rendering the module + $application->getLanguage()->load('mod_healthcheck'); + + $this->{$property}[$key] = []; + + PluginHelper::importPlugin('healthcheck'); + + $arrays = (array) $application->getDispatcher()->dispatch( + $eventName, + new HealthChecksEvent($eventName, ['context' => $context]) + ); + + foreach ($arrays as $response) { + if (!\is_array($response)) { + continue; + } + + foreach ($response as $item) { + $item = array_merge($defaults, $item); + + // Validate required fields + $isValid = true; + foreach ($requiredFields as $fieldGroup) { + $hasAnyRequired = false; + foreach ($fieldGroup as $field) { + if (!\is_null($item[$field])) { + $hasAnyRequired = true; + break; + } + } + if (!$hasAnyRequired) { + $isValid = false; + break; + } + } + + if ($isValid) { + $this->{$property}[$key][] = $item; + } + } + } + } + + return $this->{$property}[$key]; + } + + /** + * Helper method to return gauge list. + * + * This method returns the array by reference so it can be + * used to add custom gauges or remove default ones. + * + * @param Registry $params The module parameters + * @param ?CMSApplication $application The application + * + * @return array An array of gauges + * + * @since __DEPLOY_VERSION__ + */ + public function getGauges(Registry $params, ?CMSApplication $application = null) + { + $defaults = [ + 'score' => null, + 'unit' => null, + 'score_min' => null, + 'score_max' => null, + 'score_threshold_warning' => null, + 'score_threshold_success' => null, + 'label' => null, + 'sublabel' => null, + 'note' => null, + 'link' => null, + 'link_title' => null, + 'access' => true, + 'class' => null, + 'group' => 'general', + ]; + + $requiredFields = [ + ['score'], // Must have score + ['unit'], // Must have unit + ]; + + return $this->getHealthCheckData( + $params, + 'gauges', + 'onHealthcheckGetGauges', + $defaults, + $requiredFields, + $application + ); + } + + /** + * Helper method to return button list. + * + * This method returns the array by reference so it can be + * used to add custom buttons or remove default ones. + * + * @param Registry $params The module parameters + * @param ?CMSApplication $application The application + * + * @return array An array of buttons + * + * @since __DEPLOY_VERSION__ + */ + public function getButtons(Registry $params, ?CMSApplication $application = null) + { + $defaults = [ + 'link' => null, + 'image' => null, + 'amount' => null, + 'text' => null, + 'name' => null, + 'linkadd' => null, + 'linkaddicon' => null, + 'access' => true, + 'status' => null, + 'group' => 'general', + ]; + + $requiredFields = [ + ['link'], + ['text', 'name'], // Must have either text or name + ]; + + return $this->getHealthCheckData( + $params, + 'buttons', + 'onHealthcheckGetIcons', + $defaults, + $requiredFields, + $application + ); + } + + /** + * Helper method to return list list. + * + * This method returns the array by reference so it can be + * used to add custom lists or remove default ones. + * + * @param Registry $params The module parameters + * @param ?CMSApplication $application The application + * + * @return array An array of lists + * + * @since __DEPLOY_VERSION__ + */ + public function getLists(Registry $params, ?CMSApplication $application = null) + { + $defaults = [ + 'items' => null, + 'access' => true, + 'class' => null, + 'group' => 'general', + ]; + + $requiredFields = [ + ['items'], + ]; + + return $this->getHealthCheckData( + $params, + 'lists', + 'onHealthcheckGetLists', + $defaults, + $requiredFields, + $application + ); + } + + /** + * Helper method to return table list. + * + * This method returns the array by reference so it can be + * used to add custom tables or remove default ones. + * + * @param Registry $params The module parameters + * @param ?CMSApplication $application The application + * + * @return array An array of tables + * + * @since __DEPLOY_VERSION__ + */ + public function getTables(Registry $params, ?CMSApplication $application = null) + { + $defaults = [ + 'columns' => null, + 'data' => null, + 'caption' => null, + 'access' => true, + 'class' => null, + 'group' => 'general', + 'helper' => $this, + ]; + + $requiredFields = [ + ['columns'], + ['data'], // Must have either text or name + ]; + + return $this->getHealthCheckData( + $params, + 'tables', + 'onHealthcheckGetTables', + $defaults, + $requiredFields, + $application + ); + } + + /** + * Helper method to return report list. + * + * This method returns the array by reference so it can be + * used to add custom reports or remove default ones. + * + * @param Registry $params The module parameters + * @param ?CMSApplication $application The application + * + * @return array An array of reports + * + * @since __DEPLOY_VERSION__ + */ + public function getReports(Registry $params, ?CMSApplication $application = null) + { + $defaults = [ + 'link' => null, + 'image' => null, + 'text' => null, + 'name' => null, + 'linkadd' => null, + 'access' => true, + 'class' => null, + 'group' => 'general', + ]; + + $requiredFields = [ + ['link'], + ['text', 'name'], // Must have either text or name + ]; + + return $this->getHealthCheckData( + $params, + 'reports', + 'onHealthcheckGetReports', + $defaults, + $requiredFields, + $application + ); + } + + /** + * Helper method to return leading information. + * + * @param Registry $params The module parameters + * @param ?CMSApplication $application The application + * + * @return array An array of leading information + * + * @since __DEPLOY_VERSION__ + */ + public function getLeading(Registry $params, ?CMSApplication $application = null) + { + $defaults = [ + 'info' => null, + 'access' => true, + 'class' => null, + 'group' => 'general', + ]; + + $requiredFields = [ + ['info'], // Must have info + ]; + + return $this->getHealthCheckData( + $params, + 'leading', + 'onHealthcheckGetLeading', + $defaults, + $requiredFields, + $application + ); + } + + /** + * Helper method to return footer information. + * + * @param Registry $params The module parameters + * @param ?CMSApplication $application The application + * + * @return array An array of footer information + * + * @since __DEPLOY_VERSION__ + */ + public function getFooter(Registry $params, ?CMSApplication $application = null) + { + $defaults = [ + 'info' => null, + 'access' => true, + 'class' => null, + 'group' => 'general', + ]; + + $requiredFields = [ + ['info'], // Must have info + ]; + + return $this->getHealthCheckData( + $params, + 'footer', + 'onHealthcheckGetFooter', + $defaults, + $requiredFields, + $application + ); + } + + // Function to render cell content based on column type + public function renderTableCellContent($column, $item, $rowIndex) + { + $key = $column['key'] ?? ''; + $type = $column['type'] ?? 'text'; + $value = \is_object($item) ? ($item->$key ?? '') : ($item[$key] ?? ''); + + switch ($type) { + case 'badge': + $badgeClass = $column['badgeClass'] ?? 'secondary'; + if (\is_callable($column['badgeClass'])) { + $badgeClass = \call_user_func($column['badgeClass'], $value, $item, $rowIndex); + } + + return '' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . ''; + + case 'link': + $url = $column['url'] ?? ''; + if (\is_callable($column['url'])) { + $url = \call_user_func($column['url'], $value, $item, $rowIndex); + } + $title = $column['linkTitle'] ?? ''; + if (\is_callable($column['linkTitle'])) { + $title = \call_user_func($column['linkTitle'], $value, $item, $rowIndex); + } + + return '' . + htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . ''; + + case 'date': + $format = $column['dateFormat'] ?? Text::_('DATE_FORMAT_LC4'); + + return HTMLHelper::_('date', $value, $format); + + case 'boolean': + $trueText = $column['trueText'] ?? Text::_('JYES'); + $falseText = $column['falseText'] ?? Text::_('JNO'); + $trueClass = $column['trueClass'] ?? 'success'; + $falseClass = $column['falseClass'] ?? 'danger'; + $isTrue = (bool) $value; + + return '' . + ($isTrue ? $trueText : $falseText) . ''; + + case 'progress': + $percentage = (float) $value; + $progressClass = $column['progressClass'] ?? 'primary'; + if (\is_callable($column['progressClass'])) { + $progressClass = \call_user_func($column['progressClass'], $value, $item, $rowIndex); + } + + return '
+
' . $percentage . '% +
+
'; + + case 'icon': + $iconClass = $column['iconClass'] ?? 'fas fa-info'; + if (\is_callable($column['iconClass'])) { + $iconClass = \call_user_func($column['iconClass'], $value, $item, $rowIndex); + } + + return ''; + + case 'custom': + if (isset($column['renderer']) && \is_callable($column['renderer'])) { + return \call_user_func($column['renderer'], $value, $item, $rowIndex, $column); + } + + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + + case 'text': + default: + $maxLength = $column['maxLength'] ?? null; + $text = htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + if ($maxLength && \strlen($text) > $maxLength) { + $text = substr($text, 0, $maxLength) . '...'; + } + + return $text; + } + } +} diff --git a/administrator/modules/mod_healthcheck/tmpl/default.php b/administrator/modules/mod_healthcheck/tmpl/default.php new file mode 100644 index 00000000000..f069857b28a --- /dev/null +++ b/administrator/modules/mod_healthcheck/tmpl/default.php @@ -0,0 +1,112 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; + +/** @var Joomla\CMS\WebAsset\WebAssetManager $wa */ +$wa = $app->getDocument()->getWebAssetManager(); +$wa->useScript('core') + ->useScript('bootstrap.dropdown'); +$wa->registerAndUseScript('mod_quickicon', 'mod_quickicon/quickicon.min.js', ['relative' => true, 'version' => 'auto'], ['type' => 'module']); + +// Register and use the filter assets +$wa->registerAndUseScript('mod_healthcheck.filter', 'mod_healthcheck/healthcheck-filter.js', [], ['defer' => true], []) + ->registerAndUseStyle('mod_healthcheck.filter', 'mod_healthcheck/healthcheck-filter.css', [], []); + +$gauges_html = HTMLHelper::_('healthchecks.gauges', $gauges); +$buttons_html = HTMLHelper::_('healthchecks.buttons', $buttons); +$lists_html = HTMLHelper::_('healthchecks.lists', $lists); +$tables_html = HTMLHelper::_('healthchecks.tables', $tables); +$reports_html = HTMLHelper::_('healthchecks.reports', $reports); +$leading_html = HTMLHelper::_('healthchecks.leadings', $leading); +$footer_html = HTMLHelper::_('healthchecks.footers', $footer); + +$has_data = !empty($gauges_html) || !empty($buttons_html) || !empty($lists_html) || !empty($tables_html) || !empty($reports_html); +?> + + +
+
+ + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ diff --git a/build/media_source/mod_healthcheck/css/healthcheck-filter.css b/build/media_source/mod_healthcheck/css/healthcheck-filter.css new file mode 100644 index 00000000000..3dfd56c45bc --- /dev/null +++ b/build/media_source/mod_healthcheck/css/healthcheck-filter.css @@ -0,0 +1,172 @@ +/** + * @package Joomla.Administrator + * @subpackage mod_healthcheck + * + * @copyright (C) 2026 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +/* Filter buttons styling */ + +.healthcheck-filters .btn-group { + display: flex; + justify-content: flex-end; +} + +.healthcheck-filters .btn { + flex: 0 0 auto !important; + padding: .25rem .5rem; + font-size: .75rem; + font-weight: 500; + line-height: 1.5; + border-radius: .25rem; + transition: all .2s ease-in-out; +} + +.healthcheck-filters .btn-group .btn:first-child { + border-top-left-radius: .375rem; + border-bottom-left-radius: .375rem; +} + +.healthcheck-filters .btn-group .btn:last-child { + border-start-end-radius: .375rem; + border-end-end-radius: .375rem; +} + +.healthcheck-filters .btn:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, .1); + transform: translateY(-1px); +} + +.healthcheck-filters .btn.active { + font-weight: 600; +} + +/* Smooth transition for filtered items */ +.quickicon-single, +.quickicon-group { + transition: opacity .3s ease-in-out, transform .3s ease-in-out; +} + +.quickicon-single.d-none, +.quickicon-group.d-none { + opacity: 0; + transform: scale(.95); +} + +/* Responsive adjustments */ +@media (max-width: 576px) { + .healthcheck-filters .btn-group { + flex-direction: column; + width: 100%; + } + + .healthcheck-filters .btn { + margin-bottom: .25rem; + border-radius: .25rem !important; + } +} + +/* Gauge */ + +.healthcheck-gauge { + padding: 15px; + margin: 15px; + cursor: default; + background-color: #fff; + border-radius: 8px; + /* Ensure sufficient focus indicator */ + transition: box-shadow .15s ease-in-out, border-color .15s ease-in-out; +} + +.healthcheck-gauge:focus { + /*outline: none;*/ + box-shadow: 0 0 0 .25rem rgba(13, 110, 253, .25); + /*border: 2px solid #0d6efd;*/ +} + +.healthcheck-gauge:focus-within { + outline: none; + box-shadow: 0 0 0 .25rem rgba(13, 110, 253, .25); +} + +.gauge-container { + max-width: 200px; + padding: 10px; + margin: 0 auto; +} + +.gauge-svg { + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .1)); +} + +.gauge-label { + font-size: 16px; + font-weight: 600; + color: #495057; +} + +.gauge-sublabel { + font-size: 12px; +} + +.gauge-note { + font-size: 11px; + font-style: italic; + color: #6c757d; +} + +.gauge-debug { + padding-top: 5px; + font-family: monospace; + font-size: 10px; + color: #6c757d; + border-top: 1px solid #dee2e6; +} + +.gauge-link { + position: relative; +} + +.gauge-link::before { + position: absolute; + top: 5px; + left: 5px; +} + +.gauge-link:hover, +.gauge-link:focus { + color: inherit; + text-decoration: none !important; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, .15); + transform: translateY(-2px); +} + +.gauge-link:focus, +.gauge-link:focus-within { + outline: none; +} + +/* Linked gauges should have pointer cursor */ +.healthcheck-gauge:has(.gauge-link) { + cursor: pointer; +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .healthcheck-gauge { + border: 2px solid; + } + + .gauge-svg circle { + stroke-width: 10; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .gauge-svg circle { + transition: none !important; + } +} diff --git a/build/media_source/mod_healthcheck/js/healthcheck-filter.js b/build/media_source/mod_healthcheck/js/healthcheck-filter.js new file mode 100644 index 00000000000..6dc554fc7ed --- /dev/null +++ b/build/media_source/mod_healthcheck/js/healthcheck-filter.js @@ -0,0 +1,66 @@ +/** + * @package Joomla.Administrator + * @subpackage mod_healthcheck + * + * @copyright (C) 2026 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +document.addEventListener('DOMContentLoaded', function() { + 'use strict'; + + // Get all filter buttons + const filterButtons = document.querySelectorAll('.healthcheck-filters [data-filter]'); + + if (filterButtons.length === 0) { + return; + } + + // Get all health check items that can be filtered + const healthCheckItems = document.querySelectorAll('.quickicon-single[data-filter-status], .quickicon-group[data-filter-status]'); + + // Function to filter items + function filterItems(filterType) { + healthCheckItems.forEach(function(item) { + const itemStatus = item.getAttribute('data-filter-status'); + + if (filterType === 'all') { + // Show all items + item.style.display = ''; + item.classList.remove('d-none'); + } else if (filterType === itemStatus) { + // Show items matching the filter (healthy, warning, or critical) + item.style.display = ''; + item.classList.remove('d-none'); + } else { + // Hide items not matching the filter + item.style.display = 'none'; + item.classList.add('d-none'); + } + }); + } + + // Add click event listeners to filter buttons + filterButtons.forEach(function(button) { + button.addEventListener('click', function(e) { + e.preventDefault(); + + // Remove active class from all buttons + filterButtons.forEach(function(btn) { + btn.classList.remove('active'); + }); + + // Add active class to clicked button + this.classList.add('active'); + + // Get the filter type + const filterType = this.getAttribute('data-filter'); + + // Filter the items + filterItems(filterType); + }); + }); + + // Initialize with 'all' filter + filterItems('all'); +}); diff --git a/layouts/joomla/healthchecks/footer.php b/layouts/joomla/healthchecks/footer.php new file mode 100644 index 00000000000..679b0517944 --- /dev/null +++ b/layouts/joomla/healthchecks/footer.php @@ -0,0 +1,15 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +if (isset($displayData['info'])) { + echo $displayData['info']; +} diff --git a/layouts/joomla/healthchecks/gauge.php b/layouts/joomla/healthchecks/gauge.php new file mode 100644 index 00000000000..373e2d8f139 --- /dev/null +++ b/layouts/joomla/healthchecks/gauge.php @@ -0,0 +1,216 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; + +Factory::getApplication()->getLanguage()->load('mod_healthcheck', JPATH_ADMINISTRATOR); + +// Get gauge parameters with defaults +$id = empty($displayData['id']) ? '' : (' id="' . $displayData['id'] . '"'); +$label = $displayData['label'] ?? ''; +$sublabel = $displayData['sublabel'] ?? ''; +$note = $displayData['note'] ?? ''; +$link = $displayData['link'] ?? ''; +$linktitle = $displayData['linktitle'] ?? ''; +$linktarget = ''; + +// Auto-detect external links and set target to _blank +// TODO use Joomla API? +if (!empty($link) && empty($linktarget)) { + // Check if link is external (starts with http/https or contains a different domain) + if (preg_match('/^https?:\/\//', $link)) { + // Extract current domain for comparison + $currentDomain = $_SERVER['HTTP_HOST'] ?? ''; + $linkDomain = parse_url($link, PHP_URL_HOST); + + // If different domain or no current domain info, treat as external + if (empty($currentDomain) || $linkDomain !== $currentDomain) { + $linktarget = '_blank'; + } + } +} +//$rawdata = (float) ($displayData['rawdata'] ?? 0); +$score = (float) ($displayData['score'] ?? 0); +$unit = $displayData['unit'] ?? '%'; +$score_min = (float) ($displayData['score_min'] ?? 0); +$score_max = (float) ($displayData['score_max'] ?? 100); +$score_threshold_error = (float) ($displayData['score_threshold_error'] ?? 0); +$score_threshold_warning = (float) ($displayData['score_threshold_warning'] ?? 50); +$score_threshold_success = (float) ($displayData['score_threshold_success'] ?? 90); + +// Prepare link attributes +$linkAttributes = ''; +$hasLink = !empty($link); +if ($hasLink) { + $linkAttributes = 'href="' . htmlspecialchars($link) . '"'; + if (!empty($linktarget)) { + $linkAttributes .= ' target="' . htmlspecialchars($linktarget) . '"'; + if ($linktarget === '_blank') { + $linkAttributes .= ' rel="noopener noreferrer"'; + } + } + if (!empty($linktitle)) { + $linkAttributes .= ' title="' . htmlspecialchars($linktitle) . '"'; + } else { + $linkAttributes .= ' title="' . htmlspecialchars(Text::sprintf('MOD_HEALTHCHECK_GAUGE_LINK_TITLE', $label, $score, $unit)) . '"'; + } +} + +// Calculate percentage for the pie chart +$percentage = ($score_max > $score_min) ? (($score - $score_min) / ($score_max - $score_min)) * 100 : 0; +$percentage = max(0, min(100, $percentage)); // Clamp between 0-100 + +// Determine color based on thresholds +$color = '#dc3545'; // Error (red) +if ($score >= $score_threshold_success) { + $color = '#28a745'; // Success (green) +} elseif ($score >= $score_threshold_warning) { + $color = '#ffc107'; // Warning (yellow) +} + +// Calculate SVG path for pie chart +$radius = 45; +$circumference = 2 * M_PI * $radius; +$strokeDasharray = $circumference; +$strokeDashoffset = $circumference * (1 - $percentage / 100); + +// SVG viewBox and center +$size = 120; +$center = $size / 2; +?> + +
  • + role="img" + tabindex="" + aria-label="" + data-score="" + data-max="" + data-percentage=""> + + + class="gauge-link d-block text-decoration-none" + aria-label=""> + + +
    + +

    + + + +

    + + +
    + + + + +
    + + + = $score_threshold_success) : ?> + + = $score_threshold_warning) : ?> + + + + +
    + + + +
    + + +

    + + + + +
    + | + +
    + +
    + + +
    + +
  • diff --git a/layouts/joomla/healthchecks/icon.php b/layouts/joomla/healthchecks/icon.php new file mode 100644 index 00000000000..e27e11606fa --- /dev/null +++ b/layouts/joomla/healthchecks/icon.php @@ -0,0 +1,115 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; + +$id = empty($displayData['id']) ? '' : (' id="' . $displayData['id'] . '"'); +$target = empty($displayData['target']) ? '' : (' target="' . $displayData['target'] . '"'); +$onclick = empty($displayData['onclick']) ? '' : (' onclick="' . $displayData['onclick'] . '"'); + +if (isset($displayData['ajaxurl'])) { + $dataUrl = 'data-url="' . $displayData['ajaxurl'] . '"'; +} else { + $dataUrl = ''; +} + +// The title for the link (a11y) +$title = empty($displayData['title']) ? '' : (' title="' . $this->escape($displayData['title']) . '"'); + +// The information +$text = empty($displayData['text']) ? '' : ('' . $displayData['text'] . ''); + +// Make the class string + +// depending on status, determine additional class +$filterStatus = 'healthy'; // Default to "healthy" +if (isset($displayData['status'])) { + switch ($displayData['status']) { + case 'success': + $class = 'success'; + $filterStatus = 'healthy'; + break; + case 'warning': + $class = 'warning'; + $filterStatus = 'warning'; + break; + case 'error': + $class = 'danger'; + $filterStatus = 'critical'; + break; + default: + $class = 'info'; + $filterStatus = 'healthy'; + } +} +$class .= empty($displayData['class']) ? '' : (' ' . $this->escape($displayData['class'])); +?> + +
  • +
      +
    • + +
    • + + + class="" href=""> +
      +
      + +
      <?php echo empty($displayData['title']) ? '' : $this->escape($displayData['title']); ?>
      + + + +
      + +
      aria-hidden="true"> + +
      +
      + + + +
      +
      +
      + +
      +
      +
      + + +
      + + + + +
      + +
      + + +
    • + + +
    +
  • + diff --git a/layouts/joomla/healthchecks/leading.php b/layouts/joomla/healthchecks/leading.php new file mode 100644 index 00000000000..679b0517944 --- /dev/null +++ b/layouts/joomla/healthchecks/leading.php @@ -0,0 +1,15 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +if (isset($displayData['info'])) { + echo $displayData['info']; +} diff --git a/layouts/joomla/healthchecks/list.php b/layouts/joomla/healthchecks/list.php new file mode 100644 index 00000000000..747aeead342 --- /dev/null +++ b/layouts/joomla/healthchecks/list.php @@ -0,0 +1,62 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +/** + * Layout variables + * ----------------- + * @var array $items Array of items to display + * @var string $listId Optional list ID + * @var string $listClass Optional additional CSS classes + * @var string $itemClass Optional CSS class for list items + * @var string $type List type: 'ul' (default), 'ol', or 'div' + * @var callable $renderer Optional callback function to render each item + */ + +// Extract layout data +$items = $displayData['items'] ?? []; +$listId = $displayData['id'] ?? ''; +$listClass = $displayData['class'] ?? ''; +$itemClass = $displayData['itemClass'] ?? ''; +$type = $displayData['type'] ?? 'ul'; +$renderer = $displayData['renderer'] ?? null; + +// Build CSS classes +$cssClasses = ['list']; +if (!empty($listClass)) { + $cssClasses[] = $listClass; +} + +// Determine the wrapper tag based on type +$wrapperTag = in_array($type, ['ul', 'ol', 'div']) ? $type : 'ul'; +$itemTag = ($wrapperTag === 'div') ? 'div' : 'li'; +?> +< class=""> + $item) : ?> + <> + title ?? $item->name ?? $item->label ?? 'Item ' . ($index + 1), ENT_QUOTES, 'UTF-8'); + } elseif (is_array($item)) { + echo htmlspecialchars($item['title'] ?? $item['name'] ?? $item['label'] ?? 'Item ' . ($index + 1), ENT_QUOTES, 'UTF-8'); + } else { + echo htmlspecialchars($item, ENT_QUOTES, 'UTF-8'); + } + } + ?> + > + +> diff --git a/layouts/joomla/healthchecks/table.php b/layouts/joomla/healthchecks/table.php new file mode 100644 index 00000000000..d8456764e51 --- /dev/null +++ b/layouts/joomla/healthchecks/table.php @@ -0,0 +1,99 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +/** + * Layout variables + * ----------------- + * @var array $columns Array of column definitions + * @var array $data Array of data rows + * @var string $tableId Optional table ID + * @var string $tableClass Optional additional CSS classes + * @var string $caption Optional table caption for accessibility + * @var bool $striped Whether to add striped rows (default: true) + * @var bool $hover Whether to add hover effect (default: true) + * @var bool $responsive Whether to make table responsive (default: true) + */ + +// Extract layout data +$columns = $displayData['columns'] ?? []; +$data = $displayData['data'] ?? []; +$helper = $displayData['helper'] ?? null; +$tableId = $displayData['id'] ?? ''; +$tableClass = $displayData['class'] ?? ''; +$caption = $displayData['caption'] ?? ''; +$striped = $displayData['striped'] ?? true; +$hover = $displayData['hover'] ?? true; + +// Build table CSS classes +$cssClasses = ['table', 'mt-3']; +if ($striped) { + //$cssClasses[] = 'table-striped'; +} +if ($hover) { + $cssClasses[] = 'table-hover'; +} +if (!empty($tableClass)) { + $cssClasses[] = $tableClass; +} +?> +
    + > + + + + + + + + + + + + + $item) : ?> + + $column) : ?> + + + > + renderTableCellContent($column, $item, $rowIndex); + } else { + // Fallback to basic text rendering + $key = $column['key'] ?? ''; + $value = is_object($item) ? ($item->$key ?? '') : ($item[$key] ?? ''); + echo htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + ?> + + + + + +
    + > + +
    +
    diff --git a/libraries/src/HTML/Helpers/HealthChecks.php b/libraries/src/HTML/Helpers/HealthChecks.php new file mode 100644 index 00000000000..669f3843e70 --- /dev/null +++ b/libraries/src/HTML/Helpers/HealthChecks.php @@ -0,0 +1,373 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\HTML\Helpers; + +use Joomla\CMS\Factory; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Layout\FileLayout; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Utility class for health checks. + * + * @since __DEPLOY_VERSION__ + */ +abstract class HealthChecks +{ + /** + * Method to generate html code for a list of gauges + * + * @param array $gauges Array of gauges + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function gauges($gauges) + { + if (empty($gauges)) { + return ''; + } + + $html = []; + + foreach ($gauges as $gauge) { + $html[] = HTMLHelper::_('healthchecks.gauge', $gauge); + } + + return implode($html); + } + + /** + * Method to generate html code for a gauge + * + * @param array $gauge Gauge properties + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function gauge($gauge) + { + if (!static::canAccess($gauge)) { + return ''; + } + + // Instantiate a new FileLayout instance and render the layout + $layout = new FileLayout('joomla.healthchecks.gauge'); + + return $layout->render($gauge); + } + + /** + * Method to generate html code for a list of buttons + * + * @param array $buttons Array of buttons + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function buttons($buttons) + { + if (empty($buttons)) { + return ''; + } + + $html = []; + + foreach ($buttons as $button) { + $html[] = HTMLHelper::_('healthchecks.button', $button); + } + + return implode($html); + } + + /** + * Method to generate html code for a list of buttons + * + * @param array $button Button properties + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function button($button) + { + if (!static::canAccess($button)) { + return ''; + } + + // Instantiate a new FileLayout instance and render the layout + $layout = new FileLayout('joomla.healthchecks.icon'); + + return $layout->render($button); + } + + /** + * Method to generate html code for a list of tables + * + * @param array $tables Array of tables + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function tables($tables) + { + if (empty($tables)) { + return ''; + } + + $html = []; + + foreach ($tables as $table) { + $html[] = HTMLHelper::_('healthchecks.table', $table); + } + + return implode($html); + } + + /** + * Method to generate html code for a table + * + * @param array $table Table properties + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function table($table) + { + if (!static::canAccess($table)) { + return ''; + } + + // Instantiate a new FileLayout instance and render the layout + $layout = new FileLayout('joomla.healthchecks.table'); + + return $layout->render($table); + } + + /** + * Method to generate html code for a list of lists + * + * @param array $lists Array of lists + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function lists($lists) + { + if (empty($lists)) { + return ''; + } + + $html = []; + + foreach ($lists as $list) { + $html[] = HTMLHelper::_('healthchecks.list', $list); + } + + return implode($html); + } + + /** + * Method to generate html code for a list + * + * @param array $list List properties + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function list($list) + { + if (!static::canAccess($list)) { + return ''; + } + + // Instantiate a new FileLayout instance and render the layout + $layout = new FileLayout('joomla.healthchecks.list'); + + return $layout->render($list); + } + + /** + * Method to generate html code for a list of reports + * + * @param array $reports Array of reports + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function reports($reports) + { + if (empty($reports)) { + return ''; + } + + $html = []; + + foreach ($reports as $report) { + $html[] = HTMLHelper::_('healthchecks.report', $report); + } + + return implode($html); + } + + /** + * Method to generate html code for a report + * + * @param array $report Report properties + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function report($report) + { + if (!static::canAccess($report)) { + return ''; + } + + // Instantiate a new FileLayout instance and render the layout + $layout = new FileLayout('joomla.healthchecks.report'); + + return $layout->render($report); + } + + /** + * Method to generate html code for a set of leading information + * + * @param array $reports Array of leading information + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function leadings($leadings) + { + if (empty($leadings)) { + return ''; + } + + $html = []; + + foreach ($leadings as $leading) { + $html[] = HTMLHelper::_('healthchecks.leading', $leading); + } + + return implode($html); + } + + /** + * Method to generate html code for leading information + * + * @param array $leading Leading properties + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function leading($leading) + { + if (!static::canAccess($leading)) { + return ''; + } + + // Instantiate a new FileLayout instance and render the layout + $layout = new FileLayout('joomla.healthchecks.leading'); + + return $layout->render($leading); + } + + /** + * Method to generate html code for a set of footer information + * + * @param array $reports Array of footer information + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function footers($footers) + { + if (empty($footers)) { + return ''; + } + + $html = []; + + foreach ($footers as $footer) { + $html[] = HTMLHelper::_('healthchecks.footer', $footer); + } + + return implode($html); + } + + /** + * Method to generate html code for footer information + * + * @param array $footer Footer properties + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function footer($footer) + { + if (!static::canAccess($footer)) { + return ''; + } + + // Instantiate a new FileLayout instance and render the layout + $layout = new FileLayout('joomla.healthchecks.footer'); + + return $layout->render($footer); + } + + /** + * Check if an item can be accessed based on access permissions + * + * @param array $item Item with access properties + * + * @return bool True if access is allowed, false otherwise + * + * @since __DEPLOY_VERSION__ + */ + protected static function canAccess($item) + { + if (!isset($item['access'])) { + return true; + } + + if (\is_bool($item['access'])) { + return $item['access']; + } + + // Get the user object to verify permissions + $user = Factory::getApplication()->getIdentity(); + + // Take each pair of permission, context values. + for ($i = 0, $n = \count($item['access']); $i < $n; $i += 2) { + if (!$user->authorise($item['access'][$i], $item['access'][$i + 1])) { + return false; + } + } + + return true; + } +} diff --git a/libraries/src/HTML/Registry.php b/libraries/src/HTML/Registry.php index 2ca78318c3f..7409372cffb 100644 --- a/libraries/src/HTML/Registry.php +++ b/libraries/src/HTML/Registry.php @@ -43,6 +43,7 @@ final class Registry 'form' => Helpers\Form::class, 'formbehavior' => Helpers\FormBehavior::class, 'grid' => Helpers\Grid::class, + 'healthchecks' => Helpers\HealthChecks::class, 'icons' => Helpers\Icons::class, 'jgrid' => Helpers\JGrid::class, 'jquery' => Helpers\Jquery::class,